@fragno-dev/tanstack-db-adapter 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE.md +16 -0
  2. package/README.md +207 -2
  3. package/dist/checkpoint.d.ts +9 -0
  4. package/dist/checkpoint.d.ts.map +1 -0
  5. package/dist/checkpoint.js +11 -0
  6. package/dist/checkpoint.js.map +1 -0
  7. package/dist/coordinator/fragno-browser-persistence-startup.d.ts +92 -0
  8. package/dist/coordinator/fragno-browser-persistence-startup.d.ts.map +1 -0
  9. package/dist/coordinator/fragno-browser-persistence-startup.js +366 -0
  10. package/dist/coordinator/fragno-browser-persistence-startup.js.map +1 -0
  11. package/dist/coordinator/fragno-collection-registry.js +85 -0
  12. package/dist/coordinator/fragno-collection-registry.js.map +1 -0
  13. package/dist/coordinator/fragno-internal-collection.d.ts +40 -0
  14. package/dist/coordinator/fragno-internal-collection.d.ts.map +1 -0
  15. package/dist/coordinator/fragno-internal-collection.js +142 -0
  16. package/dist/coordinator/fragno-internal-collection.js.map +1 -0
  17. package/dist/coordinator/fragno-internal-fetcher.d.ts +23 -0
  18. package/dist/coordinator/fragno-internal-fetcher.d.ts.map +1 -0
  19. package/dist/coordinator/fragno-internal-fetcher.js +60 -0
  20. package/dist/coordinator/fragno-internal-fetcher.js.map +1 -0
  21. package/dist/coordinator/fragno-ordered-persistence.js +66 -0
  22. package/dist/coordinator/fragno-ordered-persistence.js.map +1 -0
  23. package/dist/coordinator/fragno-outbox-synchronizer.js +193 -0
  24. package/dist/coordinator/fragno-outbox-synchronizer.js.map +1 -0
  25. package/dist/coordinator/fragno-table-collection.js +91 -0
  26. package/dist/coordinator/fragno-table-collection.js.map +1 -0
  27. package/dist/coordinator.d.ts +113 -0
  28. package/dist/coordinator.d.ts.map +1 -0
  29. package/dist/coordinator.js +231 -0
  30. package/dist/coordinator.js.map +1 -0
  31. package/dist/outbox-stream.js +85 -0
  32. package/dist/outbox-stream.js.map +1 -0
  33. package/dist/outbox-transport-error.js +12 -0
  34. package/dist/outbox-transport-error.js.map +1 -0
  35. package/dist/protocol.js +115 -0
  36. package/dist/protocol.js.map +1 -0
  37. package/package.json +83 -3
@@ -0,0 +1,91 @@
1
+ import { toTanStackChangeMessage } from "../protocol.js";
2
+ import { FRAGNO_OUTBOX_COLLECTION_CHECKPOINT_METADATA_KEY, shouldApplyOutboxCheckpoint } from "../checkpoint.js";
3
+ import { BTreeIndex, createCollection } from "@tanstack/db";
4
+ import { persistedCollectionOptions } from "@tanstack/db-sqlite-persistence-core";
5
+ //#region src/coordinator/fragno-table-collection.ts
6
+ /** One lazily registered TanStack collection backed by the shared Fragno persistence database. */
7
+ var FragnoTableCollection = class {
8
+ collection;
9
+ constructor(options) {
10
+ const persistence = resolveCollectionPersistence(options.persistence, options.id, options.schemaVersion);
11
+ if (options.skipMissingTruncateDeletes && !persistence.adapter.scanRows) throw new Error(`Persistence for ${options.id} cannot retrieve keys required to skip missing truncate deletes.`);
12
+ this.collection = createCollection(persistedCollectionOptions({
13
+ id: options.id,
14
+ getKey: (row) => row[options.idColumnName],
15
+ persistence,
16
+ schemaVersion: options.schemaVersion,
17
+ syncMode: "eager",
18
+ gcTime: 0,
19
+ autoIndex: "eager",
20
+ defaultIndexType: BTreeIndex,
21
+ sync: {
22
+ rowUpdateMode: options.rowUpdateMode,
23
+ sync: (controls) => {
24
+ if (!controls.metadata?.persistence) throw new Error("Fragno table collections require TanStack persistence metadata.");
25
+ const syncPersistence = controls.metadata.persistence;
26
+ let presentKeys;
27
+ let preparation;
28
+ return options.outbox.register({
29
+ target: options.target,
30
+ ...options.skipMissingTruncateDeletes ? { prepareCatchUp() {
31
+ preparation ??= (async () => {
32
+ const persistedRows = await syncPersistence.scanPersistedRows();
33
+ presentKeys = new Set(persistedRows.map(({ key }) => key));
34
+ })();
35
+ return preparation;
36
+ } } : {},
37
+ apply(delivery) {
38
+ return applyDeliveries(controls, [delivery], presentKeys, "live");
39
+ },
40
+ applyBatch(deliveries) {
41
+ return applyDeliveries(controls, deliveries, presentKeys, "catch-up");
42
+ },
43
+ truncate() {
44
+ controls.begin();
45
+ controls.truncate();
46
+ const visible = controls.commit();
47
+ presentKeys?.clear();
48
+ return visible;
49
+ },
50
+ markReady() {
51
+ controls.markReady();
52
+ }
53
+ });
54
+ }
55
+ }
56
+ }));
57
+ }
58
+ };
59
+ function applyDeliveries(controls, deliveries, presentKeys, deliveryMode) {
60
+ let appliedCheckpoint = controls.metadata.collection.get(FRAGNO_OUTBOX_COLLECTION_CHECKPOINT_METADATA_KEY);
61
+ let nextCheckpoint;
62
+ const keyChanges = /* @__PURE__ */ new Map();
63
+ controls.begin();
64
+ for (const { checkpoint, changes } of deliveries) {
65
+ if (!shouldApplyOutboxCheckpoint(appliedCheckpoint, checkpoint)) continue;
66
+ for (const change of changes) {
67
+ if (deliveryMode === "catch-up" && change.type === "delete" && change.origin === "truncate" && presentKeys && !(keyChanges.get(change.key) ?? presentKeys.has(change.key))) continue;
68
+ controls.write(toTanStackChangeMessage(change));
69
+ if (presentKeys) keyChanges.set(change.key, change.type !== "delete");
70
+ }
71
+ appliedCheckpoint = checkpoint;
72
+ nextCheckpoint = checkpoint;
73
+ }
74
+ if (!nextCheckpoint) return controls.commit();
75
+ controls.metadata.collection.set(FRAGNO_OUTBOX_COLLECTION_CHECKPOINT_METADATA_KEY, nextCheckpoint);
76
+ const visible = controls.commit();
77
+ if (presentKeys) for (const [key, present] of keyChanges) if (present) presentKeys.add(key);
78
+ else presentKeys.delete(key);
79
+ return visible;
80
+ }
81
+ function resolveCollectionPersistence(persistence, collectionId, schemaVersion) {
82
+ return persistence.resolvePersistenceForCollection?.({
83
+ collectionId,
84
+ mode: "sync-present",
85
+ schemaVersion
86
+ }) ?? persistence.resolvePersistenceForMode?.("sync-present") ?? persistence;
87
+ }
88
+ //#endregion
89
+ export { FragnoTableCollection };
90
+
91
+ //# sourceMappingURL=fragno-table-collection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fragno-table-collection.js","names":[],"sources":["../../src/coordinator/fragno-table-collection.ts"],"sourcesContent":["import {\n BTreeIndex,\n createCollection,\n type Collection,\n type SyncAppliedReceipt,\n type SyncConfig,\n} from \"@tanstack/db\";\nimport {\n persistedCollectionOptions,\n type PersistedCollectionPersistence,\n} from \"@tanstack/db-sqlite-persistence-core\";\n\nimport {\n FRAGNO_OUTBOX_COLLECTION_CHECKPOINT_METADATA_KEY,\n shouldApplyOutboxCheckpoint,\n type FragnoOutboxCheckpoint,\n} from \"../checkpoint\";\nimport { toTanStackChangeMessage } from \"../protocol\";\nimport type {\n FragnoOutboxDelivery,\n FragnoOutboxSynchronizer,\n FragnoOutboxTarget,\n} from \"./fragno-outbox-synchronizer\";\n\ntype FragnoTableRow = Record<string, unknown>;\ntype FragnoTableSyncControls = Parameters<SyncConfig<FragnoTableRow, string>[\"sync\"]>[0];\n\n/** One lazily registered TanStack collection backed by the shared Fragno persistence database. */\nexport class FragnoTableCollection {\n readonly collection: Collection<FragnoTableRow, string>;\n\n constructor(options: {\n id: string;\n idColumnName: string;\n persistence: PersistedCollectionPersistence;\n schemaVersion: number;\n outbox: FragnoOutboxSynchronizer;\n rowUpdateMode: \"partial\" | \"full\";\n skipMissingTruncateDeletes: boolean;\n target: FragnoOutboxTarget;\n }) {\n const persistence = resolveCollectionPersistence(\n options.persistence,\n options.id,\n options.schemaVersion,\n );\n // TanStack scans persisted rows through `scanRows` and reports none without it, which would\n // skip deletes of rows that exist.\n if (options.skipMissingTruncateDeletes && !persistence.adapter.scanRows) {\n throw new Error(\n `Persistence for ${options.id} cannot retrieve keys required to skip missing truncate deletes.`,\n );\n }\n\n this.collection = createCollection(\n persistedCollectionOptions<FragnoTableRow, string>({\n id: options.id,\n getKey: (row) => row[options.idColumnName] as string,\n persistence,\n schemaVersion: options.schemaVersion,\n syncMode: \"eager\",\n gcTime: 0,\n autoIndex: \"eager\",\n defaultIndexType: BTreeIndex,\n sync: {\n rowUpdateMode: options.rowUpdateMode,\n sync: (controls) => {\n if (!controls.metadata?.persistence) {\n throw new Error(\"Fragno table collections require TanStack persistence metadata.\");\n }\n const syncPersistence = controls.metadata.persistence;\n\n // A new registration owns a fresh key index; rotating its transport does not.\n let presentKeys: Set<string> | undefined;\n let preparation: Promise<void> | undefined;\n return options.outbox.register({\n target: options.target,\n ...(options.skipMissingTruncateDeletes\n ? {\n prepareCatchUp() {\n preparation ??= (async () => {\n const persistedRows = await syncPersistence.scanPersistedRows();\n presentKeys = new Set(persistedRows.map(({ key }) => key));\n })();\n return preparation;\n },\n }\n : {}),\n apply(delivery) {\n return applyDeliveries(controls, [delivery], presentKeys, \"live\");\n },\n applyBatch(deliveries) {\n return applyDeliveries(controls, deliveries, presentKeys, \"catch-up\");\n },\n truncate() {\n controls.begin();\n controls.truncate();\n const visible = controls.commit();\n presentKeys?.clear();\n return visible;\n },\n markReady() {\n controls.markReady();\n },\n });\n },\n },\n }),\n );\n }\n}\n\nfunction applyDeliveries(\n controls: FragnoTableSyncControls,\n deliveries: readonly FragnoOutboxDelivery[],\n presentKeys: Set<string> | undefined,\n deliveryMode: \"live\" | \"catch-up\",\n): SyncAppliedReceipt {\n let appliedCheckpoint = controls.metadata!.collection.get(\n FRAGNO_OUTBOX_COLLECTION_CHECKPOINT_METADATA_KEY,\n ) as FragnoOutboxCheckpoint | undefined;\n let nextCheckpoint: FragnoOutboxCheckpoint | undefined;\n // Stage only touched keys, not a copy of the whole index. Failed writes/commits must not\n // advance it, while later changes in the same batch must see earlier inserts and deletes.\n const keyChanges = new Map<string, boolean>();\n\n controls.begin();\n for (const { checkpoint, changes } of deliveries) {\n if (!shouldApplyOutboxCheckpoint(appliedCheckpoint, checkpoint)) {\n continue;\n }\n\n for (const change of changes) {\n if (\n deliveryMode === \"catch-up\" &&\n change.type === \"delete\" &&\n change.origin === \"truncate\" &&\n presentKeys &&\n !(keyChanges.get(change.key) ?? presentKeys.has(change.key))\n ) {\n continue;\n }\n\n controls.write(toTanStackChangeMessage(change));\n if (presentKeys) {\n keyChanges.set(change.key, change.type !== \"delete\");\n }\n }\n appliedCheckpoint = checkpoint;\n nextCheckpoint = checkpoint;\n }\n\n if (!nextCheckpoint) {\n return controls.commit();\n }\n\n controls.metadata!.collection.set(\n FRAGNO_OUTBOX_COLLECTION_CHECKPOINT_METADATA_KEY,\n nextCheckpoint,\n );\n const visible = controls.commit();\n if (presentKeys) {\n for (const [key, present] of keyChanges) {\n if (present) {\n presentKeys.add(key);\n } else {\n presentKeys.delete(key);\n }\n }\n }\n return visible;\n}\n\nfunction resolveCollectionPersistence(\n persistence: PersistedCollectionPersistence,\n collectionId: string,\n schemaVersion: number,\n): PersistedCollectionPersistence {\n return (\n persistence.resolvePersistenceForCollection?.({\n collectionId,\n mode: \"sync-present\",\n schemaVersion,\n }) ??\n persistence.resolvePersistenceForMode?.(\"sync-present\") ??\n persistence\n );\n}\n"],"mappings":";;;;;;AA4BA,IAAa,wBAAb,MAAmC;CACjC;CAEA,YAAY,SAST;EACD,MAAM,cAAc,6BAClB,QAAQ,aACR,QAAQ,IACR,QAAQ,aACV;EAGA,IAAI,QAAQ,8BAA8B,CAAC,YAAY,QAAQ,UAC7D,MAAM,IAAI,MACR,mBAAmB,QAAQ,GAAG,iEAChC;EAGF,KAAK,aAAa,iBAChB,2BAAmD;GACjD,IAAI,QAAQ;GACZ,SAAS,QAAQ,IAAI,QAAQ;GAC7B;GACA,eAAe,QAAQ;GACvB,UAAU;GACV,QAAQ;GACR,WAAW;GACX,kBAAkB;GAClB,MAAM;IACJ,eAAe,QAAQ;IACvB,OAAO,aAAa;KAClB,IAAI,CAAC,SAAS,UAAU,aACtB,MAAM,IAAI,MAAM,iEAAiE;KAEnF,MAAM,kBAAkB,SAAS,SAAS;KAG1C,IAAI;KACJ,IAAI;KACJ,OAAO,QAAQ,OAAO,SAAS;MAC7B,QAAQ,QAAQ;MAChB,GAAI,QAAQ,6BACR,EACE,iBAAiB;OACf,iBAAiB,YAAY;QAC3B,MAAM,gBAAgB,MAAM,gBAAgB,kBAAkB;QAC9D,cAAc,IAAI,IAAI,cAAc,KAAK,EAAE,UAAU,GAAG,CAAC;OAC3D,EAAA,CAAG;OACH,OAAO;MACT,EACF,IACA,CAAC;MACL,MAAM,UAAU;OACd,OAAO,gBAAgB,UAAU,CAAC,QAAQ,GAAG,aAAa,MAAM;MAClE;MACA,WAAW,YAAY;OACrB,OAAO,gBAAgB,UAAU,YAAY,aAAa,UAAU;MACtE;MACA,WAAW;OACT,SAAS,MAAM;OACf,SAAS,SAAS;OAClB,MAAM,UAAU,SAAS,OAAO;OAChC,aAAa,MAAM;OACnB,OAAO;MACT;MACA,YAAY;OACV,SAAS,UAAU;MACrB;KACF,CAAC;IACH;GACF;EACF,CAAC,CACH;CACF;AACF;AAEA,SAAS,gBACP,UACA,YACA,aACA,cACoB;CACpB,IAAI,oBAAoB,SAAS,SAAU,WAAW,IACpD,gDACF;CACA,IAAI;CAGJ,MAAM,6BAAa,IAAI,IAAqB;CAE5C,SAAS,MAAM;CACf,KAAK,MAAM,EAAE,YAAY,aAAa,YAAY;EAChD,IAAI,CAAC,4BAA4B,mBAAmB,UAAU,GAC5D;EAGF,KAAK,MAAM,UAAU,SAAS;GAC5B,IACE,iBAAiB,cACjB,OAAO,SAAS,YAChB,OAAO,WAAW,cAClB,eACA,EAAE,WAAW,IAAI,OAAO,GAAG,KAAK,YAAY,IAAI,OAAO,GAAG,IAE1D;GAGF,SAAS,MAAM,wBAAwB,MAAM,CAAC;GAC9C,IAAI,aACF,WAAW,IAAI,OAAO,KAAK,OAAO,SAAS,QAAQ;EAEvD;EACA,oBAAoB;EACpB,iBAAiB;CACnB;CAEA,IAAI,CAAC,gBACH,OAAO,SAAS,OAAO;CAGzB,SAAS,SAAU,WAAW,IAC5B,kDACA,cACF;CACA,MAAM,UAAU,SAAS,OAAO;CAChC,IAAI,aACF,KAAK,MAAM,CAAC,KAAK,YAAY,YAC3B,IAAI,SACF,YAAY,IAAI,GAAG;MAEnB,YAAY,OAAO,GAAG;CAI5B,OAAO;AACT;AAEA,SAAS,6BACP,aACA,cACA,eACgC;CAChC,OACE,YAAY,kCAAkC;EAC5C;EACA,MAAM;EACN;CACF,CAAC,KACD,YAAY,4BAA4B,cAAc,KACtD;AAEJ"}
@@ -0,0 +1,113 @@
1
+ import { FragnoBrowserPersistenceDiagnostics } from "./coordinator/fragno-browser-persistence-startup.js";
2
+ import { FragnoOutboxCheckpoint } from "./checkpoint.js";
3
+ import { FragnoInternalCollection, FragnoInternalRow, FragnoOutboxCoordinatorError, FragnoOutboxCoordinatorState } from "./coordinator/fragno-internal-collection.js";
4
+ import { FragnoInternalDescription } from "./coordinator/fragno-internal-fetcher.js";
5
+ import "./coordinator.js";
6
+ import { Collection } from "@tanstack/db";
7
+ import { PersistedCollectionPersistence } from "@tanstack/db-sqlite-persistence-core";
8
+ import { TableToColumnValues } from "@fragno-dev/db/query";
9
+ import { AnySchema, AnyTable, FragnoId, FragnoReference } from "@fragno-dev/db/schema";
10
+ //#region src/coordinator.d.ts
11
+ /**
12
+ * Problem statement
13
+ * -----------------
14
+ * One Fragno database produces one globally ordered outbox containing mutations for every schema
15
+ * and table stored in that database. The browser should mirror that outbox into one local database,
16
+ * not create an independent transport, checkpoint, or persistence database for every collection.
17
+ *
18
+ * `createFragnoOutboxCoordinator` therefore receives only:
19
+ *
20
+ * - the mounted Fragment base URL;
21
+ * - the Fetch implementation used to call it;
22
+ * - every Fragno schema represented by that outbox.
23
+ *
24
+ * From those inputs it owns the complete local database: source identity, persistence, one exact
25
+ * outbox checkpoint, one catch-up loop, one live stream, and every table collection registered
26
+ * before preload. Callers register typed collections but cannot configure synchronization
27
+ * independently.
28
+ *
29
+ * Data flow
30
+ * ---------
31
+ *
32
+ * createFragnoOutboxCoordinator({ baseUrl, fetch, schemas })
33
+ * -> derive the Fragno internal outbox URL from baseUrl
34
+ * -> resolve the physical database adapter identity
35
+ * -> open one local persistence database for that base URL + adapter identity
36
+ * -> coordinator.collection(schema, table) lazily registers each requested table collection
37
+ * -> coordinator.preload() closes registration and preloads every registered collection
38
+ * -> each collection's sync callback subscribes to the shared outbox synchronizer
39
+ * -> read the database's one exact persisted outbox checkpoint
40
+ * -> verify checkpoint UOW identity and stream strictly after its versionstamp
41
+ * -> validate and decode each outbox entry once
42
+ * -> route every operation to its schema/table collection
43
+ * -> commit all affected collections before advancing the database checkpoint
44
+ * -> mark every collection ready together
45
+ * -> continue live delivery on the same response after its caught-up marker
46
+ * -> TanStack live queries update the UI
47
+ *
48
+ * Invariants
49
+ * ----------
50
+ *
51
+ * - one outbox maps to exactly one local database;
52
+ * - every requested table collection is registered before catch-up starts;
53
+ * - one exact checkpoint describes the complete local database, not an individual collection;
54
+ * - an entry advances that checkpoint only after every affected collection has committed;
55
+ * - entries without relevant operations still advance the checkpoint;
56
+ * - reloads and stream rotations verify checkpoint identity without replaying old entries;
57
+ * - changing adapter identity discards the previous local database materialization;
58
+ * - persisted rows alone never make collections ready before network catch-up finishes;
59
+ * - cleanup stops the shared request, stream, persistence, and every generated collection.
60
+ */
61
+ type MaterializedColumnValue<TValue> = TValue extends FragnoId | FragnoReference ? string : TValue;
62
+ type VisibleTableValues<TTable extends AnyTable> = TableToColumnValues<TTable>;
63
+ type FragnoCollectionRow<TTable extends AnyTable> = { [TColumnName in keyof VisibleTableValues<TTable> as [VisibleTableValues<TTable>[TColumnName]] extends [null] ? never : TColumnName]: MaterializedColumnValue<VisibleTableValues<TTable>[TColumnName]>; };
64
+ declare const FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION = 2;
65
+ declare function fetchFragnoOutboxDescription(options: {
66
+ baseUrl: string | URL;
67
+ fetch: typeof globalThis.fetch;
68
+ signal?: AbortSignal;
69
+ }): Promise<FragnoInternalDescription>;
70
+ type FragnoOutboxCatchUpProgress = {
71
+ completedPages: number;
72
+ totalPages: number;
73
+ percent: number;
74
+ };
75
+ type FragnoOutboxPersistenceResource = {
76
+ persistence: PersistedCollectionPersistence;
77
+ cleanup(): Promise<void>;
78
+ };
79
+ type FragnoOutboxCoordinatorDependencies = {
80
+ openPersistence(options: {
81
+ databaseName: string;
82
+ }): Promise<FragnoOutboxPersistenceResource>;
83
+ };
84
+ type FragnoCollectionOptions = {
85
+ /** Use `full` only for tables that never receive updates; Fragno update entries contain patches. */
86
+ rowUpdateMode?: "partial" | "full";
87
+ /** Scans persisted rows once per sync registration to skip missing catch-up truncate deletes. */
88
+ skipMissingTruncateDeletes?: boolean;
89
+ };
90
+ type FragnoOutboxCoordinator<TSchemas extends readonly AnySchema[]> = {
91
+ /** Persisted coordinator metadata and user-queryable lifecycle status. */
92
+ readonly internal: FragnoInternalCollection;
93
+ /** Current synchronization lifecycle state. */
94
+ readonly state: FragnoOutboxCoordinatorState;
95
+ collection<TSchema extends TSchemas[number], TTableName extends keyof TSchema["tables"] & string>(schema: TSchema, table: TTableName, options?: FragnoCollectionOptions): Collection<FragnoCollectionRow<TSchema["tables"][TTableName]>, string>;
96
+ /** Preloads every generated collection and resolves after shared initial catch-up. */
97
+ preload(): Promise<void>;
98
+ /** Waits until every accepted local persistence write is durable. */
99
+ flushPersistence(): Promise<void>;
100
+ /** Stops synchronization and closes every collection and local database resource. */
101
+ cleanup(): Promise<void>;
102
+ };
103
+ declare function createFragnoOutboxCoordinator<const TSchemas extends readonly AnySchema[]>(options: {
104
+ baseUrl: string | URL;
105
+ fetch: typeof globalThis.fetch;
106
+ schemas: TSchemas;
107
+ onCatchUpProgress?(progress: FragnoOutboxCatchUpProgress): void;
108
+ /** Receives recurring browser persistence diagnostics while startup remains stalled. */
109
+ onBrowserPersistenceDiagnostic?(diagnostics: FragnoBrowserPersistenceDiagnostics): void;
110
+ }, dependencies?: FragnoOutboxCoordinatorDependencies): Promise<FragnoOutboxCoordinator<TSchemas>>;
111
+ //#endregion
112
+ export { FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION, type FragnoBrowserPersistenceDiagnostics, FragnoCollectionOptions, FragnoCollectionRow, FragnoInternalCollection, type FragnoInternalDescription, type FragnoInternalRow, FragnoOutboxCatchUpProgress, type FragnoOutboxCheckpoint, FragnoOutboxCoordinator, FragnoOutboxCoordinatorDependencies, type FragnoOutboxCoordinatorError, type FragnoOutboxCoordinatorState, createFragnoOutboxCoordinator, fetchFragnoOutboxDescription };
113
+ //# sourceMappingURL=coordinator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coordinator.d.ts","names":[],"sources":["../src/coordinator.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAwEK,wBAAwB,UAAU,eAAe,WAAW,2BAA2B;KAEvF,mBAAmB,eAAe,YAAY,oBAAoB;KAE3D,oBAAoB,eAAe,eAC5C,qBAAqB,mBAAmB,YACvC,mBAAmB,QAAQ,uCAGzB,cAAc,wBAAwB,mBAAmB,QAAQ;cAc1D;iBAMS,6BAA6B;EACjD,kBAAkB;EAClB,cAAc,WAAW;EACzB,SAAS;IACV,QADU;KAKC;EACV;EACA;EACA;;KAGG;EACH,aAAa;EACb,WAAW;;KAGD;EACV,gBAAgB;IAAW;MAAyB,QAAQ;;KAGlD;;EAEV;;EAEA;;KAGU,wBAAwB,0BAA0B;;WAEnD,UAAU;;WAGV,OAAO;EAEhB,WAAW,gBAAgB,kBAAkB,yBAAyB,4BACpE,QAAQ,SACR,OAAO,YACP,UAAU,0BACT,WAAW,oBAAoB,kBAAkB;;EAGpD,WAAW;;EAGX,oBAAoB;;EAGpB,WAAW;;iBAGS,oCAAoC,0BAA0B,aAClF;EACE,kBAAkB;EAClB,cAAc,WAAW;EACzB,SAAS;EACT,mBAAmB,UAAU;;EAE7B,gCAAgC,aAAa;GAE/C,eAAc,sCAGb,QAAQ,wBAAwB"}
@@ -0,0 +1,231 @@
1
+ import { openFragnoBrowserPersistenceWithDiagnostics } from "./coordinator/fragno-browser-persistence-startup.js";
2
+ import { FragnoOutboxTransportError } from "./outbox-transport-error.js";
3
+ import { FragnoOutboxSynchronizer } from "./coordinator/fragno-outbox-synchronizer.js";
4
+ import { FragnoCollectionRegistry } from "./coordinator/fragno-collection-registry.js";
5
+ import { FragnoInternalCollection } from "./coordinator/fragno-internal-collection.js";
6
+ import { FragnoInternalFetcher } from "./coordinator/fragno-internal-fetcher.js";
7
+ import { orderFragnoPersistenceWrites } from "./coordinator/fragno-ordered-persistence.js";
8
+ import { FRAGNO_OUTBOX_PAGE_SIZE } from "@fragno-dev/db/outbox";
9
+ //#region src/coordinator.ts
10
+ const FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION = 2;
11
+ const FRAGNO_OUTBOX_STARTUP_TIMEOUT_MS = 5e3;
12
+ async function fetchFragnoOutboxDescription(options) {
13
+ return await new FragnoInternalFetcher(options).describe({ signal: options.signal });
14
+ }
15
+ async function createFragnoOutboxCoordinator(options, dependencies = { openPersistence: openBrowserFragnoOutboxPersistence }) {
16
+ let state = "opening";
17
+ const internalFetcher = new FragnoInternalFetcher({
18
+ baseUrl: options.baseUrl,
19
+ fetch: options.fetch
20
+ });
21
+ const { adapterIdentity } = await internalFetcher.describe();
22
+ const databaseName = `fragno-v3-${(await sha256Hex(`${internalFetcher.baseUrl}\0${adapterIdentity}\0version:2`)).slice(0, 32)}.sqlite`;
23
+ const persistenceResource = await openFragnoBrowserPersistenceWithDiagnostics({
24
+ databaseName,
25
+ onDiagnostic: (diagnostics) => options.onBrowserPersistenceDiagnostic?.(diagnostics),
26
+ openPersistence: () => dependencies.openPersistence({ databaseName })
27
+ });
28
+ const persistence = orderFragnoPersistenceWrites(persistenceResource.persistence);
29
+ let completedCatchUpPages = 0;
30
+ let totalCatchUpPages = 1;
31
+ let internalCollection;
32
+ let outboxSynchronizer;
33
+ let collectionRegistry;
34
+ try {
35
+ internalCollection = new FragnoInternalCollection({
36
+ persistence,
37
+ schemaVersion: 2,
38
+ state
39
+ });
40
+ await waitForFragnoOutboxStartupStage(internalCollection.preload(), "preloading coordinator metadata");
41
+ outboxSynchronizer = new FragnoOutboxSynchronizer({
42
+ fetcher: internalFetcher,
43
+ adapterIdentity,
44
+ checkpointStore: internalCollection,
45
+ onCatchUpPage() {
46
+ completedCatchUpPages += 1;
47
+ options.onCatchUpProgress?.({
48
+ completedPages: completedCatchUpPages,
49
+ totalPages: totalCatchUpPages,
50
+ percent: totalCatchUpPages === 0 ? 100 : Math.min(100, Math.round(completedCatchUpPages / totalCatchUpPages * 100))
51
+ });
52
+ }
53
+ });
54
+ collectionRegistry = new FragnoCollectionRegistry({
55
+ schemas: options.schemas,
56
+ persistence,
57
+ schemaVersion: 2,
58
+ outbox: outboxSynchronizer
59
+ });
60
+ } catch (error) {
61
+ outboxSynchronizer?.dispose();
62
+ await internalCollection?.cleanup().catch(() => {});
63
+ await persistenceResource.cleanup().catch(() => {});
64
+ throw error;
65
+ }
66
+ if (!internalCollection || !outboxSynchronizer || !collectionRegistry) {
67
+ await persistenceResource.cleanup();
68
+ throw new Error("Fragno outbox coordinator initialization did not complete.");
69
+ }
70
+ const initializedInternalCollection = internalCollection;
71
+ const initializedOutboxSynchronizer = outboxSynchronizer;
72
+ const initializedCollectionRegistry = collectionRegistry;
73
+ let preloadPromise;
74
+ let streamPromise;
75
+ let cleanupPromise;
76
+ const transitionTo = (nextState, error = null) => {
77
+ state = nextState;
78
+ initializedInternalCollection.setState(nextState, error);
79
+ };
80
+ const transitionToFailed = (error) => {
81
+ if (state !== "disposed") transitionTo("failed", error);
82
+ };
83
+ transitionTo("idle");
84
+ const flushPersistence = async () => {
85
+ await initializedInternalCollection.flushPersistence();
86
+ };
87
+ const isDisposed = () => state === "disposed";
88
+ const firstCatchUp = Promise.withResolvers();
89
+ firstCatchUp.promise.catch(() => {});
90
+ const startStreaming = () => {
91
+ streamPromise ??= (async () => {
92
+ let retryAttempt = 0;
93
+ while (!isDisposed()) try {
94
+ await initializedOutboxSynchronizer.streamSession({
95
+ onStarted(targetVersionstamp) {
96
+ completedCatchUpPages = 0;
97
+ totalCatchUpPages = catchUpPageCount(initializedInternalCollection.getCheckpoint()?.versionstamp, targetVersionstamp);
98
+ },
99
+ onCaughtUp() {
100
+ retryAttempt = 0;
101
+ transitionTo("live");
102
+ firstCatchUp.resolve();
103
+ }
104
+ });
105
+ if (!isDisposed()) {
106
+ transitionTo("rotating");
107
+ transitionTo("replaying");
108
+ }
109
+ } catch (error) {
110
+ if (isDisposed()) return;
111
+ if (!(error instanceof FragnoOutboxTransportError)) {
112
+ firstCatchUp.reject(error);
113
+ transitionToFailed(error);
114
+ return;
115
+ }
116
+ transitionTo("retrying", error);
117
+ retryAttempt += 1;
118
+ await waitForRetry(retryAttempt);
119
+ if (isDisposed()) return;
120
+ transitionTo("replaying");
121
+ }
122
+ })();
123
+ streamPromise.catch((error) => {
124
+ firstCatchUp.reject(error);
125
+ transitionToFailed(error);
126
+ });
127
+ };
128
+ return {
129
+ internal: initializedInternalCollection,
130
+ get state() {
131
+ return state;
132
+ },
133
+ collection(schema, tableName, collectionOptions) {
134
+ return initializedCollectionRegistry.registerCollection(schema, tableName, state, collectionOptions);
135
+ },
136
+ preload() {
137
+ preloadPromise ??= (async () => {
138
+ if (state !== "idle") throw new Error(`Cannot preload Fragno collections while the coordinator is ${state}.`);
139
+ try {
140
+ transitionTo("registering");
141
+ const collectionsReady = initializedCollectionRegistry.preload();
142
+ const collectionsRegistered = initializedOutboxSynchronizer.waitUntilRegistered(initializedCollectionRegistry.registeredTargets());
143
+ await waitForFragnoOutboxStartupStage(Promise.race([collectionsRegistered, collectionsReady.then(() => {
144
+ throw new Error("Fragno collections became ready before outbox synchronization registration completed.");
145
+ })]), "registering synchronized collections");
146
+ transitionTo("catching-up");
147
+ startStreaming();
148
+ await firstCatchUp.promise;
149
+ await collectionsReady;
150
+ } catch (error) {
151
+ transitionToFailed(error);
152
+ throw error;
153
+ }
154
+ })();
155
+ return preloadPromise;
156
+ },
157
+ flushPersistence,
158
+ cleanup() {
159
+ cleanupPromise ??= (async () => {
160
+ transitionTo("disposed");
161
+ initializedOutboxSynchronizer.dispose();
162
+ firstCatchUp.reject(new DOMException("Fragno outbox coordinator disposed.", "AbortError"));
163
+ await preloadPromise?.catch(() => {});
164
+ await streamPromise?.catch(() => {});
165
+ const errors = [];
166
+ for (const cleanup of [
167
+ flushPersistence,
168
+ () => initializedCollectionRegistry.cleanup(),
169
+ () => initializedInternalCollection.cleanup(),
170
+ () => persistence.drain(),
171
+ () => persistenceResource.cleanup()
172
+ ]) try {
173
+ await cleanup();
174
+ } catch (error) {
175
+ errors.push(error);
176
+ }
177
+ if (errors.length > 0) throw new AggregateError(errors, "Failed to clean up the Fragno outbox coordinator.");
178
+ })();
179
+ return cleanupPromise;
180
+ }
181
+ };
182
+ }
183
+ async function waitForFragnoOutboxStartupStage(stagePromise, stage) {
184
+ let timeout;
185
+ const timeoutPromise = new Promise((_, reject) => {
186
+ timeout = setTimeout(() => {
187
+ reject(/* @__PURE__ */ new Error(`Fragno outbox startup timed out after ${FRAGNO_OUTBOX_STARTUP_TIMEOUT_MS}ms while ${stage}.`));
188
+ }, FRAGNO_OUTBOX_STARTUP_TIMEOUT_MS);
189
+ });
190
+ try {
191
+ return await Promise.race([stagePromise, timeoutPromise]);
192
+ } finally {
193
+ clearTimeout(timeout);
194
+ }
195
+ }
196
+ function waitForRetry(attempt) {
197
+ const delayMs = Math.min(1e3, 25 * 2 ** Math.min(attempt - 1, 5));
198
+ return new Promise((resolve) => {
199
+ setTimeout(resolve, delayMs);
200
+ });
201
+ }
202
+ function catchUpPageCount(checkpointVersionstamp, currentVersionstamp) {
203
+ if (!currentVersionstamp) return 1;
204
+ const checkpointVersion = checkpointVersionstamp ? BigInt(`0x${checkpointVersionstamp.slice(0, 20)}`) : -1n;
205
+ const currentVersion = BigInt(`0x${currentVersionstamp.slice(0, 20)}`);
206
+ const remainingEntries = currentVersion > checkpointVersion ? currentVersion - checkpointVersion : 0n;
207
+ return Math.max(1, Number((remainingEntries + BigInt(FRAGNO_OUTBOX_PAGE_SIZE) - 1n) / BigInt(FRAGNO_OUTBOX_PAGE_SIZE)));
208
+ }
209
+ async function sha256Hex(value) {
210
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
211
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
212
+ }
213
+ async function openBrowserFragnoOutboxPersistence(options) {
214
+ const { BrowserCollectionCoordinator, createBrowserWASQLitePersistence, openBrowserWASQLiteOPFSDatabase } = await import("@tanstack/browser-db-sqlite-persistence");
215
+ const database = await openBrowserWASQLiteOPFSDatabase({ databaseName: options.databaseName });
216
+ const coordinator = new BrowserCollectionCoordinator({ dbName: options.databaseName });
217
+ return {
218
+ persistence: createBrowserWASQLitePersistence({
219
+ database,
220
+ coordinator
221
+ }),
222
+ async cleanup() {
223
+ coordinator.dispose();
224
+ await database.close?.();
225
+ }
226
+ };
227
+ }
228
+ //#endregion
229
+ export { FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION, FragnoInternalCollection, createFragnoOutboxCoordinator, fetchFragnoOutboxDescription };
230
+
231
+ //# sourceMappingURL=coordinator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coordinator.js","names":[],"sources":["../src/coordinator.ts"],"sourcesContent":["import { FRAGNO_OUTBOX_PAGE_SIZE } from \"@fragno-dev/db/outbox\";\nimport type { TableToColumnValues } from \"@fragno-dev/db/query\";\nimport type { AnySchema, AnyTable, FragnoId, FragnoReference } from \"@fragno-dev/db/schema\";\n\nimport type { Collection } from \"@tanstack/db\";\nimport type { PersistedCollectionPersistence } from \"@tanstack/db-sqlite-persistence-core\";\n\nimport {\n openFragnoBrowserPersistenceWithDiagnostics,\n type FragnoBrowserPersistenceDiagnostics,\n} from \"./coordinator/fragno-browser-persistence-startup\";\nimport { FragnoCollectionRegistry } from \"./coordinator/fragno-collection-registry\";\nimport {\n FragnoInternalCollection,\n type FragnoOutboxCoordinatorState,\n} from \"./coordinator/fragno-internal-collection\";\nimport { FragnoInternalFetcher } from \"./coordinator/fragno-internal-fetcher\";\nimport { orderFragnoPersistenceWrites } from \"./coordinator/fragno-ordered-persistence\";\nimport { FragnoOutboxSynchronizer } from \"./coordinator/fragno-outbox-synchronizer\";\nimport { FragnoOutboxTransportError } from \"./outbox-transport-error\";\n\n/**\n * Problem statement\n * -----------------\n * One Fragno database produces one globally ordered outbox containing mutations for every schema\n * and table stored in that database. The browser should mirror that outbox into one local database,\n * not create an independent transport, checkpoint, or persistence database for every collection.\n *\n * `createFragnoOutboxCoordinator` therefore receives only:\n *\n * - the mounted Fragment base URL;\n * - the Fetch implementation used to call it;\n * - every Fragno schema represented by that outbox.\n *\n * From those inputs it owns the complete local database: source identity, persistence, one exact\n * outbox checkpoint, one catch-up loop, one live stream, and every table collection registered\n * before preload. Callers register typed collections but cannot configure synchronization\n * independently.\n *\n * Data flow\n * ---------\n *\n * createFragnoOutboxCoordinator({ baseUrl, fetch, schemas })\n * -> derive the Fragno internal outbox URL from baseUrl\n * -> resolve the physical database adapter identity\n * -> open one local persistence database for that base URL + adapter identity\n * -> coordinator.collection(schema, table) lazily registers each requested table collection\n * -> coordinator.preload() closes registration and preloads every registered collection\n * -> each collection's sync callback subscribes to the shared outbox synchronizer\n * -> read the database's one exact persisted outbox checkpoint\n * -> verify checkpoint UOW identity and stream strictly after its versionstamp\n * -> validate and decode each outbox entry once\n * -> route every operation to its schema/table collection\n * -> commit all affected collections before advancing the database checkpoint\n * -> mark every collection ready together\n * -> continue live delivery on the same response after its caught-up marker\n * -> TanStack live queries update the UI\n *\n * Invariants\n * ----------\n *\n * - one outbox maps to exactly one local database;\n * - every requested table collection is registered before catch-up starts;\n * - one exact checkpoint describes the complete local database, not an individual collection;\n * - an entry advances that checkpoint only after every affected collection has committed;\n * - entries without relevant operations still advance the checkpoint;\n * - reloads and stream rotations verify checkpoint identity without replaying old entries;\n * - changing adapter identity discards the previous local database materialization;\n * - persisted rows alone never make collections ready before network catch-up finishes;\n * - cleanup stops the shared request, stream, persistence, and every generated collection.\n */\n\ntype MaterializedColumnValue<TValue> = TValue extends FragnoId | FragnoReference ? string : TValue;\n\ntype VisibleTableValues<TTable extends AnyTable> = TableToColumnValues<TTable>;\n\nexport type FragnoCollectionRow<TTable extends AnyTable> = {\n [TColumnName in keyof VisibleTableValues<TTable> as [\n VisibleTableValues<TTable>[TColumnName],\n ] extends [null]\n ? never\n : TColumnName]: MaterializedColumnValue<VisibleTableValues<TTable>[TColumnName]>;\n};\n\nexport type { FragnoOutboxCheckpoint } from \"./checkpoint\";\nexport type { FragnoBrowserPersistenceDiagnostics } from \"./coordinator/fragno-browser-persistence-startup\";\n\nexport {\n FragnoInternalCollection,\n type FragnoInternalRow,\n type FragnoOutboxCoordinatorError,\n type FragnoOutboxCoordinatorState,\n} from \"./coordinator/fragno-internal-collection\";\n\n// Version 1 could persist checkpoints without a UOW identity, which cannot verify restored history.\nexport const FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION = 2;\n\nconst FRAGNO_OUTBOX_STARTUP_TIMEOUT_MS = 5_000;\n\nexport type { FragnoInternalDescription } from \"./coordinator/fragno-internal-fetcher\";\n\nexport async function fetchFragnoOutboxDescription(options: {\n baseUrl: string | URL;\n fetch: typeof globalThis.fetch;\n signal?: AbortSignal;\n}) {\n return await new FragnoInternalFetcher(options).describe({ signal: options.signal });\n}\n\nexport type FragnoOutboxCatchUpProgress = {\n completedPages: number;\n totalPages: number;\n percent: number;\n};\n\ntype FragnoOutboxPersistenceResource = {\n persistence: PersistedCollectionPersistence;\n cleanup(): Promise<void>;\n};\n\nexport type FragnoOutboxCoordinatorDependencies = {\n openPersistence(options: { databaseName: string }): Promise<FragnoOutboxPersistenceResource>;\n};\n\nexport type FragnoCollectionOptions = {\n /** Use `full` only for tables that never receive updates; Fragno update entries contain patches. */\n rowUpdateMode?: \"partial\" | \"full\";\n /** Scans persisted rows once per sync registration to skip missing catch-up truncate deletes. */\n skipMissingTruncateDeletes?: boolean;\n};\n\nexport type FragnoOutboxCoordinator<TSchemas extends readonly AnySchema[]> = {\n /** Persisted coordinator metadata and user-queryable lifecycle status. */\n readonly internal: FragnoInternalCollection;\n\n /** Current synchronization lifecycle state. */\n readonly state: FragnoOutboxCoordinatorState;\n\n collection<TSchema extends TSchemas[number], TTableName extends keyof TSchema[\"tables\"] & string>(\n schema: TSchema,\n table: TTableName,\n options?: FragnoCollectionOptions,\n ): Collection<FragnoCollectionRow<TSchema[\"tables\"][TTableName]>, string>;\n\n /** Preloads every generated collection and resolves after shared initial catch-up. */\n preload(): Promise<void>;\n\n /** Waits until every accepted local persistence write is durable. */\n flushPersistence(): Promise<void>;\n\n /** Stops synchronization and closes every collection and local database resource. */\n cleanup(): Promise<void>;\n};\n\nexport async function createFragnoOutboxCoordinator<const TSchemas extends readonly AnySchema[]>(\n options: {\n baseUrl: string | URL;\n fetch: typeof globalThis.fetch;\n schemas: TSchemas;\n onCatchUpProgress?(progress: FragnoOutboxCatchUpProgress): void;\n /** Receives recurring browser persistence diagnostics while startup remains stalled. */\n onBrowserPersistenceDiagnostic?(diagnostics: FragnoBrowserPersistenceDiagnostics): void;\n },\n dependencies: FragnoOutboxCoordinatorDependencies = {\n openPersistence: openBrowserFragnoOutboxPersistence,\n },\n): Promise<FragnoOutboxCoordinator<TSchemas>> {\n // State transitions:\n // opening -> idle -> registering -> catching-up -> live\n // live -> rotating | retrying -> replaying -> live\n // Only classified transport interruptions retry; protocol, HTTP, and application failures stop.\n let state: FragnoOutboxCoordinatorState = \"opening\";\n\n const internalFetcher = new FragnoInternalFetcher({\n baseUrl: options.baseUrl,\n fetch: options.fetch,\n });\n\n // Fetch the backend adapter identity before opening local persistence.\n const { adapterIdentity } = await internalFetcher.describe();\n\n // Derive one stable, filename-safe local database name from baseUrl and adapter identity.\n const databaseHash = await sha256Hex(\n `${internalFetcher.baseUrl}\\0${adapterIdentity}\\0version:${FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION}`,\n );\n // OPFSCoopSyncVFS fails to open filenames above 56 characters in some Electron/Chromium\n // runtimes. A 128-bit hash keeps this deterministic and collision-resistant within that limit.\n // The v3 prefix leaves behind databases created with the temporary single-process wiring.\n const databaseName = `fragno-v3-${databaseHash.slice(0, 32)}.sqlite`;\n\n const persistenceResource = await openFragnoBrowserPersistenceWithDiagnostics({\n databaseName,\n onDiagnostic: (diagnostics) => options.onBrowserPersistenceDiagnostic?.(diagnostics),\n openPersistence: () => dependencies.openPersistence({ databaseName }),\n });\n const persistence = orderFragnoPersistenceWrites(persistenceResource.persistence);\n\n let completedCatchUpPages = 0;\n let totalCatchUpPages = 1;\n let internalCollection: FragnoInternalCollection | undefined;\n let outboxSynchronizer: FragnoOutboxSynchronizer | undefined;\n let collectionRegistry: FragnoCollectionRegistry<TSchemas> | undefined;\n\n try {\n // Load the one exact outbox checkpoint stored for this database.\n internalCollection = new FragnoInternalCollection({\n persistence,\n schemaVersion: FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION,\n state,\n });\n await waitForFragnoOutboxStartupStage(\n internalCollection.preload(),\n \"preloading coordinator metadata\",\n );\n\n outboxSynchronizer = new FragnoOutboxSynchronizer({\n fetcher: internalFetcher,\n adapterIdentity,\n checkpointStore: internalCollection,\n onCatchUpPage() {\n completedCatchUpPages += 1;\n options.onCatchUpProgress?.({\n completedPages: completedCatchUpPages,\n totalPages: totalCatchUpPages,\n percent:\n totalCatchUpPages === 0\n ? 100\n : Math.min(100, Math.round((completedCatchUpPages / totalCatchUpPages) * 100)),\n });\n },\n });\n collectionRegistry = new FragnoCollectionRegistry({\n schemas: options.schemas,\n persistence,\n schemaVersion: FRAGNO_OUTBOX_LOCAL_SCHEMA_VERSION,\n outbox: outboxSynchronizer,\n });\n } catch (error) {\n outboxSynchronizer?.dispose();\n await internalCollection?.cleanup().catch(() => {});\n await persistenceResource.cleanup().catch(() => {});\n throw error;\n }\n\n if (!internalCollection || !outboxSynchronizer || !collectionRegistry) {\n await persistenceResource.cleanup();\n throw new Error(\"Fragno outbox coordinator initialization did not complete.\");\n }\n\n const initializedInternalCollection = internalCollection;\n const initializedOutboxSynchronizer = outboxSynchronizer;\n const initializedCollectionRegistry = collectionRegistry;\n\n // The adapter identity is part of the local database name, so a changed backend opens a new\n // database instead of reusing the previous source's persisted collections.\n\n // Registered targets are not versioned in local metadata. When adding or removing a collection,\n // delete this coordinator's local persistence database so every registered table catches up from\n // the beginning against one fresh shared checkpoint.\n\n // TanStack exposes no atomic cross-collection UI transaction. Every affected collection commits\n // an applied-entry checkpoint atomically with its own row changes, making partial entry replay\n // idempotent before the shared database checkpoint advances.\n\n let preloadPromise: Promise<void> | undefined;\n let streamPromise: Promise<void> | undefined;\n let cleanupPromise: Promise<void> | undefined;\n\n const transitionTo = (nextState: FragnoOutboxCoordinatorState, error: unknown = null) => {\n state = nextState;\n // The status row only reports state, so its receipt is not awaited. A persistence failure in\n // the internal collection also fails the next checkpoint commit, which fails the stream.\n void initializedInternalCollection.setState(nextState, error);\n };\n\n const transitionToFailed = (error: unknown) => {\n if (state !== \"disposed\") {\n transitionTo(\"failed\", error);\n }\n };\n\n transitionTo(\"idle\");\n\n const flushPersistence = async () => {\n await initializedInternalCollection.flushPersistence();\n };\n\n const isDisposed = () => state === \"disposed\";\n\n const firstCatchUp = Promise.withResolvers<void>();\n // Cleanup may reject readiness before a caller has started preload.\n void firstCatchUp.promise.catch(() => {});\n\n const startStreaming = () => {\n streamPromise ??= (async () => {\n let retryAttempt = 0;\n\n while (!isDisposed()) {\n try {\n await initializedOutboxSynchronizer.streamSession({\n onStarted(targetVersionstamp) {\n completedCatchUpPages = 0;\n totalCatchUpPages = catchUpPageCount(\n initializedInternalCollection.getCheckpoint()?.versionstamp,\n targetVersionstamp,\n );\n },\n onCaughtUp() {\n retryAttempt = 0;\n transitionTo(\"live\");\n firstCatchUp.resolve();\n },\n });\n if (!isDisposed()) {\n transitionTo(\"rotating\");\n transitionTo(\"replaying\");\n }\n } catch (error) {\n if (isDisposed()) {\n return;\n }\n\n if (!(error instanceof FragnoOutboxTransportError)) {\n firstCatchUp.reject(error);\n transitionToFailed(error);\n return;\n }\n transitionTo(\"retrying\", error);\n retryAttempt += 1;\n await waitForRetry(retryAttempt);\n if (isDisposed()) {\n return;\n }\n\n transitionTo(\"replaying\");\n }\n }\n })();\n void streamPromise.catch((error: unknown) => {\n firstCatchUp.reject(error);\n transitionToFailed(error);\n });\n };\n\n return {\n internal: initializedInternalCollection,\n\n get state() {\n return state;\n },\n\n collection(schema, tableName, collectionOptions) {\n return initializedCollectionRegistry.registerCollection(\n schema,\n tableName,\n state,\n collectionOptions,\n );\n },\n\n preload() {\n preloadPromise ??= (async () => {\n if (state !== \"idle\") {\n throw new Error(`Cannot preload Fragno collections while the coordinator is ${state}.`);\n }\n\n try {\n transitionTo(\"registering\");\n const collectionsReady = initializedCollectionRegistry.preload();\n const collectionsRegistered = initializedOutboxSynchronizer.waitUntilRegistered(\n initializedCollectionRegistry.registeredTargets(),\n );\n await waitForFragnoOutboxStartupStage(\n Promise.race([\n collectionsRegistered,\n collectionsReady.then(() => {\n throw new Error(\n \"Fragno collections became ready before outbox synchronization registration completed.\",\n );\n }),\n ]),\n \"registering synchronized collections\",\n );\n\n transitionTo(\"catching-up\");\n startStreaming();\n await firstCatchUp.promise;\n await collectionsReady;\n } catch (error) {\n transitionToFailed(error);\n throw error;\n }\n })();\n\n return preloadPromise;\n },\n\n flushPersistence,\n\n cleanup() {\n cleanupPromise ??= (async () => {\n transitionTo(\"disposed\");\n initializedOutboxSynchronizer.dispose();\n firstCatchUp.reject(new DOMException(\"Fragno outbox coordinator disposed.\", \"AbortError\"));\n await preloadPromise?.catch(() => {});\n await streamPromise?.catch(() => {});\n\n const errors: unknown[] = [];\n for (const cleanup of [\n flushPersistence,\n () => initializedCollectionRegistry.cleanup(),\n () => initializedInternalCollection.cleanup(),\n () => persistence.drain(),\n () => persistenceResource.cleanup(),\n ]) {\n try {\n await cleanup();\n } catch (error) {\n errors.push(error);\n }\n }\n\n if (errors.length > 0) {\n throw new AggregateError(errors, \"Failed to clean up the Fragno outbox coordinator.\");\n }\n })();\n\n return cleanupPromise;\n },\n };\n}\n\nasync function waitForFragnoOutboxStartupStage<T>(\n stagePromise: Promise<T>,\n stage: string,\n): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n const timeoutPromise = new Promise<never>((_, reject) => {\n timeout = setTimeout(() => {\n reject(\n new Error(\n `Fragno outbox startup timed out after ${FRAGNO_OUTBOX_STARTUP_TIMEOUT_MS}ms while ${stage}.`,\n ),\n );\n }, FRAGNO_OUTBOX_STARTUP_TIMEOUT_MS);\n });\n\n try {\n return await Promise.race([stagePromise, timeoutPromise]);\n } finally {\n clearTimeout(timeout);\n }\n}\n\nfunction waitForRetry(attempt: number): Promise<void> {\n const delayMs = Math.min(1_000, 25 * 2 ** Math.min(attempt - 1, 5));\n return new Promise<void>((resolve) => {\n setTimeout(resolve, delayMs);\n });\n}\n\nfunction catchUpPageCount(\n checkpointVersionstamp: string | undefined,\n currentVersionstamp: string | null,\n): number {\n if (!currentVersionstamp) {\n return 1;\n }\n\n const checkpointVersion = checkpointVersionstamp\n ? BigInt(`0x${checkpointVersionstamp.slice(0, 20)}`)\n : -1n;\n const currentVersion = BigInt(`0x${currentVersionstamp.slice(0, 20)}`);\n const remainingEntries =\n currentVersion > checkpointVersion ? currentVersion - checkpointVersion : 0n;\n return Math.max(\n 1,\n Number(\n (remainingEntries + BigInt(FRAGNO_OUTBOX_PAGE_SIZE) - 1n) / BigInt(FRAGNO_OUTBOX_PAGE_SIZE),\n ),\n );\n}\n\nasync function sha256Hex(value: string): Promise<string> {\n const digest = await globalThis.crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(value));\n return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nasync function openBrowserFragnoOutboxPersistence(options: { databaseName: string }): Promise<{\n persistence: PersistedCollectionPersistence;\n cleanup(): Promise<void>;\n}> {\n const {\n BrowserCollectionCoordinator,\n createBrowserWASQLitePersistence,\n openBrowserWASQLiteOPFSDatabase,\n } = await import(\"@tanstack/browser-db-sqlite-persistence\");\n const database = await openBrowserWASQLiteOPFSDatabase({ databaseName: options.databaseName });\n const coordinator = new BrowserCollectionCoordinator({ dbName: options.databaseName });\n\n return {\n persistence: createBrowserWASQLitePersistence({ database, coordinator }),\n async cleanup() {\n coordinator.dispose();\n await database.close?.();\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,85 @@
1
+ import { FragnoOutboxTransportError, rethrowOutboxNetworkFailure } from "./outbox-transport-error.js";
2
+ import { parseOutboxStreamFrame } from "@fragno-dev/db/outbox-stream";
3
+ //#region src/outbox-stream.ts
4
+ /** Protocol violations are terminal: retrying cannot repair an incompatible or changed source. */
5
+ var FragnoOutboxProtocolError = class extends Error {};
6
+ /** Consumes one ordered stream session; only a final rotate frame permits normal completion. */
7
+ async function consumeNdjsonOutboxStream(body, consumer) {
8
+ const reader = body.getReader();
9
+ const decoder = new TextDecoder();
10
+ let buffer = "";
11
+ let completed = false;
12
+ let started = false;
13
+ let caughtUp = false;
14
+ let rotated = false;
15
+ let target = null;
16
+ let previousVersionstamp = consumer.afterVersionstamp;
17
+ const cancelReader = () => {
18
+ reader.cancel(consumer.signal.reason).catch(() => {});
19
+ };
20
+ if (consumer.signal.aborted) cancelReader();
21
+ else consumer.signal.addEventListener("abort", cancelReader, { once: true });
22
+ async function consumeFrame(line) {
23
+ let frame;
24
+ try {
25
+ frame = parseOutboxStreamFrame(JSON.parse(line));
26
+ } catch (cause) {
27
+ throw new FragnoOutboxProtocolError("Invalid Fragno outbox stream frame.", { cause });
28
+ }
29
+ if (rotated || !started && frame.type !== "started") throw new FragnoOutboxProtocolError("Invalid Fragno outbox stream frame order.");
30
+ switch (frame.type) {
31
+ case "started":
32
+ if (started) throw new FragnoOutboxProtocolError("Duplicate Fragno outbox started frame.");
33
+ started = true;
34
+ target = frame.catchUpTargetVersionstamp;
35
+ break;
36
+ case "entry":
37
+ if (previousVersionstamp !== void 0 && frame.entry.versionstamp <= previousVersionstamp || !caughtUp && (target === null || frame.entry.versionstamp > target)) throw new FragnoOutboxProtocolError("Fragno outbox stream entries are not strictly ordered around the catch-up boundary.");
38
+ previousVersionstamp = frame.entry.versionstamp;
39
+ break;
40
+ case "caught-up":
41
+ if (caughtUp || frame.throughVersionstamp !== target || target !== null && previousVersionstamp !== target) throw new FragnoOutboxProtocolError("Invalid Fragno outbox caught-up boundary.");
42
+ caughtUp = true;
43
+ break;
44
+ case "rotate":
45
+ rotated = true;
46
+ break;
47
+ case "heartbeat": break;
48
+ }
49
+ await consumer.onFrame(frame);
50
+ }
51
+ try {
52
+ while (!consumer.signal.aborted) {
53
+ let result;
54
+ try {
55
+ result = await reader.read();
56
+ } catch (cause) {
57
+ rethrowOutboxNetworkFailure(cause, consumer.signal);
58
+ }
59
+ const { done, value } = result;
60
+ if (done) {
61
+ completed = true;
62
+ break;
63
+ }
64
+ buffer += decoder.decode(value, { stream: true });
65
+ let newline;
66
+ while (!consumer.signal.aborted && (newline = buffer.indexOf("\n")) !== -1) {
67
+ const line = buffer.slice(0, newline);
68
+ buffer = buffer.slice(newline + 1);
69
+ await consumeFrame(line);
70
+ }
71
+ }
72
+ if (consumer.signal.aborted) throw new DOMException("Fragno outbox streaming was aborted.", "AbortError");
73
+ buffer += decoder.decode();
74
+ if (!rotated) throw new FragnoOutboxTransportError("Fragno outbox stream closed unexpectedly.");
75
+ if (buffer.length > 0) throw new FragnoOutboxProtocolError("Fragno outbox stream contains data after rotation.");
76
+ } finally {
77
+ consumer.signal.removeEventListener("abort", cancelReader);
78
+ if (!completed) await reader.cancel().catch(() => {});
79
+ reader.releaseLock();
80
+ }
81
+ }
82
+ //#endregion
83
+ export { FragnoOutboxProtocolError, consumeNdjsonOutboxStream };
84
+
85
+ //# sourceMappingURL=outbox-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox-stream.js","names":[],"sources":["../src/outbox-stream.ts"],"sourcesContent":["import { parseOutboxStreamFrame, type OutboxStreamFrame } from \"@fragno-dev/db/outbox-stream\";\n\nimport { FragnoOutboxTransportError, rethrowOutboxNetworkFailure } from \"./outbox-transport-error\";\n\n/** Protocol violations are terminal: retrying cannot repair an incompatible or changed source. */\nexport class FragnoOutboxProtocolError extends Error {}\n\ntype FragnoOutboxStreamConsumer = {\n signal: AbortSignal;\n afterVersionstamp: string | undefined;\n onFrame(frame: OutboxStreamFrame): void | Promise<void>;\n};\n\n/** Consumes one ordered stream session; only a final rotate frame permits normal completion. */\nexport async function consumeNdjsonOutboxStream(\n body: ReadableStream<Uint8Array>,\n consumer: FragnoOutboxStreamConsumer,\n): Promise<void> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let completed = false;\n let started = false;\n let caughtUp = false;\n let rotated = false;\n let target: string | null = null;\n let previousVersionstamp = consumer.afterVersionstamp;\n const cancelReader = () => {\n void reader.cancel(consumer.signal.reason).catch(() => {});\n };\n if (consumer.signal.aborted) {\n cancelReader();\n } else {\n consumer.signal.addEventListener(\"abort\", cancelReader, { once: true });\n }\n\n async function consumeFrame(line: string): Promise<void> {\n let frame: OutboxStreamFrame;\n try {\n frame = parseOutboxStreamFrame(JSON.parse(line));\n } catch (cause) {\n throw new FragnoOutboxProtocolError(\"Invalid Fragno outbox stream frame.\", { cause });\n }\n if (rotated || (!started && frame.type !== \"started\")) {\n throw new FragnoOutboxProtocolError(\"Invalid Fragno outbox stream frame order.\");\n }\n switch (frame.type) {\n case \"started\":\n if (started) {\n throw new FragnoOutboxProtocolError(\"Duplicate Fragno outbox started frame.\");\n }\n started = true;\n target = frame.catchUpTargetVersionstamp;\n break;\n case \"entry\":\n if (\n (previousVersionstamp !== undefined &&\n frame.entry.versionstamp <= previousVersionstamp) ||\n (!caughtUp && (target === null || frame.entry.versionstamp > target))\n ) {\n throw new FragnoOutboxProtocolError(\n \"Fragno outbox stream entries are not strictly ordered around the catch-up boundary.\",\n );\n }\n previousVersionstamp = frame.entry.versionstamp;\n break;\n case \"caught-up\":\n if (\n caughtUp ||\n frame.throughVersionstamp !== target ||\n (target !== null && previousVersionstamp !== target)\n ) {\n throw new FragnoOutboxProtocolError(\"Invalid Fragno outbox caught-up boundary.\");\n }\n caughtUp = true;\n break;\n case \"rotate\":\n rotated = true;\n break;\n case \"heartbeat\":\n break;\n }\n await consumer.onFrame(frame);\n }\n\n try {\n while (!consumer.signal.aborted) {\n let result: ReadableStreamReadResult<Uint8Array>;\n try {\n result = await reader.read();\n } catch (cause) {\n rethrowOutboxNetworkFailure(cause, consumer.signal);\n }\n const { done, value } = result;\n if (done) {\n completed = true;\n break;\n }\n buffer += decoder.decode(value, { stream: true });\n let newline: number;\n while (!consumer.signal.aborted && (newline = buffer.indexOf(\"\\n\")) !== -1) {\n const line = buffer.slice(0, newline);\n buffer = buffer.slice(newline + 1);\n await consumeFrame(line);\n }\n }\n if (consumer.signal.aborted) {\n throw new DOMException(\"Fragno outbox streaming was aborted.\", \"AbortError\");\n }\n buffer += decoder.decode();\n if (!rotated) {\n throw new FragnoOutboxTransportError(\"Fragno outbox stream closed unexpectedly.\");\n }\n if (buffer.length > 0) {\n throw new FragnoOutboxProtocolError(\"Fragno outbox stream contains data after rotation.\");\n }\n } finally {\n consumer.signal.removeEventListener(\"abort\", cancelReader);\n if (!completed) {\n await reader.cancel().catch(() => {});\n }\n reader.releaseLock();\n }\n}\n"],"mappings":";;;;AAKA,IAAa,4BAAb,cAA+C,MAAM,CAAC;;AAStD,eAAsB,0BACpB,MACA,UACe;CACf,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,SAAS;CACb,IAAI,YAAY;CAChB,IAAI,UAAU;CACd,IAAI,WAAW;CACf,IAAI,UAAU;CACd,IAAI,SAAwB;CAC5B,IAAI,uBAAuB,SAAS;CACpC,MAAM,qBAAqB;EACzB,OAAY,OAAO,SAAS,OAAO,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;CAC3D;CACA,IAAI,SAAS,OAAO,SAClB,aAAa;MAEb,SAAS,OAAO,iBAAiB,SAAS,cAAc,EAAE,MAAM,KAAK,CAAC;CAGxE,eAAe,aAAa,MAA6B;EACvD,IAAI;EACJ,IAAI;GACF,QAAQ,uBAAuB,KAAK,MAAM,IAAI,CAAC;EACjD,SAAS,OAAO;GACd,MAAM,IAAI,0BAA0B,uCAAuC,EAAE,MAAM,CAAC;EACtF;EACA,IAAI,WAAY,CAAC,WAAW,MAAM,SAAS,WACzC,MAAM,IAAI,0BAA0B,2CAA2C;EAEjF,QAAQ,MAAM,MAAd;GACE,KAAK;IACH,IAAI,SACF,MAAM,IAAI,0BAA0B,wCAAwC;IAE9E,UAAU;IACV,SAAS,MAAM;IACf;GACF,KAAK;IACH,IACG,yBAAyB,KAAA,KACxB,MAAM,MAAM,gBAAgB,wBAC7B,CAAC,aAAa,WAAW,QAAQ,MAAM,MAAM,eAAe,SAE7D,MAAM,IAAI,0BACR,qFACF;IAEF,uBAAuB,MAAM,MAAM;IACnC;GACF,KAAK;IACH,IACE,YACA,MAAM,wBAAwB,UAC7B,WAAW,QAAQ,yBAAyB,QAE7C,MAAM,IAAI,0BAA0B,2CAA2C;IAEjF,WAAW;IACX;GACF,KAAK;IACH,UAAU;IACV;GACF,KAAK,aACH;EACJ;EACA,MAAM,SAAS,QAAQ,KAAK;CAC9B;CAEA,IAAI;EACF,OAAO,CAAC,SAAS,OAAO,SAAS;GAC/B,IAAI;GACJ,IAAI;IACF,SAAS,MAAM,OAAO,KAAK;GAC7B,SAAS,OAAO;IACd,4BAA4B,OAAO,SAAS,MAAM;GACpD;GACA,MAAM,EAAE,MAAM,UAAU;GACxB,IAAI,MAAM;IACR,YAAY;IACZ;GACF;GACA,UAAU,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;GAChD,IAAI;GACJ,OAAO,CAAC,SAAS,OAAO,YAAY,UAAU,OAAO,QAAQ,IAAI,OAAO,IAAI;IAC1E,MAAM,OAAO,OAAO,MAAM,GAAG,OAAO;IACpC,SAAS,OAAO,MAAM,UAAU,CAAC;IACjC,MAAM,aAAa,IAAI;GACzB;EACF;EACA,IAAI,SAAS,OAAO,SAClB,MAAM,IAAI,aAAa,wCAAwC,YAAY;EAE7E,UAAU,QAAQ,OAAO;EACzB,IAAI,CAAC,SACH,MAAM,IAAI,2BAA2B,2CAA2C;EAElF,IAAI,OAAO,SAAS,GAClB,MAAM,IAAI,0BAA0B,oDAAoD;CAE5F,UAAU;EACR,SAAS,OAAO,oBAAoB,SAAS,YAAY;EACzD,IAAI,CAAC,WACH,MAAM,OAAO,OAAO,CAAC,CAAC,YAAY,CAAC,CAAC;EAEtC,OAAO,YAAY;CACrB;AACF"}
@@ -0,0 +1,12 @@
1
+ //#region src/outbox-transport-error.ts
2
+ /** Only transport interruptions permit retrying an outbox stream session. */
3
+ var FragnoOutboxTransportError = class extends Error {};
4
+ /** Classify network errors only at Fetch/read boundaries, never around decoding or application. */
5
+ function rethrowOutboxNetworkFailure(cause, signal) {
6
+ if (!signal?.aborted && (cause instanceof TypeError || cause instanceof DOMException && cause.name === "NetworkError")) throw new FragnoOutboxTransportError("Fragno outbox stream transport interrupted.", { cause });
7
+ throw cause;
8
+ }
9
+ //#endregion
10
+ export { FragnoOutboxTransportError, rethrowOutboxNetworkFailure };
11
+
12
+ //# sourceMappingURL=outbox-transport-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox-transport-error.js","names":[],"sources":["../src/outbox-transport-error.ts"],"sourcesContent":["/** Only transport interruptions permit retrying an outbox stream session. */\nexport class FragnoOutboxTransportError extends Error {}\n\n/** Classify network errors only at Fetch/read boundaries, never around decoding or application. */\nexport function rethrowOutboxNetworkFailure(\n cause: unknown,\n signal: AbortSignal | undefined,\n): never {\n // Fetch reports network failures as TypeError (or NetworkError in some stream implementations).\n // Construct and validate requests outside this boundary so request errors remain terminal.\n if (\n !signal?.aborted &&\n (cause instanceof TypeError || (cause instanceof DOMException && cause.name === \"NetworkError\"))\n ) {\n throw new FragnoOutboxTransportError(\"Fragno outbox stream transport interrupted.\", { cause });\n }\n throw cause;\n}\n"],"mappings":";;AACA,IAAa,6BAAb,cAAgD,MAAM,CAAC;;AAGvD,SAAgB,4BACd,OACA,QACO;CAGP,IACE,CAAC,QAAQ,YACR,iBAAiB,aAAc,iBAAiB,gBAAgB,MAAM,SAAS,iBAEhF,MAAM,IAAI,2BAA2B,+CAA+C,EAAE,MAAM,CAAC;CAE/F,MAAM;AACR"}