@abloatai/ablo 0.35.0 → 0.36.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 +713 -629
- package/README.md +56 -519
- package/bin/ablo.cjs +39 -0
- package/dist/BaseSyncedStore.d.ts +24 -4
- package/dist/BaseSyncedStore.js +53 -37
- package/dist/Database.d.ts +8 -20
- package/dist/Database.js +61 -59
- package/dist/InstanceCache.d.ts +6 -2
- package/dist/InstanceCache.js +18 -16
- package/dist/Model.d.ts +17 -7
- package/dist/Model.js +17 -7
- package/dist/ModelRegistry.d.ts +4 -0
- package/dist/ModelRegistry.js +17 -15
- package/dist/NetworkMonitor.d.ts +3 -1
- package/dist/NetworkMonitor.js +7 -5
- package/dist/SyncClient.d.ts +5 -15
- package/dist/SyncClient.js +60 -57
- package/dist/client/Ablo.js +30 -19
- package/dist/client/createInternalComponents.d.ts +4 -0
- package/dist/client/createInternalComponents.js +8 -2
- package/dist/client/createModelProxy.d.ts +21 -1
- package/dist/client/createModelProxy.js +123 -57
- package/dist/client/humans.d.ts +30 -9
- package/dist/client/humans.js +45 -19
- package/dist/client/reactiveEngine.d.ts +16 -11
- package/dist/client/reactiveEngine.js +113 -335
- package/dist/client/storeCluster.d.ts +47 -0
- package/dist/client/storeCluster.js +118 -0
- package/dist/client/storeLifecycle.d.ts +61 -0
- package/dist/client/storeLifecycle.js +231 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +23 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/docs/catalog.js +6 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/query/client.d.ts +3 -0
- package/dist/query/client.js +6 -5
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +5 -2
- package/dist/react/context.d.ts +2 -2
- package/dist/react/createAbloReact.d.ts +56 -0
- package/dist/react/createAbloReact.js +51 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/useAblo.d.ts +9 -2
- package/dist/react/useAblo.js +25 -7
- package/dist/schema/coordination.js +5 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +4 -0
- package/dist/schema/select.js +3 -0
- package/dist/schema/serialize.js +3 -0
- package/dist/source/adapter.d.ts +7 -5
- package/dist/source/adapter.js +7 -5
- package/dist/source/index.d.ts +1 -1
- package/dist/source/index.js +1 -1
- package/dist/{core/storeContract.d.ts → storeContract.d.ts} +5 -5
- package/dist/{core → stores}/DatabaseManager.d.ts +3 -1
- package/dist/{core → stores}/DatabaseManager.js +13 -12
- package/dist/{core → stores}/StoreManager.d.ts +5 -3
- package/dist/{core → stores}/StoreManager.js +24 -22
- package/dist/stores/SyncActionStore.d.ts +3 -1
- package/dist/stores/SyncActionStore.js +9 -7
- package/dist/sync/BootstrapFetcher.d.ts +4 -0
- package/dist/sync/BootstrapFetcher.js +28 -26
- package/dist/sync/OnDemandLoader.d.ts +3 -0
- package/dist/sync/OnDemandLoader.js +1 -0
- package/dist/sync/bootstrapApply.d.ts +3 -0
- package/dist/sync/bootstrapApply.js +2 -2
- package/dist/sync/deltaPipeline.d.ts +13 -12
- package/dist/sync/deltaPipeline.js +21 -4
- package/dist/sync/groupChange.d.ts +3 -0
- package/dist/sync/groupChange.js +16 -14
- package/dist/sync/participants.d.ts +19 -2
- package/dist/sync/participants.js +3 -1
- package/dist/sync/schemas.d.ts +2 -1
- package/dist/sync/schemas.js +3 -3
- package/dist/syncLog/contract.d.ts +20 -0
- package/dist/syncLog/contract.js +19 -0
- package/dist/syncLog/index.d.ts +1 -0
- package/dist/syncLog/index.js +1 -0
- package/dist/transaction/auth/capability.d.ts +35 -0
- package/dist/transaction/auth/capability.js +25 -0
- package/dist/transaction/coordination/awaitClaimGrant.d.ts +7 -0
- package/dist/transaction/coordination/awaitClaimGrant.js +12 -0
- package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +34 -0
- package/dist/transaction/coordination/claimHeartbeatLoop.js +20 -0
- package/dist/transaction/coordination/index.d.ts +4 -4
- package/dist/transaction/coordination/index.js +4 -3
- package/dist/transaction/coordination/locator.d.ts +23 -2
- package/dist/transaction/coordination/locator.js +22 -2
- package/dist/transaction/coordination/schema.d.ts +125 -62
- package/dist/transaction/coordination/schema.js +228 -64
- package/dist/transaction/coordination/targetConflict.js +32 -28
- package/dist/transaction/errorCodes.d.ts +2 -2
- package/dist/transaction/errorCodes.js +13 -9
- package/dist/transaction/plugin.d.ts +95 -2
- package/dist/transaction/plugin.js +21 -2
- package/dist/transaction/resources/httpResources.d.ts +57 -2
- package/dist/transaction/resources/modelOperations.d.ts +148 -40
- package/dist/transaction/resources/where.d.ts +16 -0
- package/dist/transaction/resources/where.js +45 -0
- package/dist/transaction/schema/field.d.ts +12 -18
- package/dist/transaction/schema/fieldRef.d.ts +38 -0
- package/dist/transaction/schema/fieldRef.js +11 -0
- package/dist/transaction/schema/openapi.d.ts +16 -15
- package/dist/transaction/schema/openapi.js +186 -25
- package/dist/transaction/schema/relation.d.ts +7 -2
- package/dist/transaction/schema/schema.d.ts +27 -0
- package/dist/transaction/schema/schema.js +20 -0
- package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +1 -1
- package/dist/transaction/transactions/settlement/pendingWrite.d.ts +1 -1
- package/dist/transaction/transport/httpClient.d.ts +9 -1
- package/dist/transaction/transport/httpClient.js +1 -0
- package/dist/transaction/transport/httpTransport.js +156 -44
- package/dist/transaction/transport/wsTransport.d.ts +2 -4
- package/dist/transaction/transport/wsTransport.js +16 -10
- package/dist/transaction/types/streams.d.ts +10 -0
- package/dist/transaction/utils/duration.d.ts +25 -0
- package/dist/transaction/utils/duration.js +32 -0
- package/dist/transaction/wire/accountResponses.d.ts +69 -0
- package/dist/transaction/wire/accountResponses.js +36 -1
- package/dist/transaction/wire/auth.d.ts +9 -2
- package/dist/transaction/wire/auth.js +7 -1
- package/dist/transaction/wire/claims.d.ts +164 -97
- package/dist/transaction/wire/claims.js +126 -28
- package/dist/transaction/wire/commit.d.ts +1 -1
- package/dist/transaction/wire/feedEvent.d.ts +27 -0
- package/dist/transaction/wire/feedEvent.js +27 -1
- package/dist/transaction/wire/frames.d.ts +2 -2
- package/dist/transaction/wire/inboundFrames.d.ts +12 -2
- package/dist/transaction/wire/index.d.ts +10 -6
- package/dist/transaction/wire/index.js +12 -3
- package/dist/transaction/wire/modelMutations.d.ts +31 -0
- package/dist/transaction/wire/modelMutations.js +52 -0
- package/dist/transaction/wire/modelShape.d.ts +78 -0
- package/dist/transaction/wire/modelShape.js +74 -0
- package/dist/transactions/mutations/MutationQueue.d.ts +6 -0
- package/dist/transactions/mutations/MutationQueue.js +55 -45
- package/dist/transactions/mutations/commitPayload.d.ts +3 -2
- package/dist/transactions/mutations/commitPayload.js +5 -5
- package/dist/transactions/mutations/deltaConfirmation.d.ts +4 -0
- package/dist/transactions/mutations/deltaConfirmation.js +10 -8
- package/dist/transactions/mutations/replayValidation.d.ts +2 -1
- package/dist/transactions/mutations/replayValidation.js +3 -2
- package/dist/{core → views}/QueryView.d.ts +1 -1
- package/dist/{core → views}/QueryView.js +1 -1
- package/dist/{core → views}/ViewRegistry.d.ts +1 -1
- package/dist/{core/queryUtils.d.ts → views/incrementalView.d.ts} +6 -6
- package/dist/{core/queryUtils.js → views/incrementalView.js} +6 -6
- package/docs/agents.md +1 -1
- package/docs/api-keys.md +6 -6
- package/docs/api.md +5 -43
- package/docs/audit.md +4 -3
- package/docs/cli.md +11 -11
- package/docs/client-behavior.md +4 -4
- package/docs/concurrency-convention.md +28 -42
- package/docs/coordination.md +235 -83
- package/docs/data-sources.md +4 -4
- package/docs/debugging.md +34 -12
- package/docs/deployment.md +8 -8
- package/docs/examples/scoped-agent.md +3 -3
- package/docs/groups.md +57 -3
- package/docs/guarantees.md +37 -10
- package/docs/how-it-works.md +29 -5
- package/docs/idempotency.md +6 -6
- package/docs/identity.md +22 -23
- package/docs/index.md +8 -8
- package/docs/integration-guide.md +14 -3
- package/docs/mcp.md +7 -7
- package/docs/migration.md +34 -15
- package/docs/projects.md +1 -1
- package/docs/react.md +19 -8
- package/docs/sessions.md +1 -1
- package/docs/webhooks.md +9 -9
- package/llms.txt +4 -4
- package/package.json +13 -20
- package/dist/cli.cjs +0 -288600
- package/dist/testing/fixtures/bootstrap.d.ts +0 -49
- package/dist/testing/fixtures/bootstrap.js +0 -59
- package/dist/testing/fixtures/deltas.d.ts +0 -83
- package/dist/testing/fixtures/deltas.js +0 -136
- package/dist/testing/fixtures/httpResponses.d.ts +0 -70
- package/dist/testing/fixtures/httpResponses.js +0 -90
- package/dist/testing/fixtures/models.d.ts +0 -83
- package/dist/testing/fixtures/models.js +0 -272
- package/dist/testing/helpers/reactWrapper.d.ts +0 -69
- package/dist/testing/helpers/reactWrapper.js +0 -67
- package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
- package/dist/testing/helpers/syncEngineHarness.js +0 -73
- package/dist/testing/helpers/wait.d.ts +0 -30
- package/dist/testing/helpers/wait.js +0 -49
- package/dist/testing/index.d.ts +0 -23
- package/dist/testing/index.js +0 -33
- package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
- package/dist/testing/mocks/FakeDatabase.js +0 -10
- package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
- package/dist/testing/mocks/MockMutationExecutor.js +0 -186
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
- package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
- package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
- package/dist/testing/mocks/MockSyncContext.js +0 -72
- package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
- package/dist/testing/mocks/MockSyncStore.js +0 -171
- package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
- package/dist/testing/mocks/MockWebSocket.js +0 -118
- package/docs/interaction-model.md +0 -99
- /package/dist/{core → query}/QueryProcessor.d.ts +0 -0
- /package/dist/{core → query}/QueryProcessor.js +0 -0
- /package/dist/{core/storeContract.js → storeContract.js} +0 -0
- /package/dist/{core → stores}/openIDBWithTimeout.d.ts +0 -0
- /package/dist/{core → stores}/openIDBWithTimeout.js +0 -0
- /package/dist/{source → transaction}/footprint.d.ts +0 -0
- /package/dist/{source → transaction}/footprint.js +0 -0
- /package/dist/{core → views}/ViewRegistry.js +0 -0
|
@@ -37,10 +37,28 @@
|
|
|
37
37
|
* lowercased them.
|
|
38
38
|
*/
|
|
39
39
|
function fieldSet(target) {
|
|
40
|
-
const
|
|
40
|
+
const named = [
|
|
41
41
|
...(target?.field !== undefined ? [target.field] : []),
|
|
42
42
|
...(target?.fields ?? []),
|
|
43
43
|
];
|
|
44
|
+
// A path names a part too: `/content/3` addresses a position inside the
|
|
45
|
+
// `content` field, so a claim on it covers that field and nothing else.
|
|
46
|
+
//
|
|
47
|
+
// Deriving it here is what stops a path from reading as EVERY field. A
|
|
48
|
+
// target that named no field left this set empty, and an empty set means
|
|
49
|
+
// "all of them" — so a claim on one paragraph blocked a write to `status`
|
|
50
|
+
// on the same row. It also makes the exclusion honest in the other
|
|
51
|
+
// direction: a write of the whole `content` field does conflict with a
|
|
52
|
+
// claim on part of it, because nothing can write only that part.
|
|
53
|
+
//
|
|
54
|
+
// Only when no field was named outright — an explicit `field`/`fields`
|
|
55
|
+
// beside a path is the caller being specific, and is not second-guessed.
|
|
56
|
+
// A bare `range` derives nothing: it names a span without saying of what,
|
|
57
|
+
// so it stays the conservative whole-row answer.
|
|
58
|
+
const rootOfPath = named.length === 0 && target?.path !== undefined
|
|
59
|
+
? target.path.split('/').filter(Boolean)[0]
|
|
60
|
+
: undefined;
|
|
61
|
+
const names = rootOfPath !== undefined ? [...named, rootOfPath] : named;
|
|
44
62
|
return new Set(names.map((n) => n.toLowerCase()));
|
|
45
63
|
}
|
|
46
64
|
/** Whether a claim narrows below the whole entity at all. */
|
|
@@ -50,30 +68,6 @@ function hasSubtarget(target) {
|
|
|
50
68
|
(target?.fields && target.fields.length > 0) ||
|
|
51
69
|
target?.range);
|
|
52
70
|
}
|
|
53
|
-
function lower(value) {
|
|
54
|
-
return value?.toLowerCase();
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Whether two paths address overlapping parts of a document.
|
|
58
|
-
*
|
|
59
|
-
* Paths are hierarchical, so equality is the wrong test: `/content` contains
|
|
60
|
-
* `/content/3`, and a writer holding the parent is holding the child too.
|
|
61
|
-
* Comparing them as opaque strings reported no conflict and granted both
|
|
62
|
-
* leases — the parent's writer and the child's writer would then both write,
|
|
63
|
-
* and one update would vanish with nothing raised anywhere.
|
|
64
|
-
*
|
|
65
|
-
* Containment is checked on a separator boundary so `/content` contains
|
|
66
|
-
* `/content/3` but not `/contentious`, which is the same trap as any
|
|
67
|
-
* prefix match over structured names.
|
|
68
|
-
*/
|
|
69
|
-
function pathsOverlap(a, b) {
|
|
70
|
-
const x = lower(a) ?? '';
|
|
71
|
-
const y = lower(b) ?? '';
|
|
72
|
-
if (x === y)
|
|
73
|
-
return true;
|
|
74
|
-
const contains = (parent, child) => child.startsWith(parent.endsWith('/') ? parent : `${parent}/`);
|
|
75
|
-
return contains(x, y) || contains(y, x);
|
|
76
|
-
}
|
|
77
71
|
function rangesOverlap(a, b) {
|
|
78
72
|
// Line-level overlap is the stable default. Columns can be layered
|
|
79
73
|
// in by policy later, but line ranges are what code/editor agents
|
|
@@ -85,9 +79,19 @@ export function targetsConflict(held, incoming) {
|
|
|
85
79
|
// narrower path/field/range claim under the same entity.
|
|
86
80
|
if (!hasSubtarget(held) || !hasSubtarget(incoming))
|
|
87
81
|
return true;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
// A claim is only as fine as the smallest thing the write path can address,
|
|
83
|
+
// and today that is a field: nothing writes part of a value. So two paths
|
|
84
|
+
// into the SAME field contend, and this rule used to grant them both — by
|
|
85
|
+
// comparing paths for containment and answering "disjoint" for `/content/3`
|
|
86
|
+
// against `/content/7`. Both holders were then blocked at their first write,
|
|
87
|
+
// each by the other, which is a wait dressed as concurrency and resolves
|
|
88
|
+
// only when someone releases.
|
|
89
|
+
//
|
|
90
|
+
// Both are answered below instead, through the field a path names: same
|
|
91
|
+
// field, conflict; different fields, disjoint. Path containment returns when
|
|
92
|
+
// a field can declare a smaller write unit than its whole value — an
|
|
93
|
+
// operation rather than a value — because that is what makes two positions
|
|
94
|
+
// inside one field separately writable and the disjointness real.
|
|
91
95
|
// A claim that names no field covers every field, so it conflicts with any
|
|
92
96
|
// claim under the same path. Two that both name fields conflict only where
|
|
93
97
|
// their sets intersect.
|
|
@@ -37,7 +37,7 @@ import { z } from 'zod';
|
|
|
37
37
|
* error documentation and returned on the `Ablo-Version` response header, so a
|
|
38
38
|
* consumer can detect when its expected contract has drifted from the server's.
|
|
39
39
|
*/
|
|
40
|
-
export declare const ERROR_CONTRACT_VERSION = "2026-07-
|
|
40
|
+
export declare const ERROR_CONTRACT_VERSION = "2026-07-23";
|
|
41
41
|
/** A coarse grouping of error codes, used to organize metrics and documentation. */
|
|
42
42
|
export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
|
|
43
43
|
/**
|
|
@@ -227,7 +227,6 @@ export declare const ERROR_CODES: {
|
|
|
227
227
|
readonly claim_not_wired: ErrorCodeSpec;
|
|
228
228
|
readonly claim_queued: ErrorCodeSpec;
|
|
229
229
|
readonly claim_wait_aborted: ErrorCodeSpec;
|
|
230
|
-
readonly claim_wait_poll_interval_required: ErrorCodeSpec;
|
|
231
230
|
readonly grant_timeout: ErrorCodeSpec;
|
|
232
231
|
readonly bootstrap_fetch_timeout: ErrorCodeSpec;
|
|
233
232
|
readonly bootstrap_cancelled: ErrorCodeSpec;
|
|
@@ -360,6 +359,7 @@ export declare const ERROR_CODES: {
|
|
|
360
359
|
readonly request_too_large: ErrorCodeSpec;
|
|
361
360
|
readonly invalid_schema: ErrorCodeSpec;
|
|
362
361
|
readonly incompatible_change: ErrorCodeSpec;
|
|
362
|
+
readonly replication_reset_required: ErrorCodeSpec;
|
|
363
363
|
};
|
|
364
364
|
/**
|
|
365
365
|
* The type of a valid error code: any key registered in {@link ERROR_CODES},
|
|
@@ -37,7 +37,7 @@ import { z } from 'zod';
|
|
|
37
37
|
* error documentation and returned on the `Ablo-Version` response header, so a
|
|
38
38
|
* consumer can detect when its expected contract has drifted from the server's.
|
|
39
39
|
*/
|
|
40
|
-
export const ERROR_CONTRACT_VERSION = '2026-07-
|
|
40
|
+
export const ERROR_CONTRACT_VERSION = '2026-07-23';
|
|
41
41
|
/**
|
|
42
42
|
* A closed classification of how a failure can be recovered from — a level above
|
|
43
43
|
* the raw {@link ErrorCode}. Where a code says what went wrong, a recovery class
|
|
@@ -150,15 +150,19 @@ export const ERROR_CODES = {
|
|
|
150
150
|
// only loop. Recovery belongs to the caller: take a claim, which queues fairly
|
|
151
151
|
// behind the holder (`ablo.<model>.claim`), or re-read and rebase.
|
|
152
152
|
claim_conflict: wire('claim', 409, false, 'Another participant holds a claim on this row, so the write was rejected. Take a claim with `ablo.<model>.claim` to queue fairly behind the holder, or re-read and rebase.'),
|
|
153
|
-
claim_lost: wire('claim', 409, false, 'The claim held on this row was lost before the write could apply. Re-acquire
|
|
153
|
+
claim_lost: wire('claim', 409, false, 'The claim held on this row was lost before the write could apply. Re-acquire with `ablo.<model>.claim` and retry from its fresh snapshot.'),
|
|
154
154
|
fence_token_stale: wire('claim', 409, false, 'This write carried a fencing token below the row’s current high-water: a later holder claimed the row, wrote, and moved on while this claim was lapsed, so applying the write would silently overwrite their work. The claim is gone — re-claim the row and retry from the current state.'),
|
|
155
155
|
entity_claimed: wire('claim', 409, false, 'This row is currently claimed by another participant, so the write was blocked. Queue behind the holder with `ablo.<model>.claim`, or wait for the claim to clear.'),
|
|
156
156
|
// A claim payload that cannot be parsed is a malformed request, not
|
|
157
157
|
// contention — it is filed with `malformed_subscription` below rather than
|
|
158
158
|
// with the 409s above, so the claim category stays purely about a target
|
|
159
159
|
// being held. This is the code for a claim that fails to name its target,
|
|
160
|
-
// over either transport
|
|
161
|
-
|
|
160
|
+
// over either transport — and for the participant claim behind `join`, which
|
|
161
|
+
// names scopes rather than a row but fails the same way and to the same
|
|
162
|
+
// caller. The copy covers both because the caller sees one word, `claim`, and
|
|
163
|
+
// needs to be told which shape was expected without being handed a lecture on
|
|
164
|
+
// the two frames.
|
|
165
|
+
malformed_claim: wire('validation', 400, false, 'The claim payload could not be parsed. A claim on a row must name the model and the entity it targets; a claim on a scope, which is what `join` opens, must name sync groups spelled `kind:id` or `default`. Check the payload shape and resend.'),
|
|
162
166
|
malformed_subscription: wire('validation', 400, false, 'The `update_subscription` payload was malformed; expected `{ syncGroups: string[] }`.'),
|
|
163
167
|
// The counterpart to the two above, pointing the other way: those are a
|
|
164
168
|
// client sending the server something it cannot read, this is the server
|
|
@@ -177,7 +181,7 @@ export const ERROR_CODES = {
|
|
|
177
181
|
// `readAt`, so resending the identical payload can never succeed. Recovery
|
|
178
182
|
// is a caller-level re-read that produces a NEW request with a fresh
|
|
179
183
|
// watermark — the same shape as `claim_conflict`.
|
|
180
|
-
stale_context: wire('conflict', 409, false, "The row changed after you read it — the write's `readAt` watermark is older than the current row version.
|
|
184
|
+
stale_context: wire('conflict', 409, false, "The row changed after you read it — the write's `readAt` watermark is older than the current row version. Pass a function to `update(id, current => next)` and the SDK re-reads and retries for you; or re-read and retry by hand."),
|
|
181
185
|
// Raised by the functional `update(id, current => next)` form once its
|
|
182
186
|
// internal reconcile budget is exhausted, because the row stayed continuously
|
|
183
187
|
// contended. The SDK has already retried; the caller decides whether to back
|
|
@@ -261,10 +265,9 @@ export const ERROR_CODES = {
|
|
|
261
265
|
// ── claim / lease (409 / transport) ───────────────────────────────
|
|
262
266
|
claim_lease_unavailable: wire('claim', 503, true, 'The claim-lease coordination subsystem is temporarily unavailable, so the claim could not be processed. Retry shortly.'),
|
|
263
267
|
claim_not_wired: client('claim', 'Claims were used, but this runtime has no claim support wired in. The standard `Ablo({ schema, apiKey })` client wires it up automatically.'),
|
|
264
|
-
claim_queued: wire('claim', 409, true, 'The claim was queued behind the current lease holder and will be granted in turn.
|
|
268
|
+
claim_queued: wire('claim', 409, true, 'The claim was queued behind the current lease holder and will be granted in turn. Poll `claims.retrieve({ claimId })` for the grant — the id rides on the error — or read `claim.queue` to see the line.'),
|
|
265
269
|
claim_wait_aborted: wire('claim', 409, true, 'The wait for this claim lease was aborted before the lease was granted.'),
|
|
266
|
-
|
|
267
|
-
grant_timeout: wire('claim', 504, true, 'The wait for a capability grant timed out before one arrived. Retry the request.'),
|
|
270
|
+
grant_timeout: wire('claim', 504, true, 'The wait for the claim grant timed out before your turn arrived (`waitTimeoutMs`). Claim again to rejoin the line, raise the cap, or re-read and proceed without the claim.'),
|
|
268
271
|
// ── bootstrap (transport) ──────────────────────────────────────────
|
|
269
272
|
bootstrap_fetch_timeout: wire('bootstrap', 504, true, 'The initial bootstrap fetch timed out before the server responded. Retry shortly.'),
|
|
270
273
|
// Deliberate cancellation, never a failure of the network. It is what the
|
|
@@ -289,7 +292,7 @@ export const ERROR_CODES = {
|
|
|
289
292
|
commit_failed: wire('transport', 500, true, 'The commit reached the server but failed to apply. Retrying may succeed.'),
|
|
290
293
|
replication_lag_timeout: wire('transport', 504, true, "The data source accepted the write, but its correlated authoritative source delta did not arrive before the confirmation deadline. The write may still materialize; retry with the same idempotency key or wait for source ingestion to recover."),
|
|
291
294
|
commit_offline_grace_expired: wire('transport', 503, false, 'The offline grace window expired before this commit could be sent, so it was not applied. Re-apply the change once the connection returns.'),
|
|
292
|
-
queue_too_deep: wire('transport', 503, true, 'The
|
|
295
|
+
queue_too_deep: wire('transport', 503, true, 'The line is already past its depth limit. For a claim, more participants were waiting than your `maxQueueDepth` allows — claim again without the cap to wait anyway, or work elsewhere and retry later. For a write, the transaction queue is draining — retry shortly.'),
|
|
293
296
|
flush_timeout: wire('transport', 504, true, 'Flushing the transaction queue timed out before every pending write was sent. Retry once connectivity stabilizes.'),
|
|
294
297
|
wait_for_timeout: wire('transport', 504, true, 'A wait-for condition timed out before it was satisfied. Retry, or extend the timeout.'),
|
|
295
298
|
instance_at_capacity: wire('transport', 503, true, 'The server is at connection capacity. Retry shortly — transient and not specific to your credentials.'),
|
|
@@ -421,6 +424,7 @@ export const ERROR_CODES = {
|
|
|
421
424
|
request_too_large: wire('validation', 413, false, 'The request body exceeds the maximum size.'),
|
|
422
425
|
invalid_schema: wire('validation', 400, false, 'The submitted schema could not be parsed.'),
|
|
423
426
|
incompatible_change: wire('conflict', 409, false, 'The schema change is incompatible with the schema currently deployed and cannot be applied as-is.'),
|
|
427
|
+
replication_reset_required: wire('conflict', 409, false, 'A connected log plane has live rows for a mapped model that disappeared. Declare a rename or an explicit drop/reset before advancing the schema.'),
|
|
424
428
|
};
|
|
425
429
|
/** Looks up the {@link ErrorCodeSpec} for a code. Returns `undefined` for the
|
|
426
430
|
* dynamic `policy:*` family and for any newer code this client does not yet
|
|
@@ -18,6 +18,9 @@ import type { ErrorCodeSpec } from './errorCodes.js';
|
|
|
18
18
|
import type { Logger } from './logger.js';
|
|
19
19
|
import type { CoordinationObservability } from './observability.js';
|
|
20
20
|
import type { WsTransport } from './transport/wsTransport.js';
|
|
21
|
+
import type { AuthCredentialSource } from './auth/credentialSource.js';
|
|
22
|
+
import type { SyncDeltaWireCore } from './wire/delta.js';
|
|
23
|
+
import type { ModelData } from './types/modelData.js';
|
|
21
24
|
/**
|
|
22
25
|
* A string type that keeps literal inference alive: `id: 'humans'` stays the
|
|
23
26
|
* literal `'humans'` through object-literal inference instead of widening to
|
|
@@ -51,6 +54,59 @@ export type PipelineStage =
|
|
|
51
54
|
| 'notify';
|
|
52
55
|
/** Canonical order. A plugin runner must run stages in this sequence. */
|
|
53
56
|
export declare const PIPELINE_STAGES: readonly PipelineStage[];
|
|
57
|
+
/**
|
|
58
|
+
* A delta as stage handlers receive it — the identity slice of the wire
|
|
59
|
+
* shape, projected rather than restated so the wire schema stays the one
|
|
60
|
+
* definition.
|
|
61
|
+
*/
|
|
62
|
+
export type StageDelta = Pick<SyncDeltaWireCore, 'id' | 'actionType' | 'modelName' | 'modelId'>;
|
|
63
|
+
/**
|
|
64
|
+
* One persisted change: what the durable write reported and the apply stage
|
|
65
|
+
* lands in the in-memory graph. `data` is absent for actions that carry
|
|
66
|
+
* none (a remove reports identity only).
|
|
67
|
+
*/
|
|
68
|
+
export interface AppliedChange {
|
|
69
|
+
action: 'add' | 'update' | 'remove' | 'archive' | 'verify';
|
|
70
|
+
modelName: string;
|
|
71
|
+
modelId: string;
|
|
72
|
+
data?: ModelData | null;
|
|
73
|
+
/**
|
|
74
|
+
* Server-stamped transaction id, echoing the client's own commit
|
|
75
|
+
* operation id — how a client recognizes the confirmation of a change it
|
|
76
|
+
* already applied locally. Absent for system-emitted changes, which have
|
|
77
|
+
* no client transaction behind them.
|
|
78
|
+
*/
|
|
79
|
+
transactionId?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* What each stage hands its handlers. Read off the delta pipeline these
|
|
83
|
+
* stages were read off, not invented: `receive` sees the delta being
|
|
84
|
+
* queued, `dedupe` and `persist` see the batch at those boundaries,
|
|
85
|
+
* `apply` and `notify` see the persisted changes, and `acknowledge` sees
|
|
86
|
+
* the persistence-gated cursor value.
|
|
87
|
+
*/
|
|
88
|
+
export interface StagePayloads {
|
|
89
|
+
receive: {
|
|
90
|
+
readonly delta: StageDelta;
|
|
91
|
+
};
|
|
92
|
+
dedupe: {
|
|
93
|
+
readonly deltas: readonly StageDelta[];
|
|
94
|
+
};
|
|
95
|
+
persist: {
|
|
96
|
+
readonly deltas: readonly StageDelta[];
|
|
97
|
+
};
|
|
98
|
+
apply: {
|
|
99
|
+
readonly changes: readonly AppliedChange[];
|
|
100
|
+
};
|
|
101
|
+
acknowledge: {
|
|
102
|
+
readonly syncId: number;
|
|
103
|
+
};
|
|
104
|
+
notify: {
|
|
105
|
+
readonly changes: readonly AppliedChange[];
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** The handler a plugin attaches at one stage. The payload derives from the key. */
|
|
109
|
+
export type StageHandler<K extends PipelineStage> = (payload: StagePayloads[K]) => void;
|
|
54
110
|
/**
|
|
55
111
|
* What a capability needs from whatever transport was selected.
|
|
56
112
|
*
|
|
@@ -97,6 +153,28 @@ export interface PluginContext<Options = unknown> {
|
|
|
97
153
|
};
|
|
98
154
|
/** The connection's initial read scope (sync groups). */
|
|
99
155
|
readonly syncGroups?: readonly string[];
|
|
156
|
+
/** The resolved server base URL, settled by the host before construction. */
|
|
157
|
+
readonly url?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The client's single bearer-credential source, shared with every
|
|
160
|
+
* auth-aware transport the host built. A plugin that constructs its own
|
|
161
|
+
* fetching component hands this on rather than re-deriving a credential.
|
|
162
|
+
*/
|
|
163
|
+
readonly auth?: AuthCredentialSource;
|
|
164
|
+
/**
|
|
165
|
+
* Whether a plugin with the given id is installed on this client. Supplied
|
|
166
|
+
* by {@link resolvePlugins} from the list it is resolving — the one place
|
|
167
|
+
* that truthfully knows the assembly — so a plugin interrogates what is
|
|
168
|
+
* installed instead of the host special-casing plugins by name.
|
|
169
|
+
*/
|
|
170
|
+
readonly hasPlugin?: (id: string) => boolean;
|
|
171
|
+
/**
|
|
172
|
+
* The resolved plugin list itself, supplied by {@link resolvePlugins}
|
|
173
|
+
* alongside {@link hasPlugin} (which derives from it). A component a
|
|
174
|
+
* plugin constructs — the store and its delta pipeline — holds this list
|
|
175
|
+
* to dispatch the declared stage handlers through {@link runStage}.
|
|
176
|
+
*/
|
|
177
|
+
readonly plugins?: readonly AbloPlugin[];
|
|
100
178
|
}
|
|
101
179
|
/**
|
|
102
180
|
* One installed capability.
|
|
@@ -130,8 +208,16 @@ export interface AbloPlugin<Surface = unknown> {
|
|
|
130
208
|
* hole in the published reference.
|
|
131
209
|
*/
|
|
132
210
|
readonly errorCodes?: Readonly<Record<string, ErrorCodeSpec>>;
|
|
133
|
-
/**
|
|
134
|
-
|
|
211
|
+
/**
|
|
212
|
+
* The pipeline stages this plugin attaches to: each key names a stage and
|
|
213
|
+
* holds the handler the runner invokes there. One field on purpose — a
|
|
214
|
+
* declared stage cannot exist without its handler, nor a handler without
|
|
215
|
+
* its stage, so the declaration can never point at nothing. The payload
|
|
216
|
+
* type derives from the key ({@link StagePayloads}).
|
|
217
|
+
*/
|
|
218
|
+
readonly stages?: {
|
|
219
|
+
readonly [K in PipelineStage]?: StageHandler<K>;
|
|
220
|
+
};
|
|
135
221
|
/** Build the plugin's contribution to the client surface. */
|
|
136
222
|
init(context: PluginContext): Surface;
|
|
137
223
|
}
|
|
@@ -189,4 +275,11 @@ export declare function layerPluginSurface<T extends object>(base: T, installed:
|
|
|
189
275
|
* to choose and carries no correctness weight.
|
|
190
276
|
*/
|
|
191
277
|
export declare function pluginsForStage(plugins: readonly AbloPlugin[], stage: PipelineStage): readonly AbloPlugin[];
|
|
278
|
+
/**
|
|
279
|
+
* Invokes every handler declared for one stage, in declaration order, with
|
|
280
|
+
* that stage's payload. No handlers declared is a plain no-op, so a pipeline
|
|
281
|
+
* dispatches unconditionally at each boundary and pays nothing when the
|
|
282
|
+
* stage is unclaimed.
|
|
283
|
+
*/
|
|
284
|
+
export declare function runStage<K extends PipelineStage>(plugins: readonly AbloPlugin[], stage: K, payload: StagePayloads[K]): void;
|
|
192
285
|
export {};
|
|
@@ -33,6 +33,14 @@ export const PIPELINE_STAGES = [
|
|
|
33
33
|
export function resolvePlugins(plugins, transport, context) {
|
|
34
34
|
const surface = {};
|
|
35
35
|
const seen = new Set();
|
|
36
|
+
// The assembly, injected from the list being resolved. Always this list's
|
|
37
|
+
// own answer — a host-supplied `plugins` or `hasPlugin` could disagree
|
|
38
|
+
// with the assembly it describes, so both are replaced, not deferred to.
|
|
39
|
+
const initContext = {
|
|
40
|
+
...context,
|
|
41
|
+
plugins,
|
|
42
|
+
hasPlugin: (id) => plugins.some((plugin) => plugin.id === id),
|
|
43
|
+
};
|
|
36
44
|
for (const plugin of plugins) {
|
|
37
45
|
if (seen.has(plugin.id)) {
|
|
38
46
|
throw new AbloValidationError(`The ${plugin.id} plugin is listed twice. Remove the duplicate; a plugin is installed once per client.`, { code: 'invalid_options', param: 'plugins' });
|
|
@@ -41,7 +49,7 @@ export function resolvePlugins(plugins, transport, context) {
|
|
|
41
49
|
if (plugin.requires?.duplex && !transport.duplex) {
|
|
42
50
|
throw new AbloValidationError(`The ${plugin.id} plugin needs a connection the server can send on, and this client is set up for request-response only. Switch the transport to 'websocket', or drop the plugin.`, { code: 'invalid_options', param: 'plugins' });
|
|
43
51
|
}
|
|
44
|
-
surface[plugin.id] = plugin.init(
|
|
52
|
+
surface[plugin.id] = plugin.init(initContext);
|
|
45
53
|
}
|
|
46
54
|
return surface;
|
|
47
55
|
}
|
|
@@ -83,5 +91,16 @@ export function layerPluginSurface(base, installed) {
|
|
|
83
91
|
* to choose and carries no correctness weight.
|
|
84
92
|
*/
|
|
85
93
|
export function pluginsForStage(plugins, stage) {
|
|
86
|
-
return plugins.filter((plugin) => plugin.stage
|
|
94
|
+
return plugins.filter((plugin) => plugin.stages?.[stage] !== undefined);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Invokes every handler declared for one stage, in declaration order, with
|
|
98
|
+
* that stage's payload. No handlers declared is a plain no-op, so a pipeline
|
|
99
|
+
* dispatches unconditionally at each boundary and pays nothing when the
|
|
100
|
+
* stage is unclaimed.
|
|
101
|
+
*/
|
|
102
|
+
export function runStage(plugins, stage, payload) {
|
|
103
|
+
for (const plugin of plugins) {
|
|
104
|
+
plugin.stages?.[stage]?.(payload);
|
|
105
|
+
}
|
|
87
106
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* {@link HttpClaimApi} derivation. This module holds only types and has no runtime
|
|
5
5
|
* imports.
|
|
6
6
|
*/
|
|
7
|
-
import type { OnStaleMode, ReadDependency, TrackDependency } from '../coordination/schema.js';
|
|
7
|
+
import type { ClaimHeartbeatAckPayload, OnStaleMode, ReadDependency, TrackDependency } from '../coordination/schema.js';
|
|
8
|
+
import type { ClaimHeartbeatReply, ClaimState } from '../wire/claims.js';
|
|
8
9
|
import type { ClientCommitReceipt, CommitWait } from '../wire/commit.js';
|
|
9
10
|
export type { CommitWait };
|
|
10
11
|
import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
|
|
@@ -68,6 +69,9 @@ export interface ClaimCreateOptions {
|
|
|
68
69
|
readonly queue?: boolean;
|
|
69
70
|
/** Cap on how long to wait for a queued grant before rejecting. */
|
|
70
71
|
readonly waitTimeoutMs?: number;
|
|
72
|
+
/** Abort a pending wait from outside — rejects with `claim_wait_aborted`.
|
|
73
|
+
* Ignored once the grant has arrived. */
|
|
74
|
+
readonly signal?: AbortSignal;
|
|
71
75
|
/**
|
|
72
76
|
* Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of
|
|
73
77
|
* waiting if the queue is already `>= maxQueueDepth` when we join.
|
|
@@ -131,6 +135,55 @@ export interface ClaimResource extends ClaimStream {
|
|
|
131
135
|
list(target?: Partial<ModelTarget>): readonly ModelClaim[];
|
|
132
136
|
waitFor(target: Partial<ModelTarget>, options?: ClaimWaitOptions): Promise<void>;
|
|
133
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The claim-ticket surface of the stateless HTTP client — the operations a
|
|
140
|
+
* caller performs holding only a `claimId`, which is what a queued acquire
|
|
141
|
+
* leaves in its hand (`AbloClaimedError('claim_queued')` carries it on
|
|
142
|
+
* `error.claims`). The WebSocket client never needs these: it awaits the grant
|
|
143
|
+
* on its socket.
|
|
144
|
+
*
|
|
145
|
+
* All three shapes are the wire's own — `claimStateSchema`,
|
|
146
|
+
* `claimHeartbeatReplySchema`, and the batch ack — so this surface cannot
|
|
147
|
+
* describe a response the server does not send.
|
|
148
|
+
*/
|
|
149
|
+
export interface HttpClaimsResource {
|
|
150
|
+
/**
|
|
151
|
+
* The claim's current state, by its id — `GET /v1/claims/{claimId}`. The
|
|
152
|
+
* poll half of the queued-grant handover: a queued caller polls until
|
|
153
|
+
* `status` is `'active'`, at which point `fenceToken` is present and the
|
|
154
|
+
* work can begin. `position` is advisory (a privileged caller can reorder
|
|
155
|
+
* the line, so it may go up); only `status` is authoritative.
|
|
156
|
+
*/
|
|
157
|
+
retrieve(params: {
|
|
158
|
+
readonly claimId: string;
|
|
159
|
+
}): Promise<ClaimState>;
|
|
160
|
+
/**
|
|
161
|
+
* One beat on the named lease — `POST /v1/claims/{claimId}/heartbeat`. On a
|
|
162
|
+
* held lease it extends the TTL; on a queued ticket it refreshes the
|
|
163
|
+
* waiter's slot in the line and reports `{ status: 'queued', position }`.
|
|
164
|
+
*/
|
|
165
|
+
heartbeat(params: {
|
|
166
|
+
readonly claimId: string;
|
|
167
|
+
readonly ttl?: string | number;
|
|
168
|
+
}): Promise<ClaimHeartbeatReply>;
|
|
169
|
+
/**
|
|
170
|
+
* One beat for every lease this identity holds — `POST /v1/claims/heartbeat`,
|
|
171
|
+
* one ack per extended lease. The socketless twin of the realtime
|
|
172
|
+
* keepalive, for a stateless worker holding many rows.
|
|
173
|
+
*/
|
|
174
|
+
heartbeatAll(options?: {
|
|
175
|
+
readonly ttl?: string | number;
|
|
176
|
+
}): Promise<readonly ClaimHeartbeatAckPayload[]>;
|
|
177
|
+
/**
|
|
178
|
+
* Give the ticket back — `DELETE /v1/claims/{claimId}`. On a held lease this
|
|
179
|
+
* releases it and promotes the head of the queue; on a queued ticket it
|
|
180
|
+
* leaves the line, so the waiters behind move up instead of waiting out
|
|
181
|
+
* your slot's TTL. Idempotent: releasing an already-ended claim is a no-op.
|
|
182
|
+
*/
|
|
183
|
+
release(params: {
|
|
184
|
+
readonly claimId: string;
|
|
185
|
+
}): Promise<void>;
|
|
186
|
+
}
|
|
134
187
|
export interface ModelMutationOptions extends ClaimedOptions {
|
|
135
188
|
readonly claimRef?: string | {
|
|
136
189
|
readonly id: string;
|
|
@@ -159,7 +212,9 @@ export interface ModelMutationOptions extends ClaimedOptions {
|
|
|
159
212
|
* statelessness forces. `claim({ id })` is identical on both (already async);
|
|
160
213
|
* `state`, `queue`, `reorder`, and `release` are the awaited form.
|
|
161
214
|
*/
|
|
162
|
-
export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>
|
|
215
|
+
export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T> & {
|
|
216
|
+
queue: false;
|
|
217
|
+
}) => Promise<HeldClaim<T> | null>) & ((params: ClaimParams<T>) => Promise<HeldClaim<T>>) & {
|
|
163
218
|
[K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
|
|
164
219
|
};
|
|
165
220
|
/** A single data operation a scoped **agent** session may perform on a model.
|