| 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 | 1 | import { |
| 2 | 2 | ConnectionPersistenceError, |
| 3 | 3 | EnvironmentCacheStore, |
| 4 | encodeShellSnapshotForCache, | |
| 4 | 5 | } from "@t3tools/client-runtime/platform"; |
| 5 | 6 | import { |
| 6 | 7 | type EnvironmentId, |
| ... | ... | @@ -56,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({ |
| 56 | 57 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( |
| 57 | 58 | Schema.fromJsonString(StoredShellSnapshot), |
| 58 | 59 | ); |
| 59 | const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); | |
| 60 | 60 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( |
| 61 | 61 | Schema.fromJsonString(StoredThreadSnapshot), |
| 62 | 62 | ); |
| ... | ... | @@ -136,11 +136,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { |
| 136 | 136 | }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), |
| 137 | 137 | ), |
| 138 | 138 | saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { |
| 139 | const payload = yield* encodeStoredShellSnapshot({ | |
| 140 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | |
| 141 | environmentId, | |
| 142 | snapshot, | |
| 143 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); | |
| 139 | const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( | |
| 140 | Effect.mapError((cause) => persistenceError("save-shell", cause)), | |
| 141 | ); | |
| 142 | const payload = yield* Effect.try({ | |
| 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 | 151 | yield* database |
| 145 | 152 | .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) |
| 146 | 153 | .pipe(Effect.mapError(mapDatabaseError("save-shell"))); |
apps/web/src/connection/storage.test.ts+23| ... | ... | @@ -87,6 +87,29 @@ describe("makeCatalogBackend", () => { |
| 87 | 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 | 115 | describe("browser GitHub routing permissions", () => { |
apps/web/src/connection/storage.ts+15-7| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ConnectionTargetStore, |
| 7 | 7 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 8 | 8 | EnvironmentCacheStore, |
| 9 | encodeShellSnapshotForCache, | |
| 9 | 10 | putRemoteDpopTokenInCatalog, |
| 10 | 11 | registerConnectionInCatalog, |
| 11 | 12 | removeCatalogValue, |
| ... | ... | @@ -91,7 +92,6 @@ const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDoc |
| 91 | 92 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); |
| 92 | 93 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); |
| 93 | 94 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); |
| 94 | const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); | |
| 95 | 95 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); |
| 96 | 96 | const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); |
| 97 | 97 | const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); |
| ... | ... | @@ -188,7 +188,9 @@ function writeDatabaseValue( |
| 188 | 188 | ) { |
| 189 | 189 | return Effect.callback<void, ConnectionTransientError>((resume) => { |
| 190 | 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 | 194 | resume( |
| 193 | 195 | Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), |
| 194 | 196 | ); |
| ... | ... | @@ -606,11 +608,17 @@ export const connectionStorageLayer = Layer.effectContext( |
| 606 | 608 | ), |
| 607 | 609 | saveShell: (environmentId, snapshot) => |
| 608 | 610 | Effect.gen(function* () { |
| 609 | const encoded = yield* encodeStoredShellSnapshot({ | |
| 610 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | |
| 611 | environmentId, | |
| 612 | snapshot, | |
| 613 | }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); | |
| 611 | const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot); | |
| 612 | const encoded = yield* Effect.try({ | |
| 613 | try: () => | |
| 614 | // @effect-diagnostics-next-line preferSchemaOverJson:off - the snapshot is already encoded. | |
| 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 | 622 | yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); |
| 615 | 623 | }).pipe( |
| 616 | 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 | 1 | import { |
| 2 | 2 | type EnvironmentId, |
| 3 | OrchestrationProjectShell, | |
| 3 | 4 | type OrchestrationShellSnapshot, |
| 4 | 5 | type OrchestrationThreadDetailSnapshot, |
| 5 | 6 | type ServerConfig, |
| ... | ... | @@ -130,6 +131,21 @@ export class EnvironmentCacheStore extends Context.Service< |
| 130 | 131 | } |
| 131 | 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 | 149 | export class EnvironmentOwnedDataCleanup extends Context.Reference<{ |
| 134 | 150 | readonly clear: (environmentId: EnvironmentId) => Effect.Effect<void>; |
| 135 | 151 | }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { |