@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.
- package/CHANGELOG.md +29 -3
- package/README.md +1 -1
- package/dist/BaseSyncedStore.js +8 -9
- package/dist/Database.d.ts +14 -1
- package/dist/Database.js +225 -28
- package/dist/Model.d.ts +17 -0
- package/dist/Model.js +32 -1
- package/dist/SyncClient.d.ts +10 -6
- package/dist/SyncClient.js +379 -76
- package/dist/adapters/inMemoryStorage.d.ts +1 -0
- package/dist/adapters/inMemoryStorage.js +12 -0
- package/dist/cli.cjs +6 -2
- package/dist/client/Ablo.d.ts +24 -1
- package/dist/client/Ablo.js +4 -3
- package/dist/client/ApiClient.js +309 -43
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/httpClient.d.ts +2 -0
- package/dist/client/modelRegistration.js +11 -0
- package/dist/client/options.d.ts +23 -0
- package/dist/client/wsMutationExecutor.d.ts +3 -2
- package/dist/client/wsMutationExecutor.js +3 -2
- package/dist/commit/contract.d.ts +493 -0
- package/dist/commit/contract.js +187 -0
- package/dist/commit/index.d.ts +6 -0
- package/dist/commit/index.js +5 -0
- package/dist/core/StoreManager.d.ts +2 -0
- package/dist/core/StoreManager.js +12 -0
- package/dist/errorCodes.js +6 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/interfaces/index.d.ts +2 -2
- package/dist/mutators/UndoManager.d.ts +2 -0
- package/dist/mutators/UndoManager.js +32 -0
- package/dist/react/useAblo.d.ts +6 -4
- package/dist/react/useAblo.js +25 -3
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/schema.d.ts +31 -1
- package/dist/stores/ObjectStore.d.ts +14 -1
- package/dist/stores/ObjectStore.js +27 -4
- package/dist/stores/ObjectStoreContract.d.ts +2 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/dist/sync/SyncWebSocket.d.ts +2 -1
- package/dist/sync/persistedPrefix.d.ts +12 -0
- package/dist/sync/persistedPrefix.js +22 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/mocks/FakeDatabase.d.ts +18 -0
- package/dist/testing/mocks/FakeDatabase.js +10 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -1
- package/dist/transactions/TransactionQueue.d.ts +66 -8
- package/dist/transactions/TransactionQueue.js +607 -89
- package/dist/transactions/commitEnvelope.d.ts +132 -0
- package/dist/transactions/commitEnvelope.js +139 -0
- package/dist/transactions/commitOutboxStore.d.ts +32 -0
- package/dist/transactions/commitOutboxStore.js +26 -0
- package/dist/transactions/commitPayload.d.ts +15 -0
- package/dist/transactions/commitPayload.js +6 -0
- package/dist/transactions/httpCommitEnvelope.d.ts +43 -0
- package/dist/transactions/httpCommitEnvelope.js +179 -0
- package/dist/transactions/replayValidation.d.ts +83 -0
- package/dist/transactions/replayValidation.js +46 -1
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/wire/frames.d.ts +236 -0
- package/dist/wire/frames.js +21 -0
- package/dist/wire/index.d.ts +4 -2
- package/dist/wire/index.js +2 -1
- package/docs/api.md +10 -10
- package/docs/coordination.md +2 -2
- package/docs/mcp.md +1 -1
- 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": [
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/dist/Database.d.ts
CHANGED
|
@@ -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(
|
|
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
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
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
|
-
|
|
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
|
|
987
|
-
//
|
|
988
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
|
|
1045
|
-
//
|
|
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.
|
|
1189
|
+
return this.getRequiredStore('__transactions');
|
|
1155
1190
|
}
|
|
1156
1191
|
async saveTransaction(transaction) {
|
|
1157
|
-
await this.transactionStore
|
|
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
|
|
1230
|
+
await this.transactionStore.delete(id);
|
|
1161
1231
|
}
|
|
1162
1232
|
async getPersistedTransactions() {
|
|
1163
|
-
const rows =
|
|
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
|
*/
|
package/dist/SyncClient.d.ts
CHANGED
|
@@ -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
|
-
|
|
257
|
-
*/
|
|
258
|
-
private persistMutationQueue;
|
|
261
|
+
private pendingMutationRecord;
|
|
262
|
+
private persistPendingMutation;
|
|
259
263
|
/**
|
|
260
264
|
* Restore the mutation queue from IndexedDB.
|
|
261
265
|
*
|