@abloatai/ablo 0.27.0 → 0.28.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 (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/README.md +1 -1
  3. package/dist/BaseSyncedStore.js +8 -9
  4. package/dist/Database.d.ts +14 -1
  5. package/dist/Database.js +225 -28
  6. package/dist/Model.d.ts +17 -0
  7. package/dist/Model.js +32 -1
  8. package/dist/SyncClient.d.ts +10 -6
  9. package/dist/SyncClient.js +379 -76
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +12 -0
  12. package/dist/cli.cjs +6 -2
  13. package/dist/client/Ablo.d.ts +24 -1
  14. package/dist/client/Ablo.js +4 -3
  15. package/dist/client/ApiClient.js +309 -43
  16. package/dist/client/createInternalComponents.d.ts +2 -0
  17. package/dist/client/createInternalComponents.js +1 -1
  18. package/dist/client/httpClient.d.ts +2 -0
  19. package/dist/client/modelRegistration.js +11 -0
  20. package/dist/client/options.d.ts +23 -0
  21. package/dist/client/wsMutationExecutor.d.ts +3 -2
  22. package/dist/client/wsMutationExecutor.js +3 -2
  23. package/dist/commit/contract.d.ts +493 -0
  24. package/dist/commit/contract.js +187 -0
  25. package/dist/commit/index.d.ts +6 -0
  26. package/dist/commit/index.js +5 -0
  27. package/dist/core/StoreManager.d.ts +2 -0
  28. package/dist/core/StoreManager.js +12 -0
  29. package/dist/errorCodes.js +6 -2
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +2 -0
  32. package/dist/interfaces/index.d.ts +2 -2
  33. package/dist/mutators/UndoManager.d.ts +2 -0
  34. package/dist/mutators/UndoManager.js +32 -0
  35. package/dist/react/useAblo.d.ts +6 -4
  36. package/dist/react/useAblo.js +25 -3
  37. package/dist/schema/index.d.ts +1 -1
  38. package/dist/schema/schema.d.ts +31 -1
  39. package/dist/stores/ObjectStore.d.ts +14 -1
  40. package/dist/stores/ObjectStore.js +27 -4
  41. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  42. package/dist/surface.d.ts +1 -1
  43. package/dist/surface.js +2 -0
  44. package/dist/sync/SyncWebSocket.d.ts +2 -1
  45. package/dist/sync/persistedPrefix.d.ts +12 -0
  46. package/dist/sync/persistedPrefix.js +22 -0
  47. package/dist/testing/index.d.ts +2 -0
  48. package/dist/testing/index.js +1 -0
  49. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  50. package/dist/testing/mocks/FakeDatabase.js +10 -0
  51. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  52. package/dist/transactions/TransactionQueue.d.ts +66 -8
  53. package/dist/transactions/TransactionQueue.js +607 -89
  54. package/dist/transactions/commitEnvelope.d.ts +132 -0
  55. package/dist/transactions/commitEnvelope.js +139 -0
  56. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  57. package/dist/transactions/commitOutboxStore.js +26 -0
  58. package/dist/transactions/commitPayload.d.ts +15 -0
  59. package/dist/transactions/commitPayload.js +6 -0
  60. package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
  61. package/dist/transactions/httpCommitEnvelope.js +179 -0
  62. package/dist/transactions/replayValidation.d.ts +83 -0
  63. package/dist/transactions/replayValidation.js +46 -1
  64. package/dist/wire/bootstrapReason.d.ts +9 -0
  65. package/dist/wire/bootstrapReason.js +8 -0
  66. package/dist/wire/frames.d.ts +236 -0
  67. package/dist/wire/frames.js +21 -0
  68. package/dist/wire/index.d.ts +4 -2
  69. package/dist/wire/index.js +2 -1
  70. package/docs/api.md +10 -10
  71. package/docs/coordination.md +2 -2
  72. package/docs/mcp.md +1 -1
  73. package/package.json +7 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Commits are now crash-durable — a sealed envelope replays the exact request after a restart, on both transports.** Before a batch reaches the wire, the WebSocket client seals it into one atomic outbox record: the stable idempotency key, the exact JSON-normalized ordered operations, and the source mutations it supersedes. Recovery replays the sealed request as it was sent — never re-projected from model state in the new process — and a multi-operation commit is only ever replayed whole, so an atomic A+B can't come back later as A and B separately.
8
+
9
+ - **The stateless HTTP client replays its outbox on startup.** It persists the exact request (method, path, body, idempotency key) and re-sends unacknowledged envelopes before its first new request. A definitive rejection (a 4xx other than 429) settles the envelope instead of retrying it.
10
+
11
+ - **New `commitOutbox` client option + `CommitOutboxStore` interface.** Browsers default to strict IndexedDB storage when persistence is on; Node agents and workers can inject workflow-, SQLite-, or filesystem-backed storage so commits survive process restarts. The client's transaction journal now writes with strict durability, so an acknowledged outbox record means disk-backed.
12
+
13
+ - **Replay is fenced and fails closed.** Reusing an idempotency key with a different request is rejected on both transports; replay is scoped to the actor and server that sealed the envelope and bounded to the server's idempotency retention, so a stale envelope is held for review instead of re-sent; a write queued behind an ambiguous predecessor waits for it to settle rather than overtaking it; and a persistence adapter that cannot guarantee durability reports failure instead of phantom success. Generated create ids are sealed with the request, so a crash mid-create recovers the same row.
14
+
15
+ - **`@abloatai/ablo/commit` publishes the transport-independent v2 commit contract.** Branded identifiers, ordered operations, explicit write preconditions (`unchanged_since`, `version_matches`, `claim_fence`), and a discriminated `CommitReceipt` — `committed`, `conflicted` (carrying the smallest current state needed to reconcile), or `rejected` (a typed error plus the capability a retry would need). This release publishes the contract so tooling and tests can validate against it today; the engine's live commit path still speaks the current protocol, and the cutover to v2 ships server-side.
16
+
17
+ - **New wire exports.** `@abloatai/ablo/wire` gains the matching `CommitRequestMessage` / `CommitResultMessage` frames, re-exports the current transport's operation schemas under `legacyCommit*` names, and exports `BootstrapReason` — the machine-readable reasons a live delta stream must resume via catch-up (`too_far_behind`, `too_many_deltas`, `missing_entities`, `stream_gap`).
18
+
19
+ - **Reactive reads are typed as what they actually return: snapshot rows, not model instances.** `useAblo` selectors now receive `AbloReads<Schema>` — model reads typed as the row's data fields plus schema computeds, without relation accessors or model methods. Reading `row.layers` inside a selector is a compile error instead of a silent `undefined` at runtime; compose relations through a dedicated hook or selector instead. The new `Row<'slides'>` / `InferRow` types are the snapshot-row companion to `Model<'slides'>`, mirroring what `toReactiveSnapshot()` produces. This is a type-level change only: selectors that compiled against relation accessors now fail to compile, and each of those reads was already returning `undefined` — the new errors point at real bugs. Runtime behavior is unchanged.
20
+
21
+ ### Patch Changes
22
+
23
+ - **The resume cursor advances only through the contiguous durable prefix of a delta frame.** A frame spanning several local stores commits per store, and those transactions can fail independently — taking the maximum id from any successful store could permanently skip an earlier failed delta on every later catch-up.
24
+
25
+ - **Conflicting undo-scope options warn instead of being silently ignored.** A scope keeps the options it was created with; the silent version of that rule could leave a surface believing it enabled stream recording when it hadn't.
26
+
27
+ - **Docs and error copy name the real HTTP mount.** REST paths are documented as `/api/v1/...` — the OpenAPI server URLs carry the `/api` mount — and the `jwt_issuer_untrusted` message no longer points at an endpoint that doesn't exist.
28
+
3
29
  ## 0.27.0
4
30
 
5
31
  ### Minor Changes
@@ -8,7 +34,7 @@
8
34
 
9
35
  - **Beats carry progress and pressure.** `heartbeat({ details })` stores lightweight progress as the claim's peer-visible `meta.progress` (last beat wins, via `claim.state`); every beat's answer reports `queueDepth` — how many participants wait in line behind the lease — observable per auto-beat with the new `onHeartbeat` claim option.
10
36
 
11
- - **`ablo.claims.heartbeatAll({ ttl })`** extends every lease the credential holds in one request (`POST /v1/claims/heartbeat`) — the stateless twin of the WebSocket keepalive, for workers holding many rows.
37
+ - **`ablo.claims.heartbeatAll({ ttl })`** extends every lease the credential holds in one request (`POST /api/v1/claims/heartbeat`) — the stateless twin of the WebSocket keepalive, for workers holding many rows.
12
38
 
13
39
  - **Keepalive renewals extend but never shorten a lease**, so an explicit work-duration `ttl` now survives pings and brief reconnects instead of collapsing to the liveness window.
14
40
 
@@ -39,7 +65,7 @@
39
65
  ### Minor Changes
40
66
 
41
67
  - ca30064: Logical replication is now the documented default storage path, with self-service data-source registration from the CLI.
42
- - **`ablo connect --register`** — registers your database as an Ablo data source over logical replication in one step: it runs the same pre-flight replication probe `ablo connect` uses (server reachable, `REPLICATION` privilege, `wal_level=logical`, publication/slot creatable), and on success `POST`s the connection to the engine's `/v1/datasources`. The `ek_`-authed call scopes the source to your org automatically; the password is stored decomposed as a secret, never echoed back. This is the "registration is the enable" path — there is no separate tier or flag to pick.
68
+ - **`ablo connect --register`** — registers your database as an Ablo data source over logical replication in one step: it runs the same pre-flight replication probe `ablo connect` uses (server reachable, `REPLICATION` privilege, `wal_level=logical`, publication/slot creatable), and on success `POST`s the connection to the engine's `/api/v1/datasources`. The `ek_`-authed call scopes the source to your org automatically; the password is stored decomposed as a secret, never echoed back. This is the "registration is the enable" path — there is no separate tier or flag to pick.
43
69
  - **`ablo init` leads with logical replication** — the default storage mode is now `replication` (was `endpoint`); the generated env + next-steps point at `ablo connect` / `ablo connect --register`. The signed-endpoint and direct modes remain as the explicit fallback / legacy options.
44
70
  - **`ablo status`** — a data-plane diagnostic that probes whether your registered source is reachable and replicating (failure-only reporting, so it never falsely reports healthy).
45
71
  - **`ablo push`** — minor guard/UX refinements (deploy-target clarity).
@@ -594,7 +620,7 @@
594
620
 
595
621
  ### Patch Changes
596
622
 
597
- - `Model<'name'>` type helper via the `Register` binding — name your model in one parameter (`Model<'tasks'>`) instead of restating `typeof schema`; `Model<S, 'name'>` is also supported and `InferModel` is deprecated. CLI: retire the stale `dev` wording from the login outro and `push` header. Docs: cover the `Register` binding end-to-end and document the `pk_` publishable key + the `/v1/commits` HTTP path.
623
+ - `Model<'name'>` type helper via the `Register` binding — name your model in one parameter (`Model<'tasks'>`) instead of restating `typeof schema`; `Model<S, 'name'>` is also supported and `InferModel` is deprecated. CLI: retire the stale `dev` wording from the login outro and `push` header. Docs: cover the `Register` binding end-to-end and document the `pk_` publishable key + the `/api/v1/commits` HTTP path.
598
624
  - 3024593: Fix `sessions.create({ user })` 403 — user sessions now mint via the sk\_-gated ephemeral-key door
599
625
  - `sessions.create({ user })` mints an `ek_` user session via `/auth/ephemeral-keys` (was wrongly routed through `/auth/capability`, which rejects human participants — writes were being attributed to agents).
600
626
  - Control-plane calls always present your original `sk_`, never the client's exchanged sync credential.
package/README.md CHANGED
@@ -442,7 +442,7 @@ HTTP endpoint when a server-to-server caller needs to write without opening a
442
442
  WebSocket:
443
443
 
444
444
  ```bash
445
- curl https://api.abloatai.com/v1/commits \
445
+ curl https://api.abloatai.com/api/v1/commits \
446
446
  -H "Authorization: Bearer sk_test_..." \
447
447
  -H "Content-Type: application/json" \
448
448
  -d '{ "operations": [
@@ -602,7 +602,7 @@ export class BaseSyncedStore {
602
602
  this.syncWebSocket?.disconnect();
603
603
  this.updateSyncStatus({ state: 'error', error: error });
604
604
  // SECURITY: Clear locally cached data when session is invalid
605
- this.database.clear().catch(() => { });
605
+ this.database.clear({ includeWriteJournal: true }).catch(() => { });
606
606
  this.objectPool.clear();
607
607
  return 'session_error';
608
608
  }
@@ -779,16 +779,15 @@ export class BaseSyncedStore {
779
779
  if (this.initialized)
780
780
  return { success: true };
781
781
  this.userContext = context;
782
- // Propagate identity to the SyncClient. Without this, every mutation
783
- // silently drops in the mutation-staging path with a null userId and
784
- // organizationId, because those guards early-return rather than throw.
785
- // Setting it here, where the store receives the context, keeps identity
786
- // in a single place.
787
- yield this.syncClient.initialize(context.userId, context.organizationId);
788
782
  try {
789
783
  this.updateSyncStatus({ state: 'syncing', progress: 0 });
790
- // Open database
784
+ // The commit outbox and offline mutation journal live in IndexedDB.
785
+ // Open it before SyncClient restores either one; reading first used to
786
+ // make persistence silently look empty on every cold start.
791
787
  yield this.database.open(context.userId, context.organizationId);
788
+ // Propagate identity only after storage is ready, then restore sealed
789
+ // requests before accepting fresh mutations.
790
+ yield this.syncClient.initialize(context.userId, context.organizationId);
792
791
  // Hydrate from IndexedDB (fast, cached data)
793
792
  let hasLocalData = false;
794
793
  try {
@@ -1202,7 +1201,7 @@ export class BaseSyncedStore {
1202
1201
  this.updateSyncStatus({ state: 'error', error, isSessionError: true });
1203
1202
  // SECURITY: Clear IndexedDB data on session expiry.
1204
1203
  // When auth is revoked, locally cached data must not persist on disk.
1205
- this.database.clear().catch((clearErr) => {
1204
+ this.database.clear({ includeWriteJournal: true }).catch((clearErr) => {
1206
1205
  // consumer register: session ended, but cached data may remain on disk
1207
1206
  getContext().logger.error('Your session ended, but some locally cached data could not be cleared from this device.');
1208
1207
  getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
@@ -286,8 +286,19 @@ export declare class Database {
286
286
  * branch on which one they got back. */
287
287
  private get transactionStore();
288
288
  saveTransaction(transaction: PersistedTransaction): Promise<void>;
289
+ /** Persist one burst of journal rows in a single strict durability group. */
290
+ saveTransactions(transactions: readonly PersistedTransaction[]): Promise<void>;
289
291
  removeTransaction(id: string): Promise<void>;
290
292
  getPersistedTransactions(): Promise<PersistedTransaction[]>;
293
+ getPersistedTransaction(id: string): Promise<PersistedTransaction | undefined>;
294
+ /**
295
+ * Atomically seal one exact commit request and consume the staged mutation
296
+ * records it replaces. The read, optional add, and deletes share one strict
297
+ * IndexedDB transaction, so a crash can expose the staged records or the
298
+ * sealed envelope, never a missing handoff. Returns the pre-existing record
299
+ * when the envelope id was already sealed (retry/re-entrant call).
300
+ */
301
+ sealTransactionRecord(record: PersistedTransaction, consumedRecordIds: readonly string[]): Promise<PersistedTransaction | undefined>;
291
302
  cleanupOldTransactions(maxAge: number): Promise<number>;
292
303
  /**
293
304
  * Store management
@@ -332,6 +343,8 @@ export declare class Database {
332
343
  */
333
344
  isOpen(): boolean;
334
345
  close(): Promise<void>;
335
- clear(): Promise<void>;
346
+ clear(options?: {
347
+ includeWriteJournal?: boolean;
348
+ }): Promise<void>;
336
349
  }
337
350
  export {};
package/dist/Database.js CHANGED
@@ -13,6 +13,43 @@ import { getContext } from './context.js';
13
13
  import { AbloConnectionError, AbloValidationError } from './errors.js';
14
14
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
15
15
  import { syncPositionSchema } from './sync/syncPosition.js';
16
+ import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
17
+ /**
18
+ * Request identity excludes local timing metadata for re-entrant seals: a
19
+ * retry rebuilds its envelope with a fresh `sequence`/seal clock, so comparing
20
+ * those volatile fields would reject every legitimate same-request re-seal as
21
+ * an idempotency conflict. Only the fields that define the wire request count.
22
+ */
23
+ function isSameOutboxRecord(existing, candidate) {
24
+ if (existing.type === 'http_commit_envelope' &&
25
+ candidate.type === 'http_commit_envelope') {
26
+ const identity = (record) => ({
27
+ id: record.id,
28
+ type: record.type,
29
+ storageVersion: record.storageVersion,
30
+ idempotencyKey: record.idempotencyKey,
31
+ request: record.request,
32
+ scopeNamespace: record.scopeNamespace,
33
+ });
34
+ return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
35
+ }
36
+ if (existing.type === 'commit_envelope' &&
37
+ candidate.type === 'commit_envelope') {
38
+ const identity = (record) => ({
39
+ id: record.id,
40
+ type: record.type,
41
+ storageVersion: record.storageVersion,
42
+ origin: record.origin,
43
+ idempotencyKey: record.idempotencyKey,
44
+ operations: record.operations,
45
+ sourceMutationIds: record.sourceMutationIds,
46
+ commitOptions: record.commitOptions,
47
+ scope: record.scope,
48
+ });
49
+ return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
50
+ }
51
+ return JSON.stringify(existing) === JSON.stringify(candidate);
52
+ }
16
53
  export class Database {
17
54
  // Core database components
18
55
  databaseManager;
@@ -741,10 +778,10 @@ export class Database {
741
778
  }
742
779
  // Group deltas by store for efficient transaction management.
743
780
  //
744
- // The method tracks two high-water marks: `highestSyncId` for the total
745
- // range seen, and `highestPersistedSyncId`, accumulated only from deltas
746
- // whose store transaction actually committed. The cursor advance (at
747
- // `updateWorkspaceMetadata`) uses only the persisted one.
781
+ // The method tracks the total range seen plus the exact input indexes whose
782
+ // store transaction committed. The cursor is derived from their ordered
783
+ // prefix after every store finishes; a maximum alone is unsafe because a
784
+ // later store can succeed after an earlier store failed.
748
785
  //
749
786
  // Without this split, a single store-level failure (a compacted record
750
787
  // missing a required field, a validation abort) would advance the cursor
@@ -754,7 +791,7 @@ export class Database {
754
791
  // permanently behind the server with no way to recover on reload.
755
792
  const deltasByStore = new Map();
756
793
  let highestSyncId = 0;
757
- let highestPersistedSyncId = 0;
794
+ const persistedIndexes = new Set();
758
795
  let skippedDueToConflict = 0;
759
796
  deltas.forEach((delta, idx) => {
760
797
  // Normalize to number — postgres sends bigint syncIds as strings.
@@ -785,6 +822,9 @@ export class Database {
785
822
  deleteSyncId,
786
823
  });
787
824
  results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
825
+ // The later delete in this same ordered frame supersedes this
826
+ // value, so the stale predecessor requires no separate write.
827
+ persistedIndexes.add(idx);
788
828
  skippedDueToConflict++;
789
829
  return; // Skip this delta
790
830
  }
@@ -983,14 +1023,14 @@ export class Database {
983
1023
  tx.oncomplete = () => { resolve(); };
984
1024
  tx.onerror = () => { reject(tx.error); };
985
1025
  });
986
- // Only commit staged results to the global results if the transaction succeeded.
987
- // Advance `highestPersistedSyncId` only for deltas in this successful
988
- // transaction, so the cursor can't advance past rows that never wrote to IDB.
1026
+ // Only commit staged results to the global results if the transaction
1027
+ // succeeded. Record input indexes rather than a maximum sync id; the
1028
+ // durable cursor is the prefix through these indexes.
989
1029
  for (const r of stagedResults) {
990
1030
  // Resolve the originating delta so we can carry its
991
1031
  // transactionId through to the result. Echo detection in
992
1032
  // `SyncClient.applyDeltaBatchToPool` reads it.
993
- const sourceDelta = storeDeltas.find(({ idx }) => idx === r.idx)?.delta;
1033
+ const sourceDelta = deltas[r.idx];
994
1034
  results[r.idx] = {
995
1035
  action: r.action,
996
1036
  modelName: r.modelName,
@@ -998,13 +1038,7 @@ export class Database {
998
1038
  data: r.data,
999
1039
  transactionId: sourceDelta?.transactionId,
1000
1040
  };
1001
- const rawSyncId = storeDeltas[storeDeltas.findIndex(({ idx }) => idx === r.idx)]?.delta.syncId;
1002
- // SyncDelta.syncId is typed as number but postgres serializes
1003
- // bigint to string on the wire — coerce before compare.
1004
- const syncId = typeof rawSyncId === 'string' ? Number(rawSyncId) : rawSyncId;
1005
- if (typeof syncId === 'number' && !isNaN(syncId) && syncId > highestPersistedSyncId) {
1006
- highestPersistedSyncId = syncId;
1007
- }
1041
+ persistedIndexes.add(r.idx);
1008
1042
  }
1009
1043
  }
1010
1044
  catch (err) {
@@ -1038,11 +1072,12 @@ export class Database {
1038
1072
  }
1039
1073
  }
1040
1074
  }
1041
- // Update metadata only to the highest syncId whose store transaction
1042
- // actually committed. Using `highestSyncId` (the range-seen max) would
1043
- // advance the cursor past deltas that failed to persist — the "cursor
1044
- // ahead of IDB" divergence that makes subsequent partial bootstraps
1045
- // skip the missing rows forever.
1075
+ // Advance only through the durable INPUT PREFIX. IDs need not be
1076
+ // numerically contiguous because other tenants and filtered sync groups
1077
+ // occupy gaps; the server-delivered order is the relevant sequence.
1078
+ const highestPersistedSyncId = highestPersistedPrefixSyncId(deltas, persistedIndexes);
1079
+ // Using `highestSyncId` (the range-seen max) would advance past an earlier
1080
+ // failed store transaction and permanently skip its delta.
1046
1081
  //
1047
1082
  // If `highestPersistedSyncId === 0` (every store tx failed), we leave
1048
1083
  // the metadata alone. Next partial bootstrap will re-deliver the
@@ -1151,16 +1186,51 @@ export class Database {
1151
1186
  * delete/getAll/getAllFromIndex surface, so callers don't need to
1152
1187
  * branch on which one they got back. */
1153
1188
  get transactionStore() {
1154
- return this.getStore('__transactions');
1189
+ return this.getRequiredStore('__transactions');
1155
1190
  }
1156
1191
  async saveTransaction(transaction) {
1157
- await this.transactionStore?.put(transaction);
1192
+ await this.transactionStore.put(transaction);
1193
+ }
1194
+ /** Persist one burst of journal rows in a single strict durability group. */
1195
+ async saveTransactions(transactions) {
1196
+ if (transactions.length === 0)
1197
+ return;
1198
+ if (this.inMemory) {
1199
+ await Promise.all(transactions.map((transaction) => this.transactionStore.put(transaction)));
1200
+ return;
1201
+ }
1202
+ const db = this.workspaceDb;
1203
+ if (!db || this.isClosing) {
1204
+ throw new AbloConnectionError('Database not opened for mutation journal', {
1205
+ code: 'db_not_opened',
1206
+ });
1207
+ }
1208
+ await new Promise((resolve, reject) => {
1209
+ try {
1210
+ const tx = db.transaction(['__transactions'], 'readwrite', {
1211
+ durability: 'strict',
1212
+ });
1213
+ const store = tx.objectStore('__transactions');
1214
+ for (const transaction of transactions)
1215
+ store.put(transaction);
1216
+ tx.oncomplete = () => { resolve(); };
1217
+ tx.onabort = () => {
1218
+ reject(tx.error ?? new Error('Mutation journal transaction aborted'));
1219
+ };
1220
+ tx.onerror = () => {
1221
+ // onabort owns rejection.
1222
+ };
1223
+ }
1224
+ catch (error) {
1225
+ reject(error instanceof Error ? error : new Error(String(error)));
1226
+ }
1227
+ });
1158
1228
  }
1159
1229
  async removeTransaction(id) {
1160
- await this.transactionStore?.delete(id);
1230
+ await this.transactionStore.delete(id);
1161
1231
  }
1162
1232
  async getPersistedTransactions() {
1163
- const rows = (await this.transactionStore?.getAll()) ?? [];
1233
+ const rows = await this.transactionStore.getAll();
1164
1234
  // Storage layer returns the centralized `Record<string, unknown>`
1165
1235
  // shape from `ObjectStoreContract`. PersistedTransaction adds an
1166
1236
  // index signature so each row already structurally satisfies the
@@ -1168,14 +1238,138 @@ export class Database {
1168
1238
  // here, and it only accepts PersistedTransaction.
1169
1239
  return rows;
1170
1240
  }
1241
+ async getPersistedTransaction(id) {
1242
+ return (await this.transactionStore.get(id));
1243
+ }
1244
+ /**
1245
+ * Atomically seal one exact commit request and consume the staged mutation
1246
+ * records it replaces. The read, optional add, and deletes share one strict
1247
+ * IndexedDB transaction, so a crash can expose the staged records or the
1248
+ * sealed envelope, never a missing handoff. Returns the pre-existing record
1249
+ * when the envelope id was already sealed (retry/re-entrant call).
1250
+ */
1251
+ async sealTransactionRecord(record, consumedRecordIds) {
1252
+ const recordId = record.id;
1253
+ if (!recordId) {
1254
+ throw new AbloValidationError('A sealed transaction record must carry an id', {
1255
+ code: 'invalid_body',
1256
+ });
1257
+ }
1258
+ if (this.inMemory) {
1259
+ const store = this.transactionStore;
1260
+ const existing = (await store.get(recordId));
1261
+ if (existing && !isSameOutboxRecord(existing, record)) {
1262
+ throw new AbloValidationError('Commit outbox key already identifies a different request', {
1263
+ code: 'idempotency_conflict',
1264
+ });
1265
+ }
1266
+ if (!existing) {
1267
+ const sources = await Promise.all(consumedRecordIds.map((id) => store.get(id)));
1268
+ if (sources.some((source) => source === undefined)) {
1269
+ throw new AbloValidationError('Commit outbox source mutations were already claimed by another envelope', { code: 'idempotency_conflict' });
1270
+ }
1271
+ await store.add(record);
1272
+ }
1273
+ for (const id of consumedRecordIds) {
1274
+ if (id !== recordId)
1275
+ await store.delete(id);
1276
+ }
1277
+ return existing;
1278
+ }
1279
+ const db = this.workspaceDb;
1280
+ if (!db || this.isClosing) {
1281
+ throw new AbloConnectionError('Database not opened for commit outbox', {
1282
+ code: 'db_not_opened',
1283
+ });
1284
+ }
1285
+ return new Promise((resolve, reject) => {
1286
+ try {
1287
+ const tx = db.transaction(['__transactions'], 'readwrite', {
1288
+ durability: 'strict',
1289
+ });
1290
+ const store = tx.objectStore('__transactions');
1291
+ const getRequest = store.get(recordId);
1292
+ const sourceIds = [...new Set(consumedRecordIds)].filter((id) => id !== recordId);
1293
+ const sourceRequests = sourceIds.map((id) => store.get(id));
1294
+ const sourceExists = new Array(sourceRequests.length).fill(false);
1295
+ let existing;
1296
+ let collisionError;
1297
+ let envelopeRead = false;
1298
+ let sourcesRead = 0;
1299
+ let promotionStarted = false;
1300
+ const promote = () => {
1301
+ if (promotionStarted ||
1302
+ !envelopeRead ||
1303
+ sourcesRead !== sourceRequests.length)
1304
+ return;
1305
+ promotionStarted = true;
1306
+ if (existing && !isSameOutboxRecord(existing, record)) {
1307
+ collisionError = new AbloValidationError('Commit outbox key already identifies a different request', { code: 'idempotency_conflict' });
1308
+ tx.abort();
1309
+ return;
1310
+ }
1311
+ // A new envelope owns promotion only while every source row still
1312
+ // exists. This is the fleet/tab execution claim: a second tab that
1313
+ // restored the same journal entries under another key loses here and
1314
+ // cannot dispatch. An identical existing envelope is an idempotent
1315
+ // retry, so its already-consumed sources may be absent.
1316
+ if (!existing && sourceExists.some((exists) => !exists)) {
1317
+ collisionError = new AbloValidationError('Commit outbox source mutations were already claimed by another envelope', { code: 'idempotency_conflict' });
1318
+ tx.abort();
1319
+ return;
1320
+ }
1321
+ if (!existing)
1322
+ store.add(record);
1323
+ for (const id of sourceIds)
1324
+ store.delete(id);
1325
+ };
1326
+ getRequest.onsuccess = () => {
1327
+ existing = getRequest.result;
1328
+ envelopeRead = true;
1329
+ promote();
1330
+ };
1331
+ getRequest.onerror = () => {
1332
+ tx.abort();
1333
+ };
1334
+ sourceRequests.forEach((request, index) => {
1335
+ request.onsuccess = () => {
1336
+ sourceExists[index] = request.result !== undefined;
1337
+ sourcesRead += 1;
1338
+ promote();
1339
+ };
1340
+ request.onerror = () => {
1341
+ tx.abort();
1342
+ };
1343
+ });
1344
+ tx.oncomplete = () => { resolve(existing); };
1345
+ tx.onabort = () => {
1346
+ reject(collisionError ??
1347
+ tx.error ??
1348
+ getRequest.error ??
1349
+ new Error('Commit outbox transaction aborted'));
1350
+ };
1351
+ tx.onerror = () => {
1352
+ // onabort owns rejection so the promise settles exactly once.
1353
+ };
1354
+ }
1355
+ catch (error) {
1356
+ reject(error instanceof Error ? error : new Error(String(error)));
1357
+ }
1358
+ });
1359
+ }
1171
1360
  async cleanupOldTransactions(maxAge) {
1172
1361
  const store = this.transactionStore;
1173
- if (!store)
1174
- return 0;
1175
1362
  const rows = (await store.getAll());
1176
1363
  const cutoff = Date.now() - maxAge;
1177
1364
  let cleaned = 0;
1178
1365
  for (const tx of rows) {
1366
+ // Live write intent has no safe age-based expiry. In particular, server
1367
+ // idempotency retention may already have elapsed, so silently deleting or
1368
+ // blindly replaying an old envelope would both be unsafe. Restoration
1369
+ // owns quarantine/reconciliation for these records.
1370
+ if (tx.type === 'commit_envelope' || tx.type === 'pending_mutation') {
1371
+ continue;
1372
+ }
1179
1373
  if (typeof tx.timestamp === 'number' && tx.timestamp < cutoff) {
1180
1374
  await store.delete(tx.id);
1181
1375
  cleaned++;
@@ -1251,8 +1445,11 @@ export class Database {
1251
1445
  this.currentDbInfo = null;
1252
1446
  getContext().logger.debug('Database closed');
1253
1447
  }
1254
- async clear() {
1448
+ async clear(options = {}) {
1255
1449
  await this.storeManager.clearAllStores();
1450
+ if (options.includeWriteJournal) {
1451
+ await this.transactionStore.clear();
1452
+ }
1256
1453
  getContext().logger.info('All stores cleared');
1257
1454
  }
1258
1455
  }
package/dist/Model.d.ts CHANGED
@@ -364,8 +364,25 @@ export declare abstract class Model {
364
364
  * the schema's `T` describes. Computed relations (`referenceModel`/
365
365
  * `referenceCollection`) and ephemeral fields are skipped, matching `toJSON`'s
366
366
  * row projection; they're lazy/recursive and not part of the row's data.
367
+ *
368
+ * Schema-derived getters (`computed:` entries and `${field}Json` getters) ARE
369
+ * materialized, as non-enumerable own values. The schema's inferred row type
370
+ * includes them, so omitting them would make every snapshot read of a computed
371
+ * silently `undefined` — a type-level lie. They're evaluated here, inside the
372
+ * caller's tracked function, so the reaction subscribes to whatever fields the
373
+ * getter reads. Non-enumerable keeps write-path parity with model instances:
374
+ * an instance's getters sit on the prototype and never enter `{...model}`
375
+ * spreads or `JSON.stringify`, and materialized values must not either — a
376
+ * spread-into-update would otherwise send computed keys to the server.
367
377
  */
368
378
  toReactiveSnapshot<T = ModelData>(): T;
379
+ /**
380
+ * Names of schema-derived getters — `computed:` entries and `${field}Json`
381
+ * getters — that {@link toReactiveSnapshot} materializes onto snapshots.
382
+ * The dynamic model class built by `registerModelsFromSchema` overrides this;
383
+ * hand-written Model subclasses default to none.
384
+ */
385
+ getDerivedGetterNames(): readonly string[];
369
386
  /**
370
387
  * Get field changes for activity tracking
371
388
  */
package/dist/Model.js CHANGED
@@ -23,6 +23,8 @@ export class ValidationError extends Error {
23
23
  this.name = 'ValidationError';
24
24
  }
25
25
  }
26
+ /** Shared frozen default for {@link Model.getDerivedGetterNames}. */
27
+ const EMPTY_DERIVED_GETTERS = Object.freeze([]);
26
28
  /**
27
29
  * The abstract base class every domain model extends. It holds the model's id
28
30
  * and timestamps, tracks in-place property changes for change detection and
@@ -734,6 +736,16 @@ export class Model {
734
736
  * the schema's `T` describes. Computed relations (`referenceModel`/
735
737
  * `referenceCollection`) and ephemeral fields are skipped, matching `toJSON`'s
736
738
  * row projection; they're lazy/recursive and not part of the row's data.
739
+ *
740
+ * Schema-derived getters (`computed:` entries and `${field}Json` getters) ARE
741
+ * materialized, as non-enumerable own values. The schema's inferred row type
742
+ * includes them, so omitting them would make every snapshot read of a computed
743
+ * silently `undefined` — a type-level lie. They're evaluated here, inside the
744
+ * caller's tracked function, so the reaction subscribes to whatever fields the
745
+ * getter reads. Non-enumerable keeps write-path parity with model instances:
746
+ * an instance's getters sit on the prototype and never enter `{...model}`
747
+ * spreads or `JSON.stringify`, and materialized values must not either — a
748
+ * spread-into-update would otherwise send computed keys to the server.
737
749
  */
738
750
  toReactiveSnapshot() {
739
751
  const snapshot = {
@@ -744,8 +756,8 @@ export class Model {
744
756
  if (this.archivedAt !== undefined)
745
757
  snapshot.archivedAt = this.archivedAt;
746
758
  const properties = getActiveRegistry().getProperties(this.getModelName());
759
+ const self = this;
747
760
  if (properties) {
748
- const self = this;
749
761
  for (const [propName, metadata] of properties) {
750
762
  if (metadata.type === 'ephemeralProperty' ||
751
763
  metadata.type === 'referenceModel' ||
@@ -757,8 +769,27 @@ export class Model {
757
769
  snapshot[propName] = self[propName];
758
770
  }
759
771
  }
772
+ for (const name of this.getDerivedGetterNames()) {
773
+ Object.defineProperty(snapshot, name, {
774
+ // Evaluated on the model instance so `${field}Json` caches stay on it
775
+ // and the enclosing reaction tracks the fields the getter reads.
776
+ value: self[name],
777
+ enumerable: false,
778
+ configurable: true,
779
+ writable: false,
780
+ });
781
+ }
760
782
  return snapshot;
761
783
  }
784
+ /**
785
+ * Names of schema-derived getters — `computed:` entries and `${field}Json`
786
+ * getters — that {@link toReactiveSnapshot} materializes onto snapshots.
787
+ * The dynamic model class built by `registerModelsFromSchema` overrides this;
788
+ * hand-written Model subclasses default to none.
789
+ */
790
+ getDerivedGetterNames() {
791
+ return EMPTY_DERIVED_GETTERS;
792
+ }
762
793
  /**
763
794
  * Get field changes for activity tracking
764
795
  */
@@ -15,6 +15,7 @@ import { type UnconfirmedWritesMetrics } from './transactions/UnconfirmedWrites.
15
15
  import type { Database } from './Database.js';
16
16
  import type { WriteOptions } from './interfaces/index.js';
17
17
  import { SyncPosition } from './sync/syncPosition.js';
18
+ import { type CommitOutboxStore } from './transactions/commitOutboxStore.js';
18
19
  interface SyncObserver {
19
20
  onSync?: (event: SyncEvent) => void;
20
21
  }
@@ -44,11 +45,14 @@ export declare class SyncClient extends EventEmitter {
44
45
  private database;
45
46
  private get mutationExecutor();
46
47
  private networkMonitor;
47
- private transactionQueue;
48
48
  private observers;
49
49
  private userId;
50
50
  private organizationId;
51
51
  private pendingMutations;
52
+ private readonly stagedMutationIds;
53
+ private pendingJournalBatch;
54
+ private journalFlushScheduled;
55
+ private readonly commitOutboxNamespace;
52
56
  /**
53
57
  * Tracks the ids of transactions the client has applied optimistically but
54
58
  * the server has not yet confirmed. When a delta arrives, the receive path
@@ -72,7 +76,7 @@ export declare class SyncClient extends EventEmitter {
72
76
  * responses, and snapshots and claims read `readFloor`.
73
77
  */
74
78
  readonly position: SyncPosition;
75
- constructor(objectPool: InstanceCache, database: Database);
79
+ constructor(objectPool: InstanceCache, database: Database, commitOutbox?: CommitOutboxStore, commitOutboxNamespace?: string);
76
80
  /**
77
81
  * Setup network monitoring handlers
78
82
  */
@@ -250,12 +254,12 @@ export declare class SyncClient extends EventEmitter {
250
254
  * avoid re-reading a model after pool operations that might clear them.
251
255
  */
252
256
  private queueMutation;
257
+ private scheduleJournalFlush;
258
+ private flushPendingMutationJournal;
253
259
  private syncScheduled;
254
260
  private scheduleSync;
255
- /**
256
- * Persist mutation queue to IndexedDB
257
- */
258
- private persistMutationQueue;
261
+ private pendingMutationRecord;
262
+ private persistPendingMutation;
259
263
  /**
260
264
  * Restore the mutation queue from IndexedDB.
261
265
  *