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";
44import { Atom } from "effect/unstable/reactivity";
55
66import 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
935describe("createAssetEnvironmentAtoms", () => {
1036 it("keys asset URL queries by environment and resource", () => {
packages/client-runtime/src/state/assets.ts+29-3
......@@ -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";
23import { Atom } from "effect/unstable/reactivity";
34
45import type { EnvironmentRegistry } from "../connection/registry.ts";
......@@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
89const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
910const 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
1138export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null {
1239 try {
1340 return new URL(relativeUrl, httpBaseUrl).toString();
......@@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms<R, E>(
2754 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
2855 });
2956 const createUrlsFamily = Atom.family((key: string) => {
30 const [rawEnvironmentId, resources] = JSON.parse(key) as [string, ReadonlyArray<AssetResource>];
31 const environmentId = EnvironmentId.make(rawEnvironmentId);
57 const [environmentId, resources] = parseAssetCollectionKey(key);
3258 return Atom.make((get) =>
3359 resources.map((resource) =>
3460 get(
packages/client-runtime/src/state/entities.test.ts+58
......@@ -11,6 +11,14 @@ import * as Option from "effect/Option";
1111import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1212
1313import { PrimaryConnectionTarget } from "../connection/model.ts";
14import {
15 InvalidScopedProjectKeyError,
16 InvalidScopedProjectRefCollectionKeyError,
17 InvalidScopedThreadKeyError,
18 parseProjectKey,
19 parseProjectRefCollectionKey,
20 parseThreadKey,
21} from "./entities.ts";
1422import type { EnvironmentShellState } from "./shell.ts";
1523import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts";
1624import { createEnvironmentProjectAtoms } from "./projectEntities.ts";
......@@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2");
2533const THREAD_ID = ThreadId.make("thread-1");
2634const 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
2886const THREAD_SHELL = {
2987 id: THREAD_ID,
3088 projectId: PROJECT_ID,
packages/client-runtime/src/state/entities.ts+47-3
......@@ -5,6 +5,45 @@ import {
55 type ScopedProjectRef,
66 type ScopedThreadRef,
77} 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
948export function projectKey(ref: ScopedProjectRef): string {
1049 return `${ref.environmentId}\u0000${ref.projectId}`;
......@@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray<ScopedProjectRef>):
2160export function parseProjectKey(key: string): ScopedProjectRef {
2261 const separator = key.indexOf("\u0000");
2362 if (separator < 0) {
24 throw new Error("Invalid scoped project atom key.");
63 throw new InvalidScopedProjectKeyError({ key });
2564 }
2665 return {
2766 environmentId: EnvironmentId.make(key.slice(0, separator)),
......@@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef {
3069}
3170
3271export 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 }
3478 return entries.map(([environmentId, projectId]) => ({
3579 environmentId: EnvironmentId.make(environmentId),
3680 projectId: ProjectId.make(projectId),
......@@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedP
4084export function parseThreadKey(key: string): ScopedThreadRef {
4185 const separator = key.indexOf("\u0000");
4286 if (separator < 0) {
43 throw new Error("Invalid scoped thread atom key.");
87 throw new InvalidScopedThreadKeyError({ key });
4488 }
4589 return {
4690 environmentId: EnvironmentId.make(key.slice(0, separator)),
packages/client-runtime/src/state/threads.ts+3-22
......@@ -1,7 +1,5 @@
11import {
2 EnvironmentId,
32 ORCHESTRATION_WS_METHODS,
4 ThreadId,
53 type EnvironmentId as EnvironmentIdType,
64 type OrchestrationThread,
75 type OrchestrationThreadStreamItem,
......@@ -20,6 +18,7 @@ import { connectionProjectionPhase } from "../connection/model.ts";
2018import { EnvironmentSupervisor } from "../connection/supervisor.ts";
2119import { EnvironmentCacheStore } from "../platform/persistence.ts";
2220import { subscribe } from "../rpc/client.ts";
21import { parseThreadKey, threadKey } from "./entities.ts";
2322import { applyThreadDetailEvent } from "./threadReducer.ts";
2423import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
2524import { followStreamInEnvironment } from "./runtime.ts";
......@@ -227,29 +226,11 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T
227226 );
228227}
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
248229export function createEnvironmentThreadStateAtoms<R, E>(
249230 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
250231) {
251232 const family = Atom.family((key: string) => {
252 const { environmentId, threadId } = parseThreadAtomKey(key);
233 const { environmentId, threadId } = parseThreadKey(key);
253234 return runtime
254235 .atom(threadStateChanges(environmentId, threadId), {
255236 initialValue: EMPTY_ENVIRONMENT_THREAD_STATE,
......@@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms<R, E>(
262243
263244 return {
264245 stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) =>
265 family(threadAtomKey(environmentId, threadId)),
246 family(threadKey({ environmentId, threadId })),
266247 };
267248}
268249