@abloatai/ablo 0.27.0 → 0.29.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 (126) hide show
  1. package/CHANGELOG.md +59 -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 +228 -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 +390 -75
  10. package/dist/adapters/inMemoryStorage.d.ts +1 -0
  11. package/dist/adapters/inMemoryStorage.js +29 -13
  12. package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
  13. package/dist/auth/schemas.d.ts +6 -0
  14. package/dist/auth/schemas.js +7 -0
  15. package/dist/cli.cjs +821 -402
  16. package/dist/client/Ablo.d.ts +53 -24
  17. package/dist/client/Ablo.js +12 -199
  18. package/dist/client/claimHeartbeatLoop.d.ts +1 -1
  19. package/dist/client/claimHeartbeatLoop.js +1 -1
  20. package/dist/client/createInternalComponents.d.ts +4 -0
  21. package/dist/client/createInternalComponents.js +3 -1
  22. package/dist/client/durableWrites.d.ts +21 -0
  23. package/dist/client/durableWrites.js +46 -0
  24. package/dist/client/httpClient.d.ts +21 -45
  25. package/dist/client/httpClient.js +104 -26
  26. package/dist/client/httpTransport.d.ts +8 -0
  27. package/dist/client/{ApiClient.js → httpTransport.js} +361 -308
  28. package/dist/client/modelRegistration.js +11 -0
  29. package/dist/client/options.d.ts +54 -7
  30. package/dist/client/options.js +3 -2
  31. package/dist/client/resourceTypes.d.ts +9 -85
  32. package/dist/client/resourceTypes.js +1 -1
  33. package/dist/client/sessionMint.d.ts +5 -6
  34. package/dist/client/sessionMint.js +4 -5
  35. package/dist/client/validateAbloOptions.js +3 -3
  36. package/dist/client/wsMutationExecutor.d.ts +3 -2
  37. package/dist/client/wsMutationExecutor.js +3 -2
  38. package/dist/coordination/schema.d.ts +30 -0
  39. package/dist/coordination/schema.js +14 -0
  40. package/dist/core/StoreManager.d.ts +2 -0
  41. package/dist/core/StoreManager.js +12 -0
  42. package/dist/core/index.d.ts +1 -1
  43. package/dist/errorCodes.js +6 -2
  44. package/dist/errors.d.ts +4 -40
  45. package/dist/errors.js +5 -5
  46. package/dist/index.d.ts +20 -8
  47. package/dist/index.js +9 -5
  48. package/dist/interfaces/index.d.ts +5 -2
  49. package/dist/mutators/UndoManager.d.ts +2 -0
  50. package/dist/mutators/UndoManager.js +32 -0
  51. package/dist/mutators/defineMutators.d.ts +18 -0
  52. package/dist/mutators/defineMutators.js +4 -8
  53. package/dist/react/index.d.ts +1 -1
  54. package/dist/react/useAblo.d.ts +6 -4
  55. package/dist/react/useAblo.js +25 -3
  56. package/dist/schema/index.d.ts +2 -2
  57. package/dist/schema/index.js +1 -1
  58. package/dist/schema/schema.d.ts +31 -1
  59. package/dist/schema/select.d.ts +15 -0
  60. package/dist/schema/select.js +27 -3
  61. package/dist/source/connector.d.ts +3 -3
  62. package/dist/source/factory.d.ts +4 -6
  63. package/dist/source/factory.js +4 -7
  64. package/dist/source/index.d.ts +4 -4
  65. package/dist/source/index.js +2 -2
  66. package/dist/source/signing.d.ts +0 -3
  67. package/dist/source/types.d.ts +0 -26
  68. package/dist/stores/ObjectStore.d.ts +14 -1
  69. package/dist/stores/ObjectStore.js +33 -10
  70. package/dist/stores/ObjectStoreContract.d.ts +2 -0
  71. package/dist/surface.d.ts +1 -1
  72. package/dist/surface.js +3 -0
  73. package/dist/sync/BootstrapFetcher.d.ts +10 -0
  74. package/dist/sync/BootstrapFetcher.js +27 -4
  75. package/dist/sync/SyncWebSocket.d.ts +2 -1
  76. package/dist/sync/createClaimStream.d.ts +11 -2
  77. package/dist/sync/createClaimStream.js +4 -3
  78. package/dist/sync/persistedPrefix.d.ts +12 -0
  79. package/dist/sync/persistedPrefix.js +22 -0
  80. package/dist/testing/index.d.ts +2 -0
  81. package/dist/testing/index.js +1 -0
  82. package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
  83. package/dist/testing/mocks/FakeDatabase.js +10 -0
  84. package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
  85. package/dist/transactions/TransactionQueue.d.ts +66 -8
  86. package/dist/transactions/TransactionQueue.js +607 -89
  87. package/dist/transactions/commitEnvelope.d.ts +132 -0
  88. package/dist/transactions/commitEnvelope.js +139 -0
  89. package/dist/transactions/commitOutboxStore.d.ts +32 -0
  90. package/dist/transactions/commitOutboxStore.js +26 -0
  91. package/dist/transactions/commitPayload.d.ts +15 -0
  92. package/dist/transactions/commitPayload.js +6 -0
  93. package/dist/transactions/durableWriteStore.d.ts +123 -0
  94. package/dist/transactions/durableWriteStore.js +30 -0
  95. package/dist/transactions/httpCommitEnvelope.d.ts +44 -0
  96. package/dist/transactions/httpCommitEnvelope.js +181 -0
  97. package/dist/transactions/idempotencyKey.d.ts +10 -0
  98. package/dist/transactions/idempotencyKey.js +9 -0
  99. package/dist/transactions/replayValidation.d.ts +83 -0
  100. package/dist/transactions/replayValidation.js +46 -1
  101. package/dist/types/global.d.ts +10 -29
  102. package/dist/types/global.js +4 -7
  103. package/dist/types/streams.d.ts +2 -28
  104. package/dist/wire/bootstrapReason.d.ts +9 -0
  105. package/dist/wire/bootstrapReason.js +8 -0
  106. package/dist/wire/frames.d.ts +6 -21
  107. package/dist/wire/frames.js +4 -4
  108. package/dist/wire/index.d.ts +6 -3
  109. package/dist/wire/index.js +3 -2
  110. package/dist/wire/protocolVersion.d.ts +30 -17
  111. package/dist/wire/protocolVersion.js +34 -18
  112. package/docs/agents.md +8 -3
  113. package/docs/api.md +16 -14
  114. package/docs/client-behavior.md +6 -3
  115. package/docs/coordination.md +4 -5
  116. package/docs/data-sources.md +5 -8
  117. package/docs/guarantees.md +21 -0
  118. package/docs/integration-guide.md +1 -0
  119. package/docs/mcp.md +1 -1
  120. package/docs/migration.md +21 -1
  121. package/docs/quickstart.md +11 -0
  122. package/docs/react.md +0 -46
  123. package/examples/README.md +6 -5
  124. package/llms.txt +15 -15
  125. package/package.json +3 -3
  126. package/dist/client/ApiClient.d.ts +0 -177
@@ -4,6 +4,15 @@
4
4
  */
5
5
  import type { ModelMetadata } from '../types/index.js';
6
6
  import type { ObjectStoreContract } from './ObjectStoreContract.js';
7
+ /**
8
+ * IDB transaction options type (TypeScript's lib.dom.d.ts may be outdated)
9
+ * durability: 'relaxed' provides ~16x write performance improvement
10
+ * by not requiring fsync on each transaction commit.
11
+ * Safe for optimistic sync engines that can recover from server state.
12
+ */
13
+ interface IDBTransactionOptionsWithDurability {
14
+ durability?: 'default' | 'relaxed' | 'strict';
15
+ }
7
16
  /**
8
17
  * An IndexedDB-backed store holding the records of a single model.
9
18
  *
@@ -21,8 +30,11 @@ export declare class ObjectStore implements ObjectStoreContract {
21
30
  protected modelName: string;
22
31
  protected storeName: string;
23
32
  protected metadata: ModelMetadata;
33
+ private readonly writeDurability;
24
34
  private isClosing;
25
- constructor(db: IDBDatabase, modelName: string, storeName: string, metadata: ModelMetadata);
35
+ constructor(db: IDBDatabase, modelName: string, storeName: string, metadata: ModelMetadata, writeDurability?: NonNullable<IDBTransactionOptionsWithDurability['durability']>);
36
+ /** Insert without replacing an existing key (used by the commit outbox). */
37
+ add(data: Record<string, unknown>): Promise<void>;
26
38
  /**
27
39
  * Mark this store as closing to prevent new operations
28
40
  */
@@ -100,3 +112,4 @@ export declare class ObjectStore implements ObjectStoreContract {
100
112
  */
101
113
  performMaintenance(): Promise<void>;
102
114
  }
115
+ export {};
@@ -19,12 +19,35 @@ export class ObjectStore {
19
19
  modelName;
20
20
  storeName;
21
21
  metadata;
22
+ writeDurability;
22
23
  isClosing = false;
23
- constructor(db, modelName, storeName, metadata) {
24
+ constructor(db, modelName, storeName, metadata, writeDurability = 'relaxed') {
24
25
  this.db = db;
25
26
  this.modelName = modelName;
26
27
  this.storeName = storeName;
27
28
  this.metadata = metadata;
29
+ this.writeDurability = writeDurability;
30
+ }
31
+ /** Insert without replacing an existing key (used by the commit outbox). */
32
+ async add(data) {
33
+ if (!this.checkDatabaseAvailable()) {
34
+ return Promise.reject(new Error('IndexedDB not available (closing or invalid)'));
35
+ }
36
+ return new Promise((resolve, reject) => {
37
+ try {
38
+ const tx = this.db.transaction([this.storeName], 'readwrite', {
39
+ durability: this.writeDurability,
40
+ });
41
+ const store = tx.objectStore(this.storeName);
42
+ const request = store.add(data);
43
+ tx.oncomplete = () => { resolve(); };
44
+ tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
45
+ request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
46
+ }
47
+ catch (error) {
48
+ reject(error instanceof Error ? error : new Error(String(error)));
49
+ }
50
+ });
28
51
  }
29
52
  /**
30
53
  * Mark this store as closing to prevent new operations
@@ -65,13 +88,13 @@ export class ObjectStore {
65
88
  try {
66
89
  // Use relaxed durability for ~16x write performance (safe with optimistic sync)
67
90
  const tx = this.db.transaction([this.storeName], 'readwrite', {
68
- durability: 'relaxed',
91
+ durability: this.writeDurability,
69
92
  });
70
93
  const store = tx.objectStore(this.storeName);
71
94
  const request = store.put(data);
72
95
  tx.oncomplete = () => { resolve(); };
73
- tx.onerror = () => { reject(tx.error || new Error('IndexedDB transaction error')); };
74
- request.onerror = () => { reject(request.error || new Error('IndexedDB request error')); };
96
+ tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
97
+ request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
75
98
  }
76
99
  catch (error) {
77
100
  // Propagate failure so callers do not continue with inconsistent state
@@ -169,13 +192,13 @@ export class ObjectStore {
169
192
  try {
170
193
  // Use relaxed durability for ~16x write performance (safe with optimistic sync)
171
194
  const tx = this.db.transaction([this.storeName], 'readwrite', {
172
- durability: 'relaxed',
195
+ durability: this.writeDurability,
173
196
  });
174
197
  const store = tx.objectStore(this.storeName);
175
198
  const request = store.delete(id);
176
199
  tx.oncomplete = () => { resolve(); };
177
- tx.onerror = () => { reject(tx.error || new Error('IndexedDB transaction error')); };
178
- request.onerror = () => { reject(request.error || new Error('IndexedDB request error')); };
200
+ tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
201
+ request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
179
202
  }
180
203
  catch (error) {
181
204
  reject(error instanceof Error ? error : new Error(String(error)));
@@ -205,13 +228,13 @@ export class ObjectStore {
205
228
  try {
206
229
  // Use relaxed durability for ~16x write performance (safe with optimistic sync)
207
230
  const tx = this.db.transaction([this.storeName], 'readwrite', {
208
- durability: 'relaxed',
231
+ durability: this.writeDurability,
209
232
  });
210
233
  const store = tx.objectStore(this.storeName);
211
234
  const request = store.clear();
212
235
  tx.oncomplete = () => { resolve(); };
213
- tx.onerror = () => { reject(tx.error || new Error('IndexedDB transaction error')); };
214
- request.onerror = () => { reject(request.error || new Error('IndexedDB request error')); };
236
+ tx.onerror = () => { reject(tx.error ?? new Error('IndexedDB transaction error')); };
237
+ request.onerror = () => { reject(request.error ?? new Error('IndexedDB request error')); };
215
238
  }
216
239
  catch (error) {
217
240
  reject(error instanceof Error ? error : new Error(String(error)));
@@ -15,6 +15,8 @@
15
15
  * a silent failure in a caller.
16
16
  */
17
17
  export interface ObjectStoreContract {
18
+ /** Insert a record only when its key is absent. */
19
+ add(data: Record<string, unknown>): Promise<void>;
18
20
  /** Insert or update a record. The record must carry an `id` field. */
19
21
  put(data: Record<string, unknown>): Promise<void>;
20
22
  /** Look up a record by id. */
package/dist/surface.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orde
30
30
  * The keys of the client constructor options, {@link AbloOptions}. Only
31
31
  * `schema` is required; every other key is optional.
32
32
  */
33
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "databaseUrl", "persistence", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
33
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "authEndpoint", "databaseUrl", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
34
34
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
35
35
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
36
36
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/dist/surface.js CHANGED
@@ -58,6 +58,9 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
58
58
  'authEndpoint',
59
59
  'databaseUrl',
60
60
  'persistence',
61
+ 'durableWrites',
62
+ 'commitOutbox',
63
+ 'commitOutboxScope',
61
64
  'transport',
62
65
  'debug',
63
66
  'logLevel',
@@ -88,6 +88,16 @@ export declare class BootstrapFetcher {
88
88
  * means the app's schema and the deployed schema have diverged — reads/writes
89
89
  * relying on undeployed changes will later fail with an opaque DB constraint
90
90
  * error. Warn once, actionably; never throws or blocks the bootstrap.
91
+ *
92
+ * The message names the SERVER it connected to, and spans all three real
93
+ * causes rather than assuming "you forgot to push". Drift most often means the
94
+ * schema was pushed to a different server, project, or environment than this
95
+ * client points at (a bare `ablo push` targets the hosted default; a local app
96
+ * usually reads a local server) — so the first, load-bearing pointer is `ablo
97
+ * status`, which names the exact org/project/environment the key resolves to
98
+ * and the deployed hash, turning "which of these is it?" into one glance. The
99
+ * older "Run `ablo push`" copy sent everyone down one path and confused the
100
+ * common wrong-target and version-skew cases.
91
101
  */
92
102
  private warnOnSchemaDrift;
93
103
  constructor(options: BootstrapOptions);
@@ -25,6 +25,16 @@ export class BootstrapFetcher {
25
25
  * means the app's schema and the deployed schema have diverged — reads/writes
26
26
  * relying on undeployed changes will later fail with an opaque DB constraint
27
27
  * error. Warn once, actionably; never throws or blocks the bootstrap.
28
+ *
29
+ * The message names the SERVER it connected to, and spans all three real
30
+ * causes rather than assuming "you forgot to push". Drift most often means the
31
+ * schema was pushed to a different server, project, or environment than this
32
+ * client points at (a bare `ablo push` targets the hosted default; a local app
33
+ * usually reads a local server) — so the first, load-bearing pointer is `ablo
34
+ * status`, which names the exact org/project/environment the key resolves to
35
+ * and the deployed hash, turning "which of these is it?" into one glance. The
36
+ * older "Run `ablo push`" copy sent everyone down one path and confused the
37
+ * common wrong-target and version-skew cases.
28
38
  */
29
39
  warnOnSchemaDrift(serverHash) {
30
40
  if (this.schemaDriftWarned || !serverHash)
@@ -33,14 +43,27 @@ export class BootstrapFetcher {
33
43
  if (!clientHash || clientHash === serverHash)
34
44
  return;
35
45
  this.schemaDriftWarned = true;
46
+ const org = this.options.organizationId;
47
+ const where = org ? `${this.baseUrl} (org ${org})` : this.baseUrl;
36
48
  // Self-brand the message ("Ablo:") rather than rely on the default logger's
37
49
  // `[Ablo]` namespace — consumers wiring their own logger (pino, etc.) lose
38
50
  // that prefix, and a drift warning that reads like the app's own log is
39
51
  // worse than none. The brand tells them at a glance who is talking.
40
- getContext().logger.warn(`Ablo: Schema drift detected — this app was built against schema ${clientHash}, ` +
41
- `but the deployed schema is ${serverHash}. Operations that depend on schema ` +
42
- `changes not yet deployed will fail later with an opaque database error. Run ` +
43
- `\`ablo push\` to deploy your schema (or update this app to match the deployed one).`, { clientSchemaHash: clientHash, serverSchemaHash: serverHash });
52
+ getContext().logger.warn(`Ablo: Schema drift — the schema this client was built with (${clientHash}) is not the ` +
53
+ `one active on the server it connected to (${serverHash} at ${where}). Until they match, ` +
54
+ `operations that depend on the difference will fail later with an opaque database error. ` +
55
+ `This is usually one of three things. The schema may have been pushed to a different ` +
56
+ `server, project, or environment than this client points at — run \`ablo status\` to see ` +
57
+ `the exact org, project, and environment your key resolves to, alongside the deployed ` +
58
+ `hash, and confirm they match here. Your local schema may simply not be pushed to this ` +
59
+ `server yet — run \`ablo push\` against it. Or this client and the server may have been ` +
60
+ `built with different Ablo versions, which can hash an identical schema differently — ` +
61
+ `align the versions. This check is advisory and never blocks the connection.`, {
62
+ clientSchemaHash: clientHash,
63
+ serverSchemaHash: serverHash,
64
+ serverUrl: this.baseUrl,
65
+ ...(org ? { organizationId: org } : {}),
66
+ });
44
67
  }
45
68
  constructor(options) {
46
69
  // Defaults are spread first; the explicit `baseUrl` then takes precedence,
@@ -9,6 +9,7 @@
9
9
  import { EventEmitter } from 'events';
10
10
  import type { MutationOperation } from '../interfaces/index.js';
11
11
  import { type ClientSyncDelta } from '../wire/delta.js';
12
+ import type { BootstrapReason } from '../wire/bootstrapReason.js';
12
13
  import type { ClaimError, ClaimRejection, StaleNotification, ReadDependency } from '../coordination/schema.js';
13
14
  import { type CommitAck } from './commitFrames.js';
14
15
  export type { CommitAck } from './commitFrames.js';
@@ -100,7 +101,7 @@ export interface SyncWebSocketOptions {
100
101
  */
101
102
  export interface BootstrapHint {
102
103
  tables?: string[];
103
- reason?: 'too_far_behind' | 'too_many_deltas' | 'missing_entities';
104
+ reason?: BootstrapReason;
104
105
  staleTables?: string[];
105
106
  totalDeltaCount?: number;
106
107
  }
@@ -23,6 +23,15 @@
23
23
  */
24
24
  import type { SyncWebSocket } from './SyncWebSocket.js';
25
25
  import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '../types/streams.js';
26
+ /**
27
+ * The wire capability the claim stream actually uses: subscribe to typed
28
+ * inbound frames, check liveness, and send outbound frames. `SyncWebSocket`
29
+ * satisfies it, so production wiring is unchanged — but depending on the port
30
+ * rather than the whole socket class lets a test drive it with a plain object,
31
+ * no cast. `Pick` carries the exact (generic, typed-payload) `subscribe`
32
+ * signature, so every handler stays fully typed.
33
+ */
34
+ export type ClaimTransport = Pick<SyncWebSocket, 'subscribe' | 'isConnected' | 'send'>;
26
35
  export interface ClaimStreamConfig {
27
36
  /** Identity used to filter our own active claims out of `others`. */
28
37
  participantId: string;
@@ -36,7 +45,7 @@ export interface AttachableClaimStream extends ClaimStream {
36
45
  * `ablo.<model>.claim({ id })`, which is built on this.
37
46
  */
38
47
  claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
39
- attach(transport: SyncWebSocket): void;
48
+ attach(transport: ClaimTransport): void;
40
49
  dispose(): void;
41
50
  }
42
- export declare function createClaimStream(config: ClaimStreamConfig, transport?: SyncWebSocket | null): AttachableClaimStream;
51
+ export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null): AttachableClaimStream;
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { asyncIteratorFrom } from '../utils/asyncIterator.js';
25
25
  import { toMs } from '../utils/duration.js';
26
- import { claimHeartbeatAckPayloadSchema, descriptionFromMeta, participantKindFromWire, } from '../coordination/schema.js';
26
+ import { claimHeartbeatAckPayloadSchema, claimLostSchema, descriptionFromMeta, participantKindFromWire, } from '../coordination/schema.js';
27
27
  import { AbloClaimedError, AbloConnectionError } from '../errors.js';
28
28
  import { resolveHeartbeatOptions } from '../client/claimHeartbeatLoop.js';
29
29
  import { getContext } from '../context.js';
@@ -173,9 +173,10 @@ export function createClaimStream(config, transport = null) {
173
173
  // (preempted or expired). Distinct from a rejection, which is a claim
174
174
  // the server refused.
175
175
  unsubs.push(t.subscribe('claim_lost', (payload) => {
176
- const lost = payload;
177
- if (!lost.claimId)
176
+ const parsed = claimLostSchema.safeParse(payload);
177
+ if (!parsed.success)
178
178
  return;
179
+ const lost = parsed.data;
179
180
  if (ownClaims.has(lost.claimId)) {
180
181
  const c = ownClaims.get(lost.claimId);
181
182
  getContext().logger.info(`claim: lost ${c ? claimLabel(c.entityType, c.entityId, c.field) : lost.claimId} (preempted or expired)`, { claimId: lost.claimId });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Returns the highest sync id in the durable prefix of an ordered delta frame.
3
+ *
4
+ * A frame may span several IndexedDB stores, each committed in its own
5
+ * transaction. Those transactions can succeed and fail independently. The
6
+ * resume cursor may advance only through the first failed input: taking the
7
+ * maximum id from any successful store would skip an earlier failed delta on
8
+ * every later catch-up.
9
+ */
10
+ export declare function highestPersistedPrefixSyncId(deltas: readonly {
11
+ readonly syncId?: number | string;
12
+ }[], persistedIndexes: ReadonlySet<number>): number;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Returns the highest sync id in the durable prefix of an ordered delta frame.
3
+ *
4
+ * A frame may span several IndexedDB stores, each committed in its own
5
+ * transaction. Those transactions can succeed and fail independently. The
6
+ * resume cursor may advance only through the first failed input: taking the
7
+ * maximum id from any successful store would skip an earlier failed delta on
8
+ * every later catch-up.
9
+ */
10
+ export function highestPersistedPrefixSyncId(deltas, persistedIndexes) {
11
+ let through = 0;
12
+ for (let index = 0; index < deltas.length; index += 1) {
13
+ if (!persistedIndexes.has(index))
14
+ break;
15
+ const raw = deltas[index]?.syncId;
16
+ const syncId = typeof raw === 'string' ? Number(raw) : raw;
17
+ if (typeof syncId === 'number' && Number.isFinite(syncId) && syncId > through) {
18
+ through = syncId;
19
+ }
20
+ }
21
+ return through;
22
+ }
@@ -9,6 +9,8 @@ export type { CapturedMutation, MockMutationExecutorOptions } from './mocks/Mock
9
9
  export { MockNetworkMonitor } from './mocks/MockNetworkMonitor.js';
10
10
  export { MockWebSocket } from './mocks/MockWebSocket.js';
11
11
  export type { MockDelta, MockBootstrapHint } from './mocks/MockWebSocket.js';
12
+ export { fakeDatabase } from './mocks/FakeDatabase.js';
13
+ export type { FakeDatabaseOverrides } from './mocks/FakeDatabase.js';
12
14
  export { createTestContext, } from './mocks/MockSyncContext.js';
13
15
  export type { TestContextOptions, TestContextResult } from './mocks/MockSyncContext.js';
14
16
  export { TestProject, TestTask, TestComment, TestSlideDeck, TestSlide, TestSlideLayer, TEST_MODEL_PRIORITIES, registerTestModels, createTestConfig, resetFixtureCounter, createProjectFixture, createTaskFixture, createCommentFixture, createSlideDeckFixture, createSlideFixture, createSlideLayerFixture, } from './fixtures/models.js';
@@ -10,6 +10,7 @@
10
10
  export { MockMutationExecutor } from './mocks/MockMutationExecutor.js';
11
11
  export { MockNetworkMonitor } from './mocks/MockNetworkMonitor.js';
12
12
  export { MockWebSocket } from './mocks/MockWebSocket.js';
13
+ export { fakeDatabase } from './mocks/FakeDatabase.js';
13
14
  export { createTestContext, } from './mocks/MockSyncContext.js';
14
15
  // ─────────────────────────────────────────────
15
16
  // Fixtures: Models
@@ -0,0 +1,18 @@
1
+ import type { Database } from '../../Database.js';
2
+ /**
3
+ * Overrides for {@link fakeDatabase}. Everything is typechecked against
4
+ * Database's public surface except `getPersistedTransactions`, which widens to
5
+ * `unknown[]`: persisted rows are untrusted input (the DurableWriteStore port
6
+ * documents this), and restore-path tests feed deliberately corrupt rows.
7
+ */
8
+ export type FakeDatabaseOverrides = Omit<Partial<Database>, 'getPersistedTransactions'> & {
9
+ getPersistedTransactions?: () => Promise<unknown[]>;
10
+ };
11
+ /**
12
+ * A Database double that implements ONLY the given members — no real
13
+ * prototype behind it, so a code path reaching for anything else fails
14
+ * loudly instead of silently running real IndexedDB logic. The bare `as`
15
+ * is a single narrowing assert (Database is assignable to the overrides
16
+ * type), so every override stays typechecked against the real surface.
17
+ */
18
+ export declare function fakeDatabase(overrides: FakeDatabaseOverrides): Database;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A Database double that implements ONLY the given members — no real
3
+ * prototype behind it, so a code path reaching for anything else fails
4
+ * loudly instead of silently running real IndexedDB logic. The bare `as`
5
+ * is a single narrowing assert (Database is assignable to the overrides
6
+ * type), so every override stays typechecked against the real surface.
7
+ */
8
+ export function fakeDatabase(overrides) {
9
+ return overrides;
10
+ }
@@ -4,6 +4,7 @@
4
4
  * can drive connection changes, deltas, and bootstrap hints by hand.
5
5
  */
6
6
  import type { SyncActionType } from '../../types/index.js';
7
+ import type { BootstrapReason } from '../../wire/bootstrapReason.js';
7
8
  /** The shape of a single delta — one change the server pushes to the client. */
8
9
  export interface MockDelta {
9
10
  id: number;
@@ -17,7 +18,7 @@ export interface MockDelta {
17
18
  * rebuild its data from scratch rather than catch up one delta at a time.
18
19
  */
19
20
  export interface MockBootstrapHint {
20
- reason: 'too_far_behind' | 'too_many_deltas' | 'missing_entities';
21
+ reason: BootstrapReason;
21
22
  tables?: string[];
22
23
  staleTables?: string[];
23
24
  }
@@ -18,6 +18,8 @@ import { SyncPosition } from '../sync/syncPosition.js';
18
18
  import type { WriteOptions } from '../interfaces/index.js';
19
19
  import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
20
20
  import { type MutationInput, type Transaction, type UserContext } from './commitPayload.js';
21
+ import { type DurableCommitEnvelope, type DurableCommitOperation, type CommitOutboxScope } from './commitEnvelope.js';
22
+ import type { DurableWriteStore } from './durableWriteStore.js';
21
23
  export type { Transaction, UserContext } from './commitPayload.js';
22
24
  /**
23
25
  * A pre-built, multi-operation commit submitted through
@@ -32,7 +34,7 @@ interface CommitTransaction {
32
34
  id: string;
33
35
  kind: 'commit';
34
36
  operations: {
35
- type: string;
37
+ type: DurableCommitOperation['type'];
36
38
  model: string;
37
39
  id: string;
38
40
  input?: Record<string, unknown>;
@@ -48,6 +50,12 @@ interface CommitTransaction {
48
50
  attempts: number;
49
51
  lastSyncId?: number;
50
52
  error?: Error;
53
+ sealedAt: number;
54
+ sequence: number;
55
+ sealPromise?: Promise<void>;
56
+ durableEnvelope?: DurableCommitEnvelope;
57
+ /** Journal entries atomically consumed when this request was sealed. */
58
+ sourceMutationIds?: string[];
51
59
  }
52
60
  interface ConflictResolution {
53
61
  strategy: 'last-write-wins' | 'merge' | 'reject' | 'custom';
@@ -89,6 +97,7 @@ interface TransactionQueueConfig {
89
97
  commitOfflineGraceMs: number;
90
98
  }
91
99
  export declare class TransactionQueue extends EventEmitter {
100
+ private static readonly DURABLE_REPLAY_WINDOW_MS;
92
101
  private store;
93
102
  private lastPermanentErrorSig?;
94
103
  private _mutationExecutor;
@@ -105,6 +114,15 @@ export declare class TransactionQueue extends EventEmitter {
105
114
  private commitLane;
106
115
  private commitStore;
107
116
  private commitProcessing;
117
+ private lastCommitSequence;
118
+ private durableReplayBlock;
119
+ /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
120
+ private commitOutbox;
121
+ private commitOutboxScope;
122
+ private nextCommitSequence;
123
+ private emitCommitLifecycle;
124
+ private assertDurableReplayOpen;
125
+ private assertEnvelopeInsideReplayWindow;
108
126
  private computePriorityScore;
109
127
  private ensureDerivedFields;
110
128
  private entityKey;
@@ -134,6 +152,38 @@ export declare class TransactionQueue extends EventEmitter {
134
152
  private get lastSeenSyncId();
135
153
  private noteAck;
136
154
  private batchIndex;
155
+ /** Mints the request identity once; retry paths only read the stored value. */
156
+ private generateCommitIdempotencyKey;
157
+ /**
158
+ * Binds an ordered transaction batch to one wire-level idempotency key.
159
+ * Existing envelopes are validated and restored to their original order;
160
+ * they are never extended with newly queued work.
161
+ */
162
+ private ensureCommitEnvelope;
163
+ /** Bind the strict local outbox used before any mutation reaches the wire. */
164
+ setCommitOutbox(outbox: DurableWriteStore): void;
165
+ setCommitOutboxScope(scope: CommitOutboxScope): void;
166
+ private sourceMutationIdsFor;
167
+ /**
168
+ * Atomically replaces staged mutation journal rows with one exact request.
169
+ * The returned operations are the JSON-normalized values that must be sent;
170
+ * callers never send a separately reconstructed payload after sealing.
171
+ */
172
+ private sealDurableCommit;
173
+ /** Best-effort cleanup after a definitive result; replay is safe if it fails. */
174
+ private removeDurableCommit;
175
+ /**
176
+ * Takes either one complete retry envelope or a fresh batch. A retry waits
177
+ * until every original member has re-entered the queue, preventing an
178
+ * ambiguous A+B commit from being replayed later as A and B separately.
179
+ */
180
+ private takeNextExecutionBatch;
181
+ /**
182
+ * Selects one reconnect batch without changing the request identity of work
183
+ * that was already attempted. Explicit caller keys remain one-call batches;
184
+ * an existing envelope is replayed only with all of its original members.
185
+ */
186
+ private takeOfflineFlushBatch;
137
187
  /**
138
188
  * Resolvers for per-transaction `confirmation` promises. Populated in
139
189
  * `attachConfirmation` at staging time, consumed by the constructor-time
@@ -216,8 +266,8 @@ export declare class TransactionQueue extends EventEmitter {
216
266
  private commitCreatedTransactions;
217
267
  /**
218
268
  * Flushes every pending transaction in one commit, the fast path taken on
219
- * reconnect. If the batch fails, each transaction falls back to normal,
220
- * one-by-one processing.
269
+ * reconnect. If transport fails, the transactions retain this exact commit
270
+ * envelope when they fall back to normal queue processing.
221
271
  */
222
272
  flushOfflineQueue(): Promise<void>;
223
273
  /**
@@ -225,21 +275,21 @@ export declare class TransactionQueue extends EventEmitter {
225
275
  * batched commit. Returns the {@link Transaction}, whose `confirmation`
226
276
  * promise settles once the server confirms the write.
227
277
  */
228
- create(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
278
+ create(model: Model, context: UserContext, writeOptions?: WriteOptions, sourceMutationId?: string): Promise<Transaction>;
229
279
  /**
230
280
  * Records an update and applies it optimistically, then stages it for the next
231
281
  * batched commit. Rapid updates to the same entity coalesce into a single wire
232
282
  * operation.
233
283
  * @param precomputedChanges - Optional pre-captured changes, used instead of re-reading them from the model.
234
284
  */
235
- update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?: WriteOptions): Promise<Transaction>;
285
+ update(model: Model, context: UserContext, precomputedChanges?: Record<string, unknown>, writeOptions?: WriteOptions, sourceMutationId?: string): Promise<Transaction>;
236
286
  /**
237
287
  * Records a delete and applies it optimistically. If the row's own create has
238
288
  * not yet been sent, both are cancelled locally rather than sending a create
239
289
  * followed by a delete; if the create is already in flight, the delete waits
240
290
  * until it settles so the server never sees a delete before the create.
241
291
  */
242
- delete(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
292
+ delete(model: Model, context: UserContext, writeOptions?: WriteOptions, sourceMutationId?: string): Promise<Transaction>;
243
293
  /**
244
294
  * Uploads a single attachment, delegating to the mutation executor.
245
295
  */
@@ -263,7 +313,7 @@ export declare class TransactionQueue extends EventEmitter {
263
313
  * Records an archive and applies it optimistically, then stages it for the
264
314
  * next batched commit.
265
315
  */
266
- archive(model: Model, context: UserContext, writeOptions?: WriteOptions): Promise<Transaction>;
316
+ archive(model: Model, context: UserContext, writeOptions?: WriteOptions, sourceMutationId?: string): Promise<Transaction>;
267
317
  /**
268
318
  * Records an unarchive and applies it optimistically, then stages it for the
269
319
  * next batched commit.
@@ -309,7 +359,7 @@ export declare class TransactionQueue extends EventEmitter {
309
359
  enqueueCommit(clientTxId: string, operations: CommitTransaction['operations'], options?: {
310
360
  causedByTaskId?: string | null;
311
361
  reads?: ReadDependency[] | null;
312
- }): void;
362
+ }): Promise<void>;
313
363
  /**
314
364
  * Drains the pending commit-lane envelopes one at a time. A transient
315
365
  * failure, such as a network error, leaves the envelope at the head of the
@@ -346,6 +396,8 @@ export declare class TransactionQueue extends EventEmitter {
346
396
  * - Any other business-logic error from the server.
347
397
  */
348
398
  private isPermanentError;
399
+ /** True only when the server definitively rejected before applying. */
400
+ private isDefinitiveRejection;
349
401
  /**
350
402
  * Handles a failed transaction: retries transient failures with backoff and
351
403
  * rolls back permanent ones, settling the transaction's confirmation promise
@@ -372,6 +424,12 @@ export declare class TransactionQueue extends EventEmitter {
372
424
  * persistence is disabled.
373
425
  */
374
426
  loadPersistedTransactions(database: Database): Promise<void>;
427
+ /**
428
+ * Restore exact sealed requests after the local database is open. Sealed
429
+ * envelopes replay through the atomic commit lane and are never re-projected
430
+ * through model/schema state from the new process.
431
+ */
432
+ restoreDurableCommits(): Promise<Set<string>>;
375
433
  /**
376
434
  * Validates and rehydrates one persisted row. Rows written to the same store
377
435
  * by other subsystems are skipped, and rows that fail the persisted