authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:12:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:12:45-07:00
logd1339f38462c206d65d3c8d8396c1ddfdfbb807c
tree6f5a02f2ff816b55cde3ebf9c5a97951601fc6f8
parentfaccdd4ca9511204144f3ec8d696a8993d878946
signature Signed by PGP key B5690EEEBB952194

[codex] Structure client state key errors (#3374)

Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 164 insertions(+), 29 deletions(-)

packages/client-runtime/src/state/assets.test.ts+27-1
...@@ -4,7 +4,33 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,33 @@ import * as Layer from "effect/Layer";
4import { Atom } from "effect/unstable/reactivity";4import { Atom } from "effect/unstable/reactivity";
55
6import type { EnvironmentRegistry } from "../connection/registry.ts";6import type { EnvironmentRegistry } from "../connection/registry.ts";
7import { createAssetEnvironmentAtoms } from "./assets.ts";7import {
8 createAssetEnvironmentAtoms,
9 InvalidAssetCollectionKeyError,
10 parseAssetCollectionKey,
11} from "./assets.ts";
12
13describe("asset collection keys", () => {
14 it("preserves malformed JSON and its native cause", () => {
15 const key = "not-json";
16 let error: unknown;
17
18 try {
19 parseAssetCollectionKey(key);
20 } catch (cause) {
21 error = cause;
22 }
23
24 expect(error).toBeInstanceOf(InvalidAssetCollectionKeyError);
25 expect(error).toMatchObject({ key, cause: expect.any(SyntaxError) });
26 });
27
28 it("rejects invalid asset collection shapes", () => {
29 const key = JSON.stringify(["environment-1", [{ _tag: "unknown" }]]);
30
31 expect(() => parseAssetCollectionKey(key)).toThrowError(InvalidAssetCollectionKeyError);
32 });
33});
834
9describe("createAssetEnvironmentAtoms", () => {35describe("createAssetEnvironmentAtoms", () => {
10 it("keys asset URL queries by environment and resource", () => {36 it("keys asset URL queries by environment and resource", () => {
packages/client-runtime/src/state/assets.ts+29-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts";1import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
2import { Atom } from "effect/unstable/reactivity";3import { Atom } from "effect/unstable/reactivity";
34
4import type { EnvironmentRegistry } from "../connection/registry.ts";5import type { EnvironmentRegistry } from "../connection/registry.ts";
...@@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;...@@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
8const ASSET_URL_STALE_TIME_MS = 5 * 60_000;9const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
9const ASSET_URL_IDLE_TTL_MS = 60 * 60_000;10const ASSET_URL_IDLE_TTL_MS = 60 * 60_000;
1011
12export class InvalidAssetCollectionKeyError extends Schema.TaggedErrorClass<InvalidAssetCollectionKeyError>()(
13 "InvalidAssetCollectionKeyError",
14 {
15 key: Schema.String,
16 cause: Schema.Defect(),
17 },
18) {
19 override get message(): string {
20 return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`;
21 }
22}
23
24const decodeAssetCollectionKey = Schema.decodeUnknownSync(
25 Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]),
26);
27
28export function parseAssetCollectionKey(
29 key: string,
30): readonly [EnvironmentId, ReadonlyArray<AssetResource>] {
31 try {
32 return decodeAssetCollectionKey(JSON.parse(key));
33 } catch (cause) {
34 throw new InvalidAssetCollectionKeyError({ key, cause });
35 }
36}
37
11export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {38export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {
12 try {39 try {
13 return new URL(relativeUrl, httpBaseUrl).toString();40 return new URL(relativeUrl, httpBaseUrl).toString();
...@@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms<R, E>(...@@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms<R, E>(
27 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,54 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
28 });55 });
29 const createUrlsFamily = Atom.family((key: string) => {56 const createUrlsFamily = Atom.family((key: string) => {
30 const [rawEnvironmentId, resources] = JSON.parse(key) as [string, ReadonlyArray<AssetResource>];57 const [environmentId, resources] = parseAssetCollectionKey(key);
31 const environmentId = EnvironmentId.make(rawEnvironmentId);
32 return Atom.make((get) =>58 return Atom.make((get) =>
33 resources.map((resource) =>59 resources.map((resource) =>
34 get(60 get(
packages/client-runtime/src/state/entities.test.ts+58
...@@ -11,6 +11,14 @@ import * as Option from "effect/Option";...@@ -11,6 +11,14 @@ import * as Option from "effect/Option";
11import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";11import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1212
13import { PrimaryConnectionTarget } from "../connection/model.ts";13import { PrimaryConnectionTarget } from "../connection/model.ts";
14import {
15 InvalidScopedProjectKeyError,
16 InvalidScopedProjectRefCollectionKeyError,
17 InvalidScopedThreadKeyError,
18 parseProjectKey,
19 parseProjectRefCollectionKey,
20 parseThreadKey,
21} from "./entities.ts";
14import type { EnvironmentShellState } from "./shell.ts";22import type { EnvironmentShellState } from "./shell.ts";
15import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";23import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
16import { createEnvironmentProjectAtoms } from "./projectEntities.ts";24import { createEnvironmentProjectAtoms } from "./projectEntities.ts";
...@@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2");...@@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2");
25const THREAD_ID = ThreadId.make("thread-1");33const THREAD_ID = ThreadId.make("thread-1");
26const OTHER_THREAD_ID = ThreadId.make("thread-2");34const OTHER_THREAD_ID = ThreadId.make("thread-2");
2735
36describe("scoped entity keys", () => {
37 it("preserves an invalid project key as structured error data", () => {
38 const key = "missing-project-key-separator";
39 let error: unknown;
40
41 try {
42 parseProjectKey(key);
43 } catch (cause) {
44 error = cause;
45 }
46
47 expect(error).toEqual(new InvalidScopedProjectKeyError({ key }));
48 });
49
50 it("preserves an invalid thread key as structured error data", () => {
51 const key = "missing-thread-key-separator";
52 let error: unknown;
53
54 try {
55 parseThreadKey(key);
56 } catch (cause) {
57 error = cause;
58 }
59
60 expect(error).toEqual(new InvalidScopedThreadKeyError({ key }));
61 });
62
63 it("preserves malformed project reference collection input and its cause", () => {
64 const key = "not-json";
65 let error: unknown;
66
67 try {
68 parseProjectRefCollectionKey(key);
69 } catch (cause) {
70 error = cause;
71 }
72
73 expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError);
74 expect(error).toMatchObject({ key, cause: expect.anything() });
75 });
76
77 it("rejects invalid project reference collection shapes", () => {
78 const key = JSON.stringify([["environment-1"]]);
79
80 expect(() => parseProjectRefCollectionKey(key)).toThrowError(
81 InvalidScopedProjectRefCollectionKeyError,
82 );
83 });
84});
85
28const THREAD_SHELL = {86const THREAD_SHELL = {
29 id: THREAD_ID,87 id: THREAD_ID,
30 projectId: PROJECT_ID,88 projectId: PROJECT_ID,
packages/client-runtime/src/state/entities.ts+47-3
...@@ -5,6 +5,45 @@ import {...@@ -5,6 +5,45 @@ import {
5 type ScopedProjectRef,5 type ScopedProjectRef,
6 type ScopedThreadRef,6 type ScopedThreadRef,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9
10export class InvalidScopedProjectKeyError extends Schema.TaggedErrorClass<InvalidScopedProjectKeyError>()(
11 "InvalidScopedProjectKeyError",
12 {
13 key: Schema.String,
14 },
15) {
16 override get message(): string {
17 return `Invalid scoped project atom key: ${JSON.stringify(this.key)}.`;
18 }
19}
20
21export class InvalidScopedThreadKeyError extends Schema.TaggedErrorClass<InvalidScopedThreadKeyError>()(
22 "InvalidScopedThreadKeyError",
23 {
24 key: Schema.String,
25 },
26) {
27 override get message(): string {
28 return `Invalid scoped thread atom key: ${JSON.stringify(this.key)}.`;
29 }
30}
31
32export class InvalidScopedProjectRefCollectionKeyError extends Schema.TaggedErrorClass<InvalidScopedProjectRefCollectionKeyError>()(
33 "InvalidScopedProjectRefCollectionKeyError",
34 {
35 key: Schema.String,
36 cause: Schema.Defect(),
37 },
38) {
39 override get message(): string {
40 return `Invalid scoped project reference collection atom key: ${JSON.stringify(this.key)}.`;
41 }
42}
43
44const decodeProjectRefCollectionKey = Schema.decodeUnknownSync(
45 Schema.Array(Schema.Tuple([Schema.String, Schema.String])),
46);
847
9export function projectKey(ref: ScopedProjectRef): string {48export function projectKey(ref: ScopedProjectRef): string {
10 return `${ref.environmentId}\u0000${ref.projectId}`;49 return `${ref.environmentId}\u0000${ref.projectId}`;
...@@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray<ScopedProjectRef>):...@@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray<ScopedProjectRef>):
21export function parseProjectKey(key: string): ScopedProjectRef {60export function parseProjectKey(key: string): ScopedProjectRef {
22 const separator = key.indexOf("\u0000");61 const separator = key.indexOf("\u0000");
23 if (separator < 0) {62 if (separator < 0) {
24 throw new Error("Invalid scoped project atom key.");63 throw new InvalidScopedProjectKeyError({ key });
25 }64 }
26 return {65 return {
27 environmentId: EnvironmentId.make(key.slice(0, separator)),66 environmentId: EnvironmentId.make(key.slice(0, separator)),
...@@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef {...@@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef {
30}69}
3170
32export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedProjectRef> {71export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedProjectRef> {
33 const entries = JSON.parse(key) as ReadonlyArray<readonly [string, string]>;72 let entries: ReadonlyArray<readonly [string, string]>;
73 try {
74 entries = decodeProjectRefCollectionKey(JSON.parse(key));
75 } catch (cause) {
76 throw new InvalidScopedProjectRefCollectionKeyError({ key, cause });
77 }
34 return entries.map(([environmentId, projectId]) => ({78 return entries.map(([environmentId, projectId]) => ({
35 environmentId: EnvironmentId.make(environmentId),79 environmentId: EnvironmentId.make(environmentId),
36 projectId: ProjectId.make(projectId),80 projectId: ProjectId.make(projectId),
...@@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedP...@@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedP
40export function parseThreadKey(key: string): ScopedThreadRef {84export function parseThreadKey(key: string): ScopedThreadRef {
41 const separator = key.indexOf("\u0000");85 const separator = key.indexOf("\u0000");
42 if (separator < 0) {86 if (separator < 0) {
43 throw new Error("Invalid scoped thread atom key.");87 throw new InvalidScopedThreadKeyError({ key });
44 }88 }
45 return {89 return {
46 environmentId: EnvironmentId.make(key.slice(0, separator)),90 environmentId: EnvironmentId.make(key.slice(0, separator)),
packages/client-runtime/src/state/threads.ts+3-22
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import {1import {
2 EnvironmentId,
3 ORCHESTRATION_WS_METHODS,2 ORCHESTRATION_WS_METHODS,
4 ThreadId,
5 type EnvironmentId as EnvironmentIdType,3 type EnvironmentId as EnvironmentIdType,
6 type OrchestrationThread,4 type OrchestrationThread,
7 type OrchestrationThreadStreamItem,5 type OrchestrationThreadStreamItem,
...@@ -20,6 +18,7 @@ import { connectionProjectionPhase } from "../connection/model.ts";...@@ -20,6 +18,7 @@ import { connectionProjectionPhase } from "../connection/model.ts";
20import { EnvironmentSupervisor } from "../connection/supervisor.ts";18import { EnvironmentSupervisor } from "../connection/supervisor.ts";
21import { EnvironmentCacheStore } from "../platform/persistence.ts";19import { EnvironmentCacheStore } from "../platform/persistence.ts";
22import { subscribe } from "../rpc/client.ts";20import { subscribe } from "../rpc/client.ts";
21import { parseThreadKey, threadKey } from "./entities.ts";
23import { applyThreadDetailEvent } from "./threadReducer.ts";22import { applyThreadDetailEvent } from "./threadReducer.ts";
24import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";23import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
25import { followStreamInEnvironment } from "./runtime.ts";24import { followStreamInEnvironment } from "./runtime.ts";
...@@ -227,29 +226,11 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T...@@ -227,29 +226,11 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T
227 );226 );
228}227}
229228
230function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string {
231 return `${environmentId}\u0000${threadId}`;
232}
233
234function parseThreadAtomKey(key: string): {
235 readonly environmentId: EnvironmentIdType;
236 readonly threadId: ThreadIdType;
237} {
238 const separator = key.indexOf("\u0000");
239 if (separator < 0) {
240 throw new Error("Invalid environment thread atom key.");
241 }
242 return {
243 environmentId: EnvironmentId.make(key.slice(0, separator)),
244 threadId: ThreadId.make(key.slice(separator + 1)),
245 };
246}
247
248export function createEnvironmentThreadStateAtoms<R, E>(229export function createEnvironmentThreadStateAtoms<R, E>(
249 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,230 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
250) {231) {
251 const family = Atom.family((key: string) => {232 const family = Atom.family((key: string) => {
252 const { environmentId, threadId } = parseThreadAtomKey(key);233 const { environmentId, threadId } = parseThreadKey(key);
253 return runtime234 return runtime
254 .atom(threadStateChanges(environmentId, threadId), {235 .atom(threadStateChanges(environmentId, threadId), {
255 initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,236 initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
...@@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms<R, E>(...@@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms<R, E>(
262243
263 return {244 return {
264 stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) =>245 stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) =>
265 family(threadAtomKey(environmentId, threadId)),246 family(threadKey({ environmentId, threadId })),
266 };247 };
267}248}
268249