@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.
- package/CHANGELOG.md +59 -3
- package/README.md +1 -1
- package/dist/BaseSyncedStore.js +8 -9
- package/dist/Database.d.ts +14 -1
- package/dist/Database.js +228 -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 +390 -75
- package/dist/adapters/inMemoryStorage.d.ts +1 -0
- package/dist/adapters/inMemoryStorage.js +29 -13
- package/dist/ai-sdk/coordinatedTool.d.ts +15 -2
- package/dist/auth/schemas.d.ts +6 -0
- package/dist/auth/schemas.js +7 -0
- package/dist/cli.cjs +821 -402
- package/dist/client/Ablo.d.ts +53 -24
- package/dist/client/Ablo.js +12 -199
- package/dist/client/claimHeartbeatLoop.d.ts +1 -1
- package/dist/client/claimHeartbeatLoop.js +1 -1
- package/dist/client/createInternalComponents.d.ts +4 -0
- package/dist/client/createInternalComponents.js +3 -1
- package/dist/client/durableWrites.d.ts +21 -0
- package/dist/client/durableWrites.js +46 -0
- package/dist/client/httpClient.d.ts +21 -45
- package/dist/client/httpClient.js +104 -26
- package/dist/client/httpTransport.d.ts +8 -0
- package/dist/client/{ApiClient.js → httpTransport.js} +361 -308
- package/dist/client/modelRegistration.js +11 -0
- package/dist/client/options.d.ts +54 -7
- package/dist/client/options.js +3 -2
- package/dist/client/resourceTypes.d.ts +9 -85
- package/dist/client/resourceTypes.js +1 -1
- package/dist/client/sessionMint.d.ts +5 -6
- package/dist/client/sessionMint.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/client/wsMutationExecutor.d.ts +3 -2
- package/dist/client/wsMutationExecutor.js +3 -2
- package/dist/coordination/schema.d.ts +30 -0
- package/dist/coordination/schema.js +14 -0
- package/dist/core/StoreManager.d.ts +2 -0
- package/dist/core/StoreManager.js +12 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/errorCodes.js +6 -2
- package/dist/errors.d.ts +4 -40
- package/dist/errors.js +5 -5
- package/dist/index.d.ts +20 -8
- package/dist/index.js +9 -5
- package/dist/interfaces/index.d.ts +5 -2
- package/dist/mutators/UndoManager.d.ts +2 -0
- package/dist/mutators/UndoManager.js +32 -0
- package/dist/mutators/defineMutators.d.ts +18 -0
- package/dist/mutators/defineMutators.js +4 -8
- package/dist/react/index.d.ts +1 -1
- package/dist/react/useAblo.d.ts +6 -4
- package/dist/react/useAblo.js +25 -3
- package/dist/schema/index.d.ts +2 -2
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +31 -1
- package/dist/schema/select.d.ts +15 -0
- package/dist/schema/select.js +27 -3
- package/dist/source/connector.d.ts +3 -3
- package/dist/source/factory.d.ts +4 -6
- package/dist/source/factory.js +4 -7
- package/dist/source/index.d.ts +4 -4
- package/dist/source/index.js +2 -2
- package/dist/source/signing.d.ts +0 -3
- package/dist/source/types.d.ts +0 -26
- package/dist/stores/ObjectStore.d.ts +14 -1
- package/dist/stores/ObjectStore.js +33 -10
- package/dist/stores/ObjectStoreContract.d.ts +2 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +3 -0
- package/dist/sync/BootstrapFetcher.d.ts +10 -0
- package/dist/sync/BootstrapFetcher.js +27 -4
- package/dist/sync/SyncWebSocket.d.ts +2 -1
- package/dist/sync/createClaimStream.d.ts +11 -2
- package/dist/sync/createClaimStream.js +4 -3
- 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/durableWriteStore.d.ts +123 -0
- package/dist/transactions/durableWriteStore.js +30 -0
- package/dist/transactions/httpCommitEnvelope.d.ts +44 -0
- package/dist/transactions/httpCommitEnvelope.js +181 -0
- package/dist/transactions/idempotencyKey.d.ts +10 -0
- package/dist/transactions/idempotencyKey.js +9 -0
- package/dist/transactions/replayValidation.d.ts +83 -0
- package/dist/transactions/replayValidation.js +46 -1
- package/dist/types/global.d.ts +10 -29
- package/dist/types/global.js +4 -7
- package/dist/types/streams.d.ts +2 -28
- package/dist/wire/bootstrapReason.d.ts +9 -0
- package/dist/wire/bootstrapReason.js +8 -0
- package/dist/wire/frames.d.ts +6 -21
- package/dist/wire/frames.js +4 -4
- package/dist/wire/index.d.ts +6 -3
- package/dist/wire/index.js +3 -2
- package/dist/wire/protocolVersion.d.ts +30 -17
- package/dist/wire/protocolVersion.js +34 -18
- package/docs/agents.md +8 -3
- package/docs/api.md +16 -14
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +4 -5
- package/docs/data-sources.md +5 -8
- package/docs/guarantees.md +21 -0
- package/docs/integration-guide.md +1 -0
- package/docs/mcp.md +1 -1
- package/docs/migration.md +21 -1
- package/docs/quickstart.md +11 -0
- package/docs/react.md +0 -46
- package/examples/README.md +6 -5
- package/llms.txt +15 -15
- package/package.json +3 -3
- package/dist/client/ApiClient.d.ts +0 -177
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.29.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **`omitModels()` projects a schema by exclusion — the companion to `selectModels`.** Keep every model except the named ones, so one product can be the general case while another product owns the models it drops. The suite shell narrows with `omitModels(full, ['mailMailboxes', 'mailThreads'])` while the mail app selects those same models. Validation matches `selectModels`: relations into the omitted set are dropped, and a dropped `parent` edge throws, so a model whose scope routes through an omitted parent can't be silently kept. Each app now binds its own schema projection rather than sharing one global registration.
|
|
8
|
+
|
|
9
|
+
- **Every application model has one typed access path: `ablo.<model>`.** Both the WebSocket and stateless HTTP clients return bare rows from `retrieve` and accept the same typed create/update/delete forms. The schema-less `Ablo({ schema: null })` overload, the public `.model(name)` accessors, the transport-envelope types, and the hidden capability CRUD client are removed. Select `transport: 'http'` for workers without changing model syntax, use `commits.create` only for atomic multi-row writes, and `sessions.create` to mint a scoped user or agent credential.
|
|
10
|
+
|
|
11
|
+
- **The commit wire names only the protocol that actually runs.** `@abloatai/ablo/wire` exports `CommitMessage`, `MutationResultMessage`, `commitOperationSchema`, and `commitPayloadSchema` directly. The prospective v2 commit contract published under `@abloatai/ablo/commit` in 0.28.0 — its `CommitReceipt` type, the `legacyCommit*` wire aliases, and the unsent named-mutation frame — is removed, so there is one contract to implement and test. Idempotency-key sealing and fencing move to `transactions/idempotencyKey.ts` (ADR 0006: the v1/v2 boundary lives at ingress, not in the client). Local `useMutators` calls still compose normal transactions; this removes only the dead server wire path.
|
|
12
|
+
|
|
13
|
+
- **Protocol support is backed by codecs, not a numeric claim (ADR 0005).** The wire package publishes an explicit supported-version manifest; the server must provide a decoder for every entry. Durable HTTP writes seal their original protocol version and replay with it after an upgrade. Application-schema hashes are an advisory bootstrap warning rather than an exact WebSocket gate, so additive schema versions can overlap through an expand/contract rollout.
|
|
14
|
+
|
|
15
|
+
- **The injected write-storage option is `durableWrites`, and `DurableWriteStore` is its interface.** The `commitOutbox` client option and `CommitOutboxStore` interface introduced in 0.28.0 are renamed. `durableWritesConfigSchema`, `durableWriteStoreSchema`, `pendingWriteSchema`, and the `DurableWritesConfig` / `DurableWriteStore` / `PendingWrite` types are exported from the package root, so a workflow-, SQLite-, or filesystem-backed adapter validates against the shipped schema instead of an interface you re-declare. The old `commitOutbox` / `CommitOutboxStore` names remain as deprecated aliases through the next major release.
|
|
16
|
+
|
|
17
|
+
- **`Register` registers your `Schema` and `UserMeta` — those are the two keys.** The `Presence` and `Claims` registration keys and their `ResolvePresence` / `ResolveClaims` resolver types are gone, matching the removed presence and claim React placeholders. Read presence through `usePeers` or `useWatch`, and row claims through `ablo.<model>.claim`. The default resolver now carries a `SchemaRecord` fallback, so a shared package typechecks standalone with no app registration in scope.
|
|
18
|
+
|
|
19
|
+
- **One signed adapter export: `dataSource`.** The `abloSource` alias is removed, and the parallel `DataSource*` type names (`DataSourceOptions`, `DataSourceCommitParams`, `DataSourceHandlerContext`, and the rest) collapse into the canonical `Source*` types — one name per shape. Endpoints already built with `dataSource()` are unchanged; only the duplicate names are gone.
|
|
20
|
+
|
|
21
|
+
- **The claim type surface is reached through `Ablo.Claim.*`.** `ClaimOptions`, `ClaimParams`, `ClaimLookupParams`, `ClaimReorderParams`, `Claim`, `ClaimHeartbeat`, `ClaimHeartbeatOptions`, and `ModelOperations` are namespace members rather than top-level exports; `HeldClaim` stays as a deprecated top-level alias because 0.20.1 documented it. The internal construction types `InternalAbloOptions` and `HttpClaimApi` are no longer exported — the public surface names only what an application builds against.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- **Removed React placeholders that had no working runtime.** `usePresence`, `useClaim`, `SyncGroupProvider`, and `useSyncGroup` are no longer exported. Use `usePeers` or `useWatch` for presence and scoped participation, and `ablo.<model>.claim` for row claims. The unused `Register.Presence` / `Register.Claims` resolver types are gone with them.
|
|
26
|
+
|
|
27
|
+
## 0.28.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- **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.
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
- **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.
|
|
38
|
+
|
|
39
|
+
- **`@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.
|
|
40
|
+
|
|
41
|
+
- **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`).
|
|
42
|
+
|
|
43
|
+
- **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.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- **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.
|
|
48
|
+
|
|
49
|
+
- **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.
|
|
50
|
+
|
|
51
|
+
- **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.
|
|
52
|
+
|
|
53
|
+
- **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.
|
|
54
|
+
|
|
55
|
+
- **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.
|
|
56
|
+
|
|
57
|
+
- **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.
|
|
58
|
+
|
|
3
59
|
## 0.27.0
|
|
4
60
|
|
|
5
61
|
### Minor Changes
|
|
@@ -8,7 +64,7 @@
|
|
|
8
64
|
|
|
9
65
|
- **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
66
|
|
|
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.
|
|
67
|
+
- **`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
68
|
|
|
13
69
|
- **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
70
|
|
|
@@ -39,7 +95,7 @@
|
|
|
39
95
|
### Minor Changes
|
|
40
96
|
|
|
41
97
|
- 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.
|
|
98
|
+
- **`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
99
|
- **`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
100
|
- **`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
101
|
- **`ablo push`** — minor guard/UX refinements (deploy-target clarity).
|
|
@@ -594,7 +650,7 @@
|
|
|
594
650
|
|
|
595
651
|
### Patch Changes
|
|
596
652
|
|
|
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.
|
|
653
|
+
- `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
654
|
- 3024593: Fix `sessions.create({ user })` 403 — user sessions now mint via the sk\_-gated ephemeral-key door
|
|
599
655
|
- `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
656
|
- 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,46 @@ 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
|
+
// HTTP outbox rows written before protocol versioning are v1. Normalize
|
|
32
|
+
// them so a same-request re-seal remains idempotent after an upgrade.
|
|
33
|
+
protocolVersion: record.protocolVersion ?? 1,
|
|
34
|
+
request: record.request,
|
|
35
|
+
scopeNamespace: record.scopeNamespace,
|
|
36
|
+
});
|
|
37
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
38
|
+
}
|
|
39
|
+
if (existing.type === 'commit_envelope' &&
|
|
40
|
+
candidate.type === 'commit_envelope') {
|
|
41
|
+
const identity = (record) => ({
|
|
42
|
+
id: record.id,
|
|
43
|
+
type: record.type,
|
|
44
|
+
storageVersion: record.storageVersion,
|
|
45
|
+
origin: record.origin,
|
|
46
|
+
idempotencyKey: record.idempotencyKey,
|
|
47
|
+
operations: record.operations,
|
|
48
|
+
sourceMutationIds: record.sourceMutationIds,
|
|
49
|
+
commitOptions: record.commitOptions,
|
|
50
|
+
scope: record.scope,
|
|
51
|
+
});
|
|
52
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
53
|
+
}
|
|
54
|
+
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
55
|
+
}
|
|
16
56
|
export class Database {
|
|
17
57
|
// Core database components
|
|
18
58
|
databaseManager;
|
|
@@ -741,10 +781,10 @@ export class Database {
|
|
|
741
781
|
}
|
|
742
782
|
// Group deltas by store for efficient transaction management.
|
|
743
783
|
//
|
|
744
|
-
// The method tracks
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
784
|
+
// The method tracks the total range seen plus the exact input indexes whose
|
|
785
|
+
// store transaction committed. The cursor is derived from their ordered
|
|
786
|
+
// prefix after every store finishes; a maximum alone is unsafe because a
|
|
787
|
+
// later store can succeed after an earlier store failed.
|
|
748
788
|
//
|
|
749
789
|
// Without this split, a single store-level failure (a compacted record
|
|
750
790
|
// missing a required field, a validation abort) would advance the cursor
|
|
@@ -754,7 +794,7 @@ export class Database {
|
|
|
754
794
|
// permanently behind the server with no way to recover on reload.
|
|
755
795
|
const deltasByStore = new Map();
|
|
756
796
|
let highestSyncId = 0;
|
|
757
|
-
|
|
797
|
+
const persistedIndexes = new Set();
|
|
758
798
|
let skippedDueToConflict = 0;
|
|
759
799
|
deltas.forEach((delta, idx) => {
|
|
760
800
|
// Normalize to number — postgres sends bigint syncIds as strings.
|
|
@@ -785,6 +825,9 @@ export class Database {
|
|
|
785
825
|
deleteSyncId,
|
|
786
826
|
});
|
|
787
827
|
results[idx] = { action: 'verify', modelName: delta.modelName, modelId: delta.modelId };
|
|
828
|
+
// The later delete in this same ordered frame supersedes this
|
|
829
|
+
// value, so the stale predecessor requires no separate write.
|
|
830
|
+
persistedIndexes.add(idx);
|
|
788
831
|
skippedDueToConflict++;
|
|
789
832
|
return; // Skip this delta
|
|
790
833
|
}
|
|
@@ -983,14 +1026,14 @@ export class Database {
|
|
|
983
1026
|
tx.oncomplete = () => { resolve(); };
|
|
984
1027
|
tx.onerror = () => { reject(tx.error); };
|
|
985
1028
|
});
|
|
986
|
-
// Only commit staged results to the global results if the transaction
|
|
987
|
-
//
|
|
988
|
-
//
|
|
1029
|
+
// Only commit staged results to the global results if the transaction
|
|
1030
|
+
// succeeded. Record input indexes rather than a maximum sync id; the
|
|
1031
|
+
// durable cursor is the prefix through these indexes.
|
|
989
1032
|
for (const r of stagedResults) {
|
|
990
1033
|
// Resolve the originating delta so we can carry its
|
|
991
1034
|
// transactionId through to the result. Echo detection in
|
|
992
1035
|
// `SyncClient.applyDeltaBatchToPool` reads it.
|
|
993
|
-
const sourceDelta =
|
|
1036
|
+
const sourceDelta = deltas[r.idx];
|
|
994
1037
|
results[r.idx] = {
|
|
995
1038
|
action: r.action,
|
|
996
1039
|
modelName: r.modelName,
|
|
@@ -998,13 +1041,7 @@ export class Database {
|
|
|
998
1041
|
data: r.data,
|
|
999
1042
|
transactionId: sourceDelta?.transactionId,
|
|
1000
1043
|
};
|
|
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
|
-
}
|
|
1044
|
+
persistedIndexes.add(r.idx);
|
|
1008
1045
|
}
|
|
1009
1046
|
}
|
|
1010
1047
|
catch (err) {
|
|
@@ -1038,11 +1075,12 @@ export class Database {
|
|
|
1038
1075
|
}
|
|
1039
1076
|
}
|
|
1040
1077
|
}
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
|
|
1045
|
-
//
|
|
1078
|
+
// Advance only through the durable INPUT PREFIX. IDs need not be
|
|
1079
|
+
// numerically contiguous because other tenants and filtered sync groups
|
|
1080
|
+
// occupy gaps; the server-delivered order is the relevant sequence.
|
|
1081
|
+
const highestPersistedSyncId = highestPersistedPrefixSyncId(deltas, persistedIndexes);
|
|
1082
|
+
// Using `highestSyncId` (the range-seen max) would advance past an earlier
|
|
1083
|
+
// failed store transaction and permanently skip its delta.
|
|
1046
1084
|
//
|
|
1047
1085
|
// If `highestPersistedSyncId === 0` (every store tx failed), we leave
|
|
1048
1086
|
// the metadata alone. Next partial bootstrap will re-deliver the
|
|
@@ -1151,16 +1189,51 @@ export class Database {
|
|
|
1151
1189
|
* delete/getAll/getAllFromIndex surface, so callers don't need to
|
|
1152
1190
|
* branch on which one they got back. */
|
|
1153
1191
|
get transactionStore() {
|
|
1154
|
-
return this.
|
|
1192
|
+
return this.getRequiredStore('__transactions');
|
|
1155
1193
|
}
|
|
1156
1194
|
async saveTransaction(transaction) {
|
|
1157
|
-
await this.transactionStore
|
|
1195
|
+
await this.transactionStore.put(transaction);
|
|
1196
|
+
}
|
|
1197
|
+
/** Persist one burst of journal rows in a single strict durability group. */
|
|
1198
|
+
async saveTransactions(transactions) {
|
|
1199
|
+
if (transactions.length === 0)
|
|
1200
|
+
return;
|
|
1201
|
+
if (this.inMemory) {
|
|
1202
|
+
await Promise.all(transactions.map((transaction) => this.transactionStore.put(transaction)));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const db = this.workspaceDb;
|
|
1206
|
+
if (!db || this.isClosing) {
|
|
1207
|
+
throw new AbloConnectionError('Database not opened for mutation journal', {
|
|
1208
|
+
code: 'db_not_opened',
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
await new Promise((resolve, reject) => {
|
|
1212
|
+
try {
|
|
1213
|
+
const tx = db.transaction(['__transactions'], 'readwrite', {
|
|
1214
|
+
durability: 'strict',
|
|
1215
|
+
});
|
|
1216
|
+
const store = tx.objectStore('__transactions');
|
|
1217
|
+
for (const transaction of transactions)
|
|
1218
|
+
store.put(transaction);
|
|
1219
|
+
tx.oncomplete = () => { resolve(); };
|
|
1220
|
+
tx.onabort = () => {
|
|
1221
|
+
reject(tx.error ?? new Error('Mutation journal transaction aborted'));
|
|
1222
|
+
};
|
|
1223
|
+
tx.onerror = () => {
|
|
1224
|
+
// onabort owns rejection.
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1158
1231
|
}
|
|
1159
1232
|
async removeTransaction(id) {
|
|
1160
|
-
await this.transactionStore
|
|
1233
|
+
await this.transactionStore.delete(id);
|
|
1161
1234
|
}
|
|
1162
1235
|
async getPersistedTransactions() {
|
|
1163
|
-
const rows =
|
|
1236
|
+
const rows = await this.transactionStore.getAll();
|
|
1164
1237
|
// Storage layer returns the centralized `Record<string, unknown>`
|
|
1165
1238
|
// shape from `ObjectStoreContract`. PersistedTransaction adds an
|
|
1166
1239
|
// index signature so each row already structurally satisfies the
|
|
@@ -1168,14 +1241,138 @@ export class Database {
|
|
|
1168
1241
|
// here, and it only accepts PersistedTransaction.
|
|
1169
1242
|
return rows;
|
|
1170
1243
|
}
|
|
1244
|
+
async getPersistedTransaction(id) {
|
|
1245
|
+
return (await this.transactionStore.get(id));
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Atomically seal one exact commit request and consume the staged mutation
|
|
1249
|
+
* records it replaces. The read, optional add, and deletes share one strict
|
|
1250
|
+
* IndexedDB transaction, so a crash can expose the staged records or the
|
|
1251
|
+
* sealed envelope, never a missing handoff. Returns the pre-existing record
|
|
1252
|
+
* when the envelope id was already sealed (retry/re-entrant call).
|
|
1253
|
+
*/
|
|
1254
|
+
async sealTransactionRecord(record, consumedRecordIds) {
|
|
1255
|
+
const recordId = record.id;
|
|
1256
|
+
if (!recordId) {
|
|
1257
|
+
throw new AbloValidationError('A sealed transaction record must carry an id', {
|
|
1258
|
+
code: 'invalid_body',
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
if (this.inMemory) {
|
|
1262
|
+
const store = this.transactionStore;
|
|
1263
|
+
const existing = (await store.get(recordId));
|
|
1264
|
+
if (existing && !isSameOutboxRecord(existing, record)) {
|
|
1265
|
+
throw new AbloValidationError('Pending-write key already identifies a different request', {
|
|
1266
|
+
code: 'idempotency_conflict',
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
if (!existing) {
|
|
1270
|
+
const sources = await Promise.all(consumedRecordIds.map((id) => store.get(id)));
|
|
1271
|
+
if (sources.some((source) => source === undefined)) {
|
|
1272
|
+
throw new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
|
|
1273
|
+
}
|
|
1274
|
+
await store.add(record);
|
|
1275
|
+
}
|
|
1276
|
+
for (const id of consumedRecordIds) {
|
|
1277
|
+
if (id !== recordId)
|
|
1278
|
+
await store.delete(id);
|
|
1279
|
+
}
|
|
1280
|
+
return existing;
|
|
1281
|
+
}
|
|
1282
|
+
const db = this.workspaceDb;
|
|
1283
|
+
if (!db || this.isClosing) {
|
|
1284
|
+
throw new AbloConnectionError('Database not opened for durable writes', {
|
|
1285
|
+
code: 'db_not_opened',
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return new Promise((resolve, reject) => {
|
|
1289
|
+
try {
|
|
1290
|
+
const tx = db.transaction(['__transactions'], 'readwrite', {
|
|
1291
|
+
durability: 'strict',
|
|
1292
|
+
});
|
|
1293
|
+
const store = tx.objectStore('__transactions');
|
|
1294
|
+
const getRequest = store.get(recordId);
|
|
1295
|
+
const sourceIds = [...new Set(consumedRecordIds)].filter((id) => id !== recordId);
|
|
1296
|
+
const sourceRequests = sourceIds.map((id) => store.get(id));
|
|
1297
|
+
const sourceExists = new Array(sourceRequests.length).fill(false);
|
|
1298
|
+
let existing;
|
|
1299
|
+
let collisionError;
|
|
1300
|
+
let envelopeRead = false;
|
|
1301
|
+
let sourcesRead = 0;
|
|
1302
|
+
let promotionStarted = false;
|
|
1303
|
+
const promote = () => {
|
|
1304
|
+
if (promotionStarted ||
|
|
1305
|
+
!envelopeRead ||
|
|
1306
|
+
sourcesRead !== sourceRequests.length)
|
|
1307
|
+
return;
|
|
1308
|
+
promotionStarted = true;
|
|
1309
|
+
if (existing && !isSameOutboxRecord(existing, record)) {
|
|
1310
|
+
collisionError = new AbloValidationError('Pending-write key already identifies a different request', { code: 'idempotency_conflict' });
|
|
1311
|
+
tx.abort();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
// A new envelope owns promotion only while every source row still
|
|
1315
|
+
// exists. This is the fleet/tab execution claim: a second tab that
|
|
1316
|
+
// restored the same journal entries under another key loses here and
|
|
1317
|
+
// cannot dispatch. An identical existing envelope is an idempotent
|
|
1318
|
+
// retry, so its already-consumed sources may be absent.
|
|
1319
|
+
if (!existing && sourceExists.some((exists) => !exists)) {
|
|
1320
|
+
collisionError = new AbloValidationError('Pending-write source mutations were already claimed by another write', { code: 'idempotency_conflict' });
|
|
1321
|
+
tx.abort();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (!existing)
|
|
1325
|
+
store.add(record);
|
|
1326
|
+
for (const id of sourceIds)
|
|
1327
|
+
store.delete(id);
|
|
1328
|
+
};
|
|
1329
|
+
getRequest.onsuccess = () => {
|
|
1330
|
+
existing = getRequest.result;
|
|
1331
|
+
envelopeRead = true;
|
|
1332
|
+
promote();
|
|
1333
|
+
};
|
|
1334
|
+
getRequest.onerror = () => {
|
|
1335
|
+
tx.abort();
|
|
1336
|
+
};
|
|
1337
|
+
sourceRequests.forEach((request, index) => {
|
|
1338
|
+
request.onsuccess = () => {
|
|
1339
|
+
sourceExists[index] = request.result !== undefined;
|
|
1340
|
+
sourcesRead += 1;
|
|
1341
|
+
promote();
|
|
1342
|
+
};
|
|
1343
|
+
request.onerror = () => {
|
|
1344
|
+
tx.abort();
|
|
1345
|
+
};
|
|
1346
|
+
});
|
|
1347
|
+
tx.oncomplete = () => { resolve(existing); };
|
|
1348
|
+
tx.onabort = () => {
|
|
1349
|
+
reject(collisionError ??
|
|
1350
|
+
tx.error ??
|
|
1351
|
+
getRequest.error ??
|
|
1352
|
+
new Error('Durable-write transaction aborted'));
|
|
1353
|
+
};
|
|
1354
|
+
tx.onerror = () => {
|
|
1355
|
+
// onabort owns rejection so the promise settles exactly once.
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1171
1363
|
async cleanupOldTransactions(maxAge) {
|
|
1172
1364
|
const store = this.transactionStore;
|
|
1173
|
-
if (!store)
|
|
1174
|
-
return 0;
|
|
1175
1365
|
const rows = (await store.getAll());
|
|
1176
1366
|
const cutoff = Date.now() - maxAge;
|
|
1177
1367
|
let cleaned = 0;
|
|
1178
1368
|
for (const tx of rows) {
|
|
1369
|
+
// Live write intent has no safe age-based expiry. In particular, server
|
|
1370
|
+
// idempotency retention may already have elapsed, so silently deleting or
|
|
1371
|
+
// blindly replaying an old envelope would both be unsafe. Restoration
|
|
1372
|
+
// owns quarantine/reconciliation for these records.
|
|
1373
|
+
if (tx.type === 'commit_envelope' || tx.type === 'pending_mutation') {
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1179
1376
|
if (typeof tx.timestamp === 'number' && tx.timestamp < cutoff) {
|
|
1180
1377
|
await store.delete(tx.id);
|
|
1181
1378
|
cleaned++;
|
|
@@ -1251,8 +1448,11 @@ export class Database {
|
|
|
1251
1448
|
this.currentDbInfo = null;
|
|
1252
1449
|
getContext().logger.debug('Database closed');
|
|
1253
1450
|
}
|
|
1254
|
-
async clear() {
|
|
1451
|
+
async clear(options = {}) {
|
|
1255
1452
|
await this.storeManager.clearAllStores();
|
|
1453
|
+
if (options.includeWriteJournal) {
|
|
1454
|
+
await this.transactionStore.clear();
|
|
1455
|
+
}
|
|
1256
1456
|
getContext().logger.info('All stores cleared');
|
|
1257
1457
|
}
|
|
1258
1458
|
}
|
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
|
*/
|