| author | |
| committer | |
| log | d1339f38462c206d65d3c8d8396c1ddfdfbb807c |
| tree | 6f5a02f2ff816b55cde3ebf9c5a97951601fc6f8 |
| parent | faccdd4ca9511204144f3ec8d696a8993d878946 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | 5 | |
| 6 | 6 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 7 | import { createAssetEnvironmentAtoms } from "./assets.ts"; | |
| 7 | import { | |
| 8 | createAssetEnvironmentAtoms, | |
| 9 | InvalidAssetCollectionKeyError, | |
| 10 | parseAssetCollectionKey, | |
| 11 | } from "./assets.ts"; | |
| 12 | ||
| 13 | describe("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 | }); | |
| 8 | 34 | |
| 9 | 35 | describe("createAssetEnvironmentAtoms", () => { |
| 10 | 36 | it("keys asset URL queries by environment and resource", () => { |
packages/client-runtime/src/state/assets.ts+29-3| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 4 | |
| 4 | 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| ... | ... | @@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; |
| 8 | 9 | const ASSET_URL_STALE_TIME_MS = 5 * 60_000; |
| 9 | 10 | const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; |
| 10 | 11 | |
| 12 | export 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 | ||
| 24 | const decodeAssetCollectionKey = Schema.decodeUnknownSync( | |
| 25 | Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), | |
| 26 | ); | |
| 27 | ||
| 28 | export 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 | ||
| 11 | 38 | export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { |
| 12 | 39 | try { |
| 13 | 40 | return new URL(relativeUrl, httpBaseUrl).toString(); |
| ... | ... | @@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms<R, E>( |
| 27 | 54 | refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, |
| 28 | 55 | }); |
| 29 | 56 | 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); | |
| 32 | 58 | return Atom.make((get) => |
| 33 | 59 | resources.map((resource) => |
| 34 | 60 | get( |
packages/client-runtime/src/state/entities.test.ts+58| ... | ... | @@ -11,6 +11,14 @@ import * as Option from "effect/Option"; |
| 11 | 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 12 | 12 | |
| 13 | 13 | import { PrimaryConnectionTarget } from "../connection/model.ts"; |
| 14 | import { | |
| 15 | InvalidScopedProjectKeyError, | |
| 16 | InvalidScopedProjectRefCollectionKeyError, | |
| 17 | InvalidScopedThreadKeyError, | |
| 18 | parseProjectKey, | |
| 19 | parseProjectRefCollectionKey, | |
| 20 | parseThreadKey, | |
| 21 | } from "./entities.ts"; | |
| 14 | 22 | import type { EnvironmentShellState } from "./shell.ts"; |
| 15 | 23 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; |
| 16 | 24 | import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; |
| ... | ... | @@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2"); |
| 25 | 33 | const THREAD_ID = ThreadId.make("thread-1"); |
| 26 | 34 | const OTHER_THREAD_ID = ThreadId.make("thread-2"); |
| 27 | 35 | |
| 36 | describe("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 | ||
| 28 | 86 | const THREAD_SHELL = { |
| 29 | 87 | id: THREAD_ID, |
| 30 | 88 | projectId: PROJECT_ID, |
packages/client-runtime/src/state/entities.ts+47-3| ... | ... | @@ -5,6 +5,45 @@ import { |
| 5 | 5 | type ScopedProjectRef, |
| 6 | 6 | type ScopedThreadRef, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | export 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 | ||
| 21 | export 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 | ||
| 32 | export 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 | ||
| 44 | const decodeProjectRefCollectionKey = Schema.decodeUnknownSync( | |
| 45 | Schema.Array(Schema.Tuple([Schema.String, Schema.String])), | |
| 46 | ); | |
| 8 | 47 | |
| 9 | 48 | export function projectKey(ref: ScopedProjectRef): string { |
| 10 | 49 | return `${ref.environmentId}\u0000${ref.projectId}`; |
| ... | ... | @@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray<ScopedProjectRef>): |
| 21 | 60 | export function parseProjectKey(key: string): ScopedProjectRef { |
| 22 | 61 | const separator = key.indexOf("\u0000"); |
| 23 | 62 | if (separator < 0) { |
| 24 | throw new Error("Invalid scoped project atom key."); | |
| 63 | throw new InvalidScopedProjectKeyError({ key }); | |
| 25 | 64 | } |
| 26 | 65 | return { |
| 27 | 66 | environmentId: EnvironmentId.make(key.slice(0, separator)), |
| ... | ... | @@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef { |
| 30 | 69 | } |
| 31 | 70 | |
| 32 | 71 | export 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 | 78 | return entries.map(([environmentId, projectId]) => ({ |
| 35 | 79 | environmentId: EnvironmentId.make(environmentId), |
| 36 | 80 | projectId: ProjectId.make(projectId), |
| ... | ... | @@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedP |
| 40 | 84 | export function parseThreadKey(key: string): ScopedThreadRef { |
| 41 | 85 | const separator = key.indexOf("\u0000"); |
| 42 | 86 | if (separator < 0) { |
| 43 | throw new Error("Invalid scoped thread atom key."); | |
| 87 | throw new InvalidScopedThreadKeyError({ key }); | |
| 44 | 88 | } |
| 45 | 89 | return { |
| 46 | 90 | environmentId: EnvironmentId.make(key.slice(0, separator)), |
packages/client-runtime/src/state/threads.ts+3-22| ... | ... | @@ -1,7 +1,5 @@ |
| 1 | 1 | import { |
| 2 | EnvironmentId, | |
| 3 | 2 | ORCHESTRATION_WS_METHODS, |
| 4 | ThreadId, | |
| 5 | 3 | type EnvironmentId as EnvironmentIdType, |
| 6 | 4 | type OrchestrationThread, |
| 7 | 5 | type OrchestrationThreadStreamItem, |
| ... | ... | @@ -20,6 +18,7 @@ import { connectionProjectionPhase } from "../connection/model.ts"; |
| 20 | 18 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 21 | 19 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 22 | 20 | import { subscribe } from "../rpc/client.ts"; |
| 21 | import { parseThreadKey, threadKey } from "./entities.ts"; | |
| 23 | 22 | import { applyThreadDetailEvent } from "./threadReducer.ts"; |
| 24 | 23 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 25 | 24 | import { followStreamInEnvironment } from "./runtime.ts"; |
| ... | ... | @@ -227,29 +226,11 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T |
| 227 | 226 | ); |
| 228 | 227 | } |
| 229 | 228 | |
| 230 | function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string { | |
| 231 | return `${environmentId}\u0000${threadId}`; | |
| 232 | } | |
| 233 | ||
| 234 | function 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 | ||
| 248 | 229 | export function createEnvironmentThreadStateAtoms<R, E>( |
| 249 | 230 | runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, |
| 250 | 231 | ) { |
| 251 | 232 | const family = Atom.family((key: string) => { |
| 252 | const { environmentId, threadId } = parseThreadAtomKey(key); | |
| 233 | const { environmentId, threadId } = parseThreadKey(key); | |
| 253 | 234 | return runtime |
| 254 | 235 | .atom(threadStateChanges(environmentId, threadId), { |
| 255 | 236 | initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, |
| ... | ... | @@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms<R, E>( |
| 262 | 243 | |
| 263 | 244 | return { |
| 264 | 245 | stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => |
| 265 | family(threadAtomKey(environmentId, threadId)), | |
| 246 | family(threadKey({ environmentId, threadId })), | |
| 266 | 247 | }; |
| 267 | 248 | } |
| 268 | 249 |