| author | |
| committer | |
| log | 9151ea407accd1b7af575dcdd514e2b2e3fe6cbb |
| tree | 6481c440459cf3901c0253f0e3d02fb185dd6172 |
| parent | 1d6f23b51929117d5e69ded8c5c0f8e195d75d4a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 122 insertions(+), 13 deletions(-)
apps/mobile/src/connection/environment-cache-store.ts+13-6| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | ConnectionPersistenceError, | 2 | ConnectionPersistenceError, |
| 3 | EnvironmentCacheStore, | 3 | EnvironmentCacheStore, |
| 4 | encodeShellSnapshotForCache, | ||
| 4 | } from "@t3tools/client-runtime/platform"; | 5 | } from "@t3tools/client-runtime/platform"; |
| 5 | import { | 6 | import { |
| 6 | type EnvironmentId, | 7 | type EnvironmentId, |
| ... | @@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({ | ... | @@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({ |
| 56 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( | 57 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( |
| 57 | Schema.fromJsonString(StoredShellSnapshot), | 58 | Schema.fromJsonString(StoredShellSnapshot), |
| 58 | ); | 59 | ); |
| 59 | const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); | ||
| 60 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( | 60 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( |
| 61 | Schema.fromJsonString(StoredThreadSnapshot), | 61 | Schema.fromJsonString(StoredThreadSnapshot), |
| 62 | ); | 62 | ); |
| ... | @@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { | ... | @@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 136 | }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), | 136 | }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), |
| 137 | ), | 137 | ), |
| 138 | saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { | 138 | saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { |
| 139 | const payload = yield* encodeStoredShellSnapshot({ | 139 | const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( |
| 140 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | 140 | Effect.mapError((cause) => persistenceError("save-shell", cause)), |
| 141 | environmentId, | 141 | ); |
| 142 | snapshot, | 142 | const payload = yield* Effect.try({ |
| 143 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); | 143 | try: () => |
| 144 | JSON.stringify({ | ||
| 145 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 146 | environmentId, | ||
| 147 | snapshot: encodedSnapshot, | ||
| 148 | } satisfies typeof StoredShellSnapshot.Encoded), | ||
| 149 | catch: (cause) => persistenceError("save-shell", cause), | ||
| 150 | }); | ||
| 144 | yield* database | 151 | yield* database |
| 145 | .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) | 152 | .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) |
| 146 | .pipe(Effect.mapError(mapDatabaseError("save-shell"))); | 153 | .pipe(Effect.mapError(mapDatabaseError("save-shell"))); |
apps/web/src/connection/storage.test.ts+23| ... | @@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => { | ... | @@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => { |
| 87 | expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); | 87 | expect(setConnectionCatalog).toHaveBeenCalledWith("{}"); |
| 88 | }), | 88 | }), |
| 89 | ); | 89 | ); |
| 90 | |||
| 91 | it.effect("fails IndexedDB writes whose commit aborts", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | vi.stubGlobal("window", {}); | ||
| 94 | const transaction = Object.assign(new EventTarget(), { | ||
| 95 | error: null as DOMException | null, | ||
| 96 | objectStore: () => ({ | ||
| 97 | put: () => { | ||
| 98 | // A failed commit aborts the transaction without an "error" event. | ||
| 99 | queueMicrotask(() => { | ||
| 100 | transaction.error = new DOMException("Quota exceeded", "QuotaExceededError"); | ||
| 101 | transaction.dispatchEvent(new Event("abort")); | ||
| 102 | }); | ||
| 103 | }, | ||
| 104 | }), | ||
| 105 | }); | ||
| 106 | const backend = makeCatalogBackend({ transaction: () => transaction } as never); | ||
| 107 | |||
| 108 | const error = yield* backend.write("{}").pipe(Effect.flip); | ||
| 109 | |||
| 110 | expect(error.message).toContain("QuotaExceededError"); | ||
| 111 | }), | ||
| 112 | ); | ||
| 90 | }); | 113 | }); |
| 91 | 114 | ||
| 92 | describe("browser GitHub routing permissions", () => { | 115 | describe("browser GitHub routing permissions", () => { |
apps/web/src/connection/storage.ts+15-7| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | ConnectionTargetStore, | 6 | ConnectionTargetStore, |
| 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 8 | EnvironmentCacheStore, | 8 | EnvironmentCacheStore, |
| 9 | encodeShellSnapshotForCache, | ||
| 9 | putRemoteDpopTokenInCatalog, | 10 | putRemoteDpopTokenInCatalog, |
| 10 | registerConnectionInCatalog, | 11 | registerConnectionInCatalog, |
| 11 | removeCatalogValue, | 12 | removeCatalogValue, |
| ... | @@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc | ... | @@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc |
| 91 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); | 92 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); |
| 92 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); | 93 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); |
| 93 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); | 94 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); |
| 94 | const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); | ||
| 95 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); | 95 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); |
| 96 | const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); | 96 | const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); |
| 97 | const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); | 97 | const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); |
| ... | @@ -188,7 +188,9 @@ function writeDatabaseValue( | ... | @@ -188,7 +188,9 @@ function writeDatabaseValue( |
| 188 | ) { | 188 | ) { |
| 189 | return Effect.callback<void, ConnectionTransientError>((resume) => { | 189 | return Effect.callback<void, ConnectionTransientError>((resume) => { |
| 190 | const transaction = database.transaction(storeName, "readwrite"); | 190 | const transaction = database.transaction(storeName, "readwrite"); |
| 191 | transaction.addEventListener("error", () => { | 191 | // Every failed write fires "abort". A failed commit, such as |
| 192 | // QuotaExceededError, fires only "abort" and no "error". | ||
| 193 | transaction.addEventListener("abort", () => { | ||
| 192 | resume( | 194 | resume( |
| 193 | Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), | 195 | Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), |
| 194 | ); | 196 | ); |
| ... | @@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext( |
| 606 | ), | 608 | ), |
| 607 | saveShell: (environmentId, snapshot) => | 609 | saveShell: (environmentId, snapshot) => |
| 608 | Effect.gen(function* () { | 610 | Effect.gen(function* () { |
| 609 | const encoded = yield* encodeStoredShellSnapshot({ | 611 | const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot); |
| 610 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | 612 | const encoded = yield* Effect.try({ |
| 611 | environmentId, | 613 | try: () => |
| 612 | snapshot, | 614 | // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded. |
| 613 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); | 615 | JSON.stringify({ |
| 616 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 617 | environmentId, | ||
| 618 | snapshot: encodedSnapshot, | ||
| 619 | } satisfies typeof StoredShellSnapshot.Encoded), | ||
| 620 | catch: (cause) => persistenceError("save-shell", cause), | ||
| 621 | }); | ||
| 614 | yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); | 622 | yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); |
| 615 | }).pipe( | 623 | }).pipe( |
| 616 | Effect.mapError((cause) => | 624 | Effect.mapError((cause) => |
packages/client-runtime/src/platform/persistence.test.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { | ||
| 2 | OrchestrationProjectShell, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationThreadShell, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Arr from "effect/Array"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary"; | ||
| 11 | |||
| 12 | import { encodeShellSnapshotForCache } from "./persistence.ts"; | ||
| 13 | |||
| 14 | // Generated values can hold untrimmed strings, which a decoded value never | ||
| 15 | // has. One encode and decode gives a value a client can hold; values that | ||
| 16 | // fail are dropped. Size 30 makes the generator fill optional fields. | ||
| 17 | const sampleDecoded = <S extends Schema.Constraint>(schema: S) => | ||
| 18 | Effect.gen(function* () { | ||
| 19 | const encode = Schema.encodeEffect(schema); | ||
| 20 | const decode = Schema.decodeEffect(schema); | ||
| 21 | const generated = yield* Arbitrary.sampleEffect(Arbitrary.schema(schema), { | ||
| 22 | count: 1000, | ||
| 23 | size: 30, | ||
| 24 | }); | ||
| 25 | const decoded = yield* Effect.forEach(generated, (value) => | ||
| 26 | encode(value).pipe(Effect.flatMap(decode), Effect.option), | ||
| 27 | ); | ||
| 28 | return Arr.getSomes(decoded); | ||
| 29 | }); | ||
| 30 | const encodeSnapshot = Schema.encodeEffect(OrchestrationShellSnapshot); | ||
| 31 | |||
| 32 | describe("encodeShellSnapshotForCache", () => { | ||
| 33 | it.effect("matches the Schema encoding of a generated snapshot", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | const threads = yield* sampleDecoded(OrchestrationThreadShell); | ||
| 36 | const projects = yield* sampleDecoded(OrchestrationProjectShell); | ||
| 37 | const snapshot: OrchestrationShellSnapshot = { | ||
| 38 | snapshotSequence: 1, | ||
| 39 | // The generator rarely makes monogram icons, and they are the one | ||
| 40 | // project field whose encoding differs from the decoded value. | ||
| 41 | projects: projects.map((project, index) => | ||
| 42 | index % 2 === 0 | ||
| 43 | ? { ...project, projectIcon: { kind: "monogram", text: "T3", color: "blue" } } | ||
| 44 | : project, | ||
| 45 | ), | ||
| 46 | threads, | ||
| 47 | updatedAt: "2026-09-25T00:00:00.000Z", | ||
| 48 | }; | ||
| 49 | |||
| 50 | expect(threads.length).toBeGreaterThan(0); | ||
| 51 | expect(projects.length).toBeGreaterThan(0); | ||
| 52 | expect(yield* encodeShellSnapshotForCache(snapshot)).toEqual(yield* encodeSnapshot(snapshot)); | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | }); | ||
packages/client-runtime/src/platform/persistence.ts+16| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | OrchestrationProjectShell, | ||
| 3 | type OrchestrationShellSnapshot, | 4 | type OrchestrationShellSnapshot, |
| 4 | type OrchestrationThreadDetailSnapshot, | 5 | type OrchestrationThreadDetailSnapshot, |
| 5 | type ServerConfig, | 6 | type ServerConfig, |
| ... | @@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service< | ... | @@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service< |
| 130 | } | 131 | } |
| 131 | >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} | 132 | >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} |
| 132 | 133 | ||
| 134 | const encodeProjectShells = Schema.encodeEffect(Schema.Array(OrchestrationProjectShell)); | ||
| 135 | |||
| 136 | /** | ||
| 137 | * Encodes a shell snapshot for `EnvironmentCacheStore.saveShell`. The result | ||
| 138 | * equals `Schema.encode(OrchestrationShellSnapshot)`, so the cache format does | ||
| 139 | * not change. Walking thousands of threads through Schema blocks the UI | ||
| 140 | * thread, and a decoded thread shell is already in its encoded form, so only | ||
| 141 | * the projects go through Schema: their icon has a real encode transform. | ||
| 142 | */ | ||
| 143 | export const encodeShellSnapshotForCache = (snapshot: OrchestrationShellSnapshot) => | ||
| 144 | Effect.map( | ||
| 145 | encodeProjectShells(snapshot.projects), | ||
| 146 | (projects) => ({ ...snapshot, projects }) satisfies typeof OrchestrationShellSnapshot.Encoded, | ||
| 147 | ); | ||
| 148 | |||
| 133 | export class EnvironmentOwnedDataCleanup extends Context.Reference<{ | 149 | export class EnvironmentOwnedDataCleanup extends Context.Reference<{ |
| 134 | readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>; | 150 | readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>; |
| 135 | }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { | 151 | }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { |