| 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,7 +4,33 @@ import * as Layer from "effect/Layer"; |
| 4 | import { Atom } from "effect/unstable/reactivity"; | 4 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | 5 | ||
| 6 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 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 | describe("createAssetEnvironmentAtoms", () => { | 35 | describe("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 @@ |
| 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 | import { Atom } from "effect/unstable/reactivity"; | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 4 | ||
| 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 5 | import 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; |
| 8 | const ASSET_URL_STALE_TIME_MS = 5 * 60_000; | 9 | const ASSET_URL_STALE_TIME_MS = 5 * 60_000; |
| 9 | const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; | 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 | export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { | 38 | export 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"; |
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 12 | 12 | ||
| 13 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | 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 | import type { EnvironmentShellState } from "./shell.ts"; | 22 | import type { EnvironmentShellState } from "./shell.ts"; |
| 15 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; | 23 | import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; |
| 16 | import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; | 24 | import { 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"); |
| 25 | const THREAD_ID = ThreadId.make("thread-1"); | 33 | const THREAD_ID = ThreadId.make("thread-1"); |
| 26 | const OTHER_THREAD_ID = ThreadId.make("thread-2"); | 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 | const THREAD_SHELL = { | 86 | const 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"; |
| 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 | export function projectKey(ref: ScopedProjectRef): string { | 48 | export 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>): |
| 21 | export function parseProjectKey(key: string): ScopedProjectRef { | 60 | export 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 | } |
| 31 | 70 | ||
| 32 | export function parseProjectRefCollectionKey(key: string): ReadonlyArray<ScopedProjectRef> { | 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 | 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 |
| 40 | export function parseThreadKey(key: string): ScopedThreadRef { | 84 | export 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 20 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 18 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 21 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | 19 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 22 | import { subscribe } from "../rpc/client.ts"; | 20 | import { subscribe } from "../rpc/client.ts"; |
| 21 | import { parseThreadKey, threadKey } from "./entities.ts"; | ||
| 23 | import { applyThreadDetailEvent } from "./threadReducer.ts"; | 22 | import { applyThreadDetailEvent } from "./threadReducer.ts"; |
| 24 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; | 23 | import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; |
| 25 | import { followStreamInEnvironment } from "./runtime.ts"; | 24 | import { 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 | } |
| 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 | export function createEnvironmentThreadStateAtoms<R, E>( | 229 | export 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 runtime | 234 | 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>( |
| 262 | 243 | ||
| 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 | } |
| 268 | 249 |