diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 1a4cf384663..58add31d6bb 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -4,7 +4,33 @@ import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { createAssetEnvironmentAtoms } from "./assets.ts"; +import { + createAssetEnvironmentAtoms, + InvalidAssetCollectionKeyError, + parseAssetCollectionKey, +} from "./assets.ts"; + +describe("asset collection keys", () => { + it("preserves malformed JSON and its native cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseAssetCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidAssetCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.any(SyntaxError) }); + }); + + it("rejects invalid asset collection shapes", () => { + const key = JSON.stringify(["environment-1", [{ _tag: "unknown" }]]); + + expect(() => parseAssetCollectionKey(key)).toThrowError(InvalidAssetCollectionKeyError); + }); +}); describe("createAssetEnvironmentAtoms", () => { it("keys asset URL queries by environment and resource", () => { diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 6863de9055f..e407f5d0028 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,4 +1,5 @@ -import { EnvironmentId, type AssetResource, WS_METHODS } from "@t3tools/contracts"; +import { AssetResource, EnvironmentId, WS_METHODS } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -8,6 +9,32 @@ const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; +export class InvalidAssetCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidAssetCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid asset collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeAssetCollectionKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.Array(AssetResource)]), +); + +export function parseAssetCollectionKey( + key: string, +): readonly [EnvironmentId, ReadonlyArray] { + try { + return decodeAssetCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidAssetCollectionKeyError({ key, cause }); + } +} + export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { try { return new URL(relativeUrl, httpBaseUrl).toString(); @@ -27,8 +54,7 @@ export function createAssetEnvironmentAtoms( refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, }); const createUrlsFamily = Atom.family((key: string) => { - const [rawEnvironmentId, resources] = JSON.parse(key) as [string, ReadonlyArray]; - const environmentId = EnvironmentId.make(rawEnvironmentId); + const [environmentId, resources] = parseAssetCollectionKey(key); return Atom.make((get) => resources.map((resource) => get( diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 2bdb8f84250..c772d134a67 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -11,6 +11,14 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { + InvalidScopedProjectKeyError, + InvalidScopedProjectRefCollectionKeyError, + InvalidScopedThreadKeyError, + parseProjectKey, + parseProjectRefCollectionKey, + parseThreadKey, +} from "./entities.ts"; import type { EnvironmentShellState } from "./shell.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; @@ -25,6 +33,56 @@ const OTHER_PROJECT_ID = ProjectId.make("project-2"); const THREAD_ID = ThreadId.make("thread-1"); const OTHER_THREAD_ID = ThreadId.make("thread-2"); +describe("scoped entity keys", () => { + it("preserves an invalid project key as structured error data", () => { + const key = "missing-project-key-separator"; + let error: unknown; + + try { + parseProjectKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedProjectKeyError({ key })); + }); + + it("preserves an invalid thread key as structured error data", () => { + const key = "missing-thread-key-separator"; + let error: unknown; + + try { + parseThreadKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toEqual(new InvalidScopedThreadKeyError({ key })); + }); + + it("preserves malformed project reference collection input and its cause", () => { + const key = "not-json"; + let error: unknown; + + try { + parseProjectRefCollectionKey(key); + } catch (cause) { + error = cause; + } + + expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError); + expect(error).toMatchObject({ key, cause: expect.anything() }); + }); + + it("rejects invalid project reference collection shapes", () => { + const key = JSON.stringify([["environment-1"]]); + + expect(() => parseProjectRefCollectionKey(key)).toThrowError( + InvalidScopedProjectRefCollectionKeyError, + ); + }); +}); + const THREAD_SHELL = { id: THREAD_ID, projectId: PROJECT_ID, diff --git a/packages/client-runtime/src/state/entities.ts b/packages/client-runtime/src/state/entities.ts index 4bcf16f7cfd..e90f31d6da4 100644 --- a/packages/client-runtime/src/state/entities.ts +++ b/packages/client-runtime/src/state/entities.ts @@ -5,6 +5,45 @@ import { type ScopedProjectRef, type ScopedThreadRef, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class InvalidScopedProjectKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped project atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedThreadKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedThreadKeyError", + { + key: Schema.String, + }, +) { + override get message(): string { + return `Invalid scoped thread atom key: ${JSON.stringify(this.key)}.`; + } +} + +export class InvalidScopedProjectRefCollectionKeyError extends Schema.TaggedErrorClass()( + "InvalidScopedProjectRefCollectionKeyError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Invalid scoped project reference collection atom key: ${JSON.stringify(this.key)}.`; + } +} + +const decodeProjectRefCollectionKey = Schema.decodeUnknownSync( + Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +); export function projectKey(ref: ScopedProjectRef): string { return `${ref.environmentId}\u0000${ref.projectId}`; @@ -21,7 +60,7 @@ export function projectRefCollectionKey(refs: ReadonlyArray): export function parseProjectKey(key: string): ScopedProjectRef { const separator = key.indexOf("\u0000"); if (separator < 0) { - throw new Error("Invalid scoped project atom key."); + throw new InvalidScopedProjectKeyError({ key }); } return { environmentId: EnvironmentId.make(key.slice(0, separator)), @@ -30,7 +69,12 @@ export function parseProjectKey(key: string): ScopedProjectRef { } export function parseProjectRefCollectionKey(key: string): ReadonlyArray { - const entries = JSON.parse(key) as ReadonlyArray; + let entries: ReadonlyArray; + try { + entries = decodeProjectRefCollectionKey(JSON.parse(key)); + } catch (cause) { + throw new InvalidScopedProjectRefCollectionKeyError({ key, cause }); + } return entries.map(([environmentId, projectId]) => ({ environmentId: EnvironmentId.make(environmentId), projectId: ProjectId.make(projectId), @@ -40,7 +84,7 @@ export function parseProjectRefCollectionKey(key: string): ReadonlyArray( runtime: Atom.AtomRuntime, ) { const family = Atom.family((key: string) => { - const { environmentId, threadId } = parseThreadAtomKey(key); + const { environmentId, threadId } = parseThreadKey(key); return runtime .atom(threadStateChanges(environmentId, threadId), { initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, @@ -262,7 +243,7 @@ export function createEnvironmentThreadStateAtoms( return { stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => - family(threadAtomKey(environmentId, threadId)), + family(threadKey({ environmentId, threadId })), }; }