@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
|
@@ -52,6 +52,10 @@ export interface ModelCollaboration {
|
|
|
52
52
|
queue?: boolean;
|
|
53
53
|
/** Reject (don't wait) if the queue is already this deep when we join. */
|
|
54
54
|
maxQueueDepth?: number;
|
|
55
|
+
/** Cap on the queued wait before rejecting with `grant_timeout`. */
|
|
56
|
+
waitTimeoutMs?: number;
|
|
57
|
+
/** Abort the queued wait — rejects with `claim_wait_aborted`. */
|
|
58
|
+
signal?: AbortSignal;
|
|
55
59
|
}): Promise<Claim>;
|
|
56
60
|
createSnapshot(modelKey: string, id: string): Snapshot;
|
|
57
61
|
/**
|
|
@@ -67,6 +71,12 @@ export interface ModelCollaboration {
|
|
|
67
71
|
* target because it isn't bound to a single model.
|
|
68
72
|
*/
|
|
69
73
|
state(target: EntityHalf): Claim | null;
|
|
74
|
+
/**
|
|
75
|
+
* Every active claim on a target, not just one. Sub-row claims on disjoint
|
|
76
|
+
* parts of a row are all granted, so a row can have several holders at once
|
|
77
|
+
* and `state` answers with only the first of them.
|
|
78
|
+
*/
|
|
79
|
+
holders(target: EntityHalf): readonly Claim[];
|
|
70
80
|
/**
|
|
71
81
|
* The reactive wait queue on a target — the FIFO line of queued claims
|
|
72
82
|
* behind the holder. Synchronous snapshot off the synced claim stream.
|
|
@@ -223,6 +233,16 @@ interface ReactiveModelSurface<T> {
|
|
|
223
233
|
* See {@link ReactiveModelSurface}.
|
|
224
234
|
*/
|
|
225
235
|
export type ModelOperations<T, CreateInput> = Omit<HttpModelClient<T, CreateInput>, 'claim'> & ReactiveModelSurface<T>;
|
|
226
|
-
export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: InstanceCache, syncClient: SyncClient, registry: ModelRegistry,
|
|
236
|
+
export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: InstanceCache, syncClient: SyncClient, registry: ModelRegistry,
|
|
237
|
+
/**
|
|
238
|
+
* The one thing this factory asks of the loader: fetch rows for a model.
|
|
239
|
+
*
|
|
240
|
+
* Declared as the slice rather than the whole `OnDemandLoader` because the
|
|
241
|
+
* whole is a class, and a parameter typed as a class can only ever be
|
|
242
|
+
* satisfied by an instance of it — so every caller that has a narrower
|
|
243
|
+
* collaborator, a test most of all, is pushed into a cast through `unknown`
|
|
244
|
+
* to supply the one method that is actually read.
|
|
245
|
+
*/
|
|
246
|
+
hydration: Pick<OnDemandLoader, 'fetch'>, collaboration?: ModelCollaboration,
|
|
227
247
|
/** The client-wide `wait` default; a per-call `wait` still wins over it. */
|
|
228
248
|
defaultWait?: 'queued' | 'confirmed'): ModelOperations<T, C>;
|
|
@@ -17,9 +17,14 @@ import { claimDescription, } from '../transaction/coordination/schema.js';
|
|
|
17
17
|
import { Model, modelAsRow } from '../Model.js';
|
|
18
18
|
import { toMs } from '../transaction/utils/duration.js';
|
|
19
19
|
import { LEASE_TTL_MS } from '../transaction/wire/protocol.js';
|
|
20
|
-
import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from '../transaction/coordination/claimHeartbeatLoop.js';
|
|
20
|
+
import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '../transaction/coordination/claimHeartbeatLoop.js';
|
|
21
21
|
import { assertWriteOptions } from '../transaction/resources/writeOptionsSchema.js';
|
|
22
22
|
import { modelTarget, subTarget } from '../transaction/coordination/index.js';
|
|
23
|
+
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
24
|
+
// .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
|
|
25
|
+
// decodes that build a public claim, so their `meta` converts wire→declared
|
|
26
|
+
// here like the other enumerated crossings.
|
|
27
|
+
import { declaredMeta } from '../transaction/coordination/claimMeta.js';
|
|
23
28
|
import { ModelScope } from '../transaction/types/index.js';
|
|
24
29
|
const modelClientMeta = new WeakMap();
|
|
25
30
|
export function getModelClientMeta(modelClient) {
|
|
@@ -27,7 +32,17 @@ export function getModelClientMeta(modelClient) {
|
|
|
27
32
|
return undefined;
|
|
28
33
|
return modelClientMeta.get(modelClient);
|
|
29
34
|
}
|
|
30
|
-
export function createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, registry,
|
|
35
|
+
export function createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, registry,
|
|
36
|
+
/**
|
|
37
|
+
* The one thing this factory asks of the loader: fetch rows for a model.
|
|
38
|
+
*
|
|
39
|
+
* Declared as the slice rather than the whole `OnDemandLoader` because the
|
|
40
|
+
* whole is a class, and a parameter typed as a class can only ever be
|
|
41
|
+
* satisfied by an instance of it — so every caller that has a narrower
|
|
42
|
+
* collaborator, a test most of all, is pushed into a cast through `unknown`
|
|
43
|
+
* to supply the one method that is actually read.
|
|
44
|
+
*/
|
|
45
|
+
hydration, collaboration,
|
|
31
46
|
/** The client-wide `wait` default; a per-call `wait` still wins over it. */
|
|
32
47
|
defaultWait) {
|
|
33
48
|
/**
|
|
@@ -165,20 +180,15 @@ defaultWait) {
|
|
|
165
180
|
const held = collaboration.state({ model: wireModel, id });
|
|
166
181
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
167
182
|
const failFast = options.queue === false;
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
183
|
+
// The try-claim (`queue: false`): a held target is an expected outcome,
|
|
184
|
+
// not an error, so it resolves `null` — the caller reads `if (!claim)`
|
|
185
|
+
// and moves on; who holds it stays readable via `claim.state`. Best-effort
|
|
186
|
+
// at the client (a racing claim not yet synced into our snapshot slips
|
|
187
|
+
// through here) — the commit-time claim guard is the authoritative
|
|
188
|
+
// backstop that rejects the loser's first write. For work-distribution
|
|
189
|
+
// dedup that's exactly right: don't wait (that would double-process), skip.
|
|
174
190
|
if (failFast && contended) {
|
|
175
|
-
|
|
176
|
-
throw new AbloClaimedError(formatClaimedErrorMessage({
|
|
177
|
-
targetLabel: `${registeredModelName}/${id}`,
|
|
178
|
-
heldBy: held.heldBy,
|
|
179
|
-
claim,
|
|
180
|
-
fallback: `${registeredModelName}/${id} is held by ${held.heldBy ?? 'another participant'}.`,
|
|
181
|
-
}), { code: 'entity_claimed', claims: [claim] });
|
|
191
|
+
return null;
|
|
182
192
|
}
|
|
183
193
|
// Ensure the row exists locally before claiming.
|
|
184
194
|
let model = ownRowOrThrow(id);
|
|
@@ -215,6 +225,10 @@ defaultWait) {
|
|
|
215
225
|
ttl: options.ttl,
|
|
216
226
|
queue: !failFast,
|
|
217
227
|
maxQueueDepth: options.maxQueueDepth,
|
|
228
|
+
// The one wait cap, declared once on ClaimTargetOptions — the socket
|
|
229
|
+
// wait and the HTTP poll-wait both honor it as `grant_timeout`.
|
|
230
|
+
waitTimeoutMs: options.waitTimeoutMs,
|
|
231
|
+
signal: options.signal,
|
|
218
232
|
});
|
|
219
233
|
// Only when the claim actually waited behind another holder can the row have
|
|
220
234
|
// changed underneath us — re-read so the claimed snapshot reflects what that
|
|
@@ -238,10 +252,15 @@ defaultWait) {
|
|
|
238
252
|
// The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
|
|
239
253
|
// report (`state` maps `held.target.model` to `type`), so a holder and a
|
|
240
254
|
// peer see the same `target.type` for one row — the wire model token.
|
|
255
|
+
// Its `meta` is the DECLARED shape (the handle is a public claim), so the
|
|
256
|
+
// wire-shaped projection converts back through `declaredMeta` — the same
|
|
257
|
+
// crossing the HTTP handle assembly makes.
|
|
258
|
+
const { meta: selfMeta, ...selfNarrowed } = subTarget(options);
|
|
241
259
|
const selfTarget = {
|
|
242
260
|
type: wireModel,
|
|
243
261
|
id,
|
|
244
|
-
...
|
|
262
|
+
...selfNarrowed,
|
|
263
|
+
...(selfMeta !== undefined ? { meta: declaredMeta(selfMeta) } : {}),
|
|
245
264
|
};
|
|
246
265
|
const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
|
|
247
266
|
const expiresAt = Date.now() + ttlMs;
|
|
@@ -252,14 +271,20 @@ defaultWait) {
|
|
|
252
271
|
description,
|
|
253
272
|
expiresAt,
|
|
254
273
|
});
|
|
274
|
+
const { meta: targetMeta, ...targetNarrowed } = subTarget(options);
|
|
255
275
|
const target = {
|
|
256
276
|
type: schemaKey,
|
|
257
277
|
id,
|
|
258
|
-
...
|
|
278
|
+
...targetNarrowed,
|
|
279
|
+
...(targetMeta !== undefined ? { meta: declaredMeta(targetMeta) } : {}),
|
|
259
280
|
};
|
|
281
|
+
// One reading of the heartbeat options — cadence and callbacks from
|
|
282
|
+
// whichever spelling the caller used (plan object, shorthand, or the
|
|
283
|
+
// deprecated flat callbacks).
|
|
284
|
+
const plan = resolveHeartbeatPlan(options);
|
|
260
285
|
// A beat resolves with the server's extended expiry; keep the local
|
|
261
286
|
// self-claim estimate in step so `claim.state` renders the real window,
|
|
262
|
-
// and surface every answer through `
|
|
287
|
+
// and surface every answer through the plan's `onBeat` (pressure signal).
|
|
263
288
|
const heartbeat = async (beatOptions) => {
|
|
264
289
|
if (!lease.heartbeat) {
|
|
265
290
|
throw new AbloValidationError('This claim handle has no heartbeat wiring, which the standard Ablo({ schema, apiKey }) client provides on every claim. This appears only when a claim is minted through an internal path that predates heartbeats.', { code: 'claim_not_wired' });
|
|
@@ -272,18 +297,16 @@ defaultWait) {
|
|
|
272
297
|
const held = activeClaims.get(id);
|
|
273
298
|
if (held)
|
|
274
299
|
held.expiresAt = beat.expiresAt;
|
|
275
|
-
|
|
300
|
+
plan.onBeat?.(beat);
|
|
276
301
|
return beat;
|
|
277
302
|
};
|
|
278
303
|
// Opt-in auto-heartbeat: the loop beats until release, and a definitive
|
|
279
|
-
// loss stops it and surfaces through `
|
|
280
|
-
const stopHeartbeatLoop =
|
|
304
|
+
// loss stops it and surfaces through the plan's `onLost`.
|
|
305
|
+
const stopHeartbeatLoop = plan.loop
|
|
281
306
|
? startClaimHeartbeatLoop({
|
|
282
307
|
beat: () => heartbeat(),
|
|
283
|
-
intervalMs: heartbeatCadenceMs(ttlMs,
|
|
284
|
-
...(
|
|
285
|
-
? { onLost: options.onHeartbeatLost }
|
|
286
|
-
: {}),
|
|
308
|
+
intervalMs: heartbeatCadenceMs(ttlMs, plan.cadence),
|
|
309
|
+
...(plan.onLost ? { onLost: plan.onLost } : {}),
|
|
287
310
|
})
|
|
288
311
|
: undefined;
|
|
289
312
|
const release = () => {
|
|
@@ -325,19 +348,14 @@ defaultWait) {
|
|
|
325
348
|
const held = collaboration.state({ model: wireModel, id });
|
|
326
349
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
327
350
|
const failFast = options.queue === false;
|
|
328
|
-
//
|
|
329
|
-
// Best-effort at the client —
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
351
|
+
// The try-claim (`queue: false`): resolve `null` if a holder is already
|
|
352
|
+
// visible — an expected outcome, not an error. Best-effort at the client —
|
|
353
|
+
// a row this participant never synced usually carries no local claim state
|
|
354
|
+
// either, so a peer gets the deterministic `null` only once it has
|
|
355
|
+
// observed the holder (entered the row's entity scope). The server's
|
|
356
|
+
// queue is the backstop for the queuing path.
|
|
333
357
|
if (failFast && contended) {
|
|
334
|
-
|
|
335
|
-
throw new AbloClaimedError(formatClaimedErrorMessage({
|
|
336
|
-
targetLabel: `${registeredModelName}/${id}`,
|
|
337
|
-
heldBy: held.heldBy,
|
|
338
|
-
claim,
|
|
339
|
-
fallback: `${registeredModelName}/${id} is held by ${held.heldBy ?? 'another participant'}.`,
|
|
340
|
-
}), { code: 'entity_claimed', claims: [claim] });
|
|
358
|
+
return null;
|
|
341
359
|
}
|
|
342
360
|
// Enter the entity scope before acquiring the lease so the holder's claim
|
|
343
361
|
// presence broadcasts to everyone in this entity group — the same ordering
|
|
@@ -358,6 +376,10 @@ defaultWait) {
|
|
|
358
376
|
ttl: options.ttl,
|
|
359
377
|
queue: !failFast,
|
|
360
378
|
maxQueueDepth: options.maxQueueDepth,
|
|
379
|
+
// The one wait cap, declared once on ClaimTargetOptions — the socket
|
|
380
|
+
// wait and the HTTP poll-wait both honor it as `grant_timeout`.
|
|
381
|
+
waitTimeoutMs: options.waitTimeoutMs,
|
|
382
|
+
signal: options.signal,
|
|
361
383
|
});
|
|
362
384
|
// A watermark-only snapshot: `createSnapshot` still reads the engine's
|
|
363
385
|
// current `lastSyncId` even though the pool holds no row (the bucket is
|
|
@@ -384,6 +406,9 @@ defaultWait) {
|
|
|
384
406
|
id,
|
|
385
407
|
...subTarget(options),
|
|
386
408
|
};
|
|
409
|
+
// One reading of the heartbeat options — cadence and callbacks from
|
|
410
|
+
// whichever spelling the caller used.
|
|
411
|
+
const plan = resolveHeartbeatPlan(options);
|
|
387
412
|
// A beat resolves with the server's extended expiry; keep the local
|
|
388
413
|
// self-claim estimate in step so `claim.state` renders the real window.
|
|
389
414
|
const heartbeat = async (beatOptions) => {
|
|
@@ -398,16 +423,14 @@ defaultWait) {
|
|
|
398
423
|
const held = activeClaims.get(id);
|
|
399
424
|
if (held)
|
|
400
425
|
held.expiresAt = beat.expiresAt;
|
|
401
|
-
|
|
426
|
+
plan.onBeat?.(beat);
|
|
402
427
|
return beat;
|
|
403
428
|
};
|
|
404
|
-
const stopHeartbeatLoop =
|
|
429
|
+
const stopHeartbeatLoop = plan.loop
|
|
405
430
|
? startClaimHeartbeatLoop({
|
|
406
431
|
beat: () => heartbeat(),
|
|
407
|
-
intervalMs: heartbeatCadenceMs(ttlMs,
|
|
408
|
-
...(
|
|
409
|
-
? { onLost: options.onHeartbeatLost }
|
|
410
|
-
: {}),
|
|
432
|
+
intervalMs: heartbeatCadenceMs(ttlMs, plan.cadence),
|
|
433
|
+
...(plan.onLost ? { onLost: plan.onLost } : {}),
|
|
411
434
|
})
|
|
412
435
|
: undefined;
|
|
413
436
|
const release = () => {
|
|
@@ -455,6 +478,33 @@ defaultWait) {
|
|
|
455
478
|
// JSON into an interface. These read as `Claim` here, and the one assertion
|
|
456
479
|
// that applies the caller's parameter is on the assignment below, in one
|
|
457
480
|
// place rather than at every call site.
|
|
481
|
+
/**
|
|
482
|
+
* This client's own claim on a row, as a claim-state object.
|
|
483
|
+
*
|
|
484
|
+
* The server excludes a holder's own presence frames and the client skips
|
|
485
|
+
* them, so a row this client holds is absent from every peer-derived read.
|
|
486
|
+
* Both `state` and `list` therefore synthesize it from the stored lease, and
|
|
487
|
+
* they do it through here so the two answers cannot describe the same
|
|
488
|
+
* holding differently.
|
|
489
|
+
*/
|
|
490
|
+
const ownClaimState = (id) => {
|
|
491
|
+
const own = activeClaims.get(id);
|
|
492
|
+
if (!own)
|
|
493
|
+
return null;
|
|
494
|
+
return {
|
|
495
|
+
object: 'claim',
|
|
496
|
+
id: own.lease.id,
|
|
497
|
+
status: 'active',
|
|
498
|
+
target: own.target,
|
|
499
|
+
description: own.description,
|
|
500
|
+
heldBy: collaboration?.selfParticipantId ?? '',
|
|
501
|
+
participantKind: collaboration?.selfParticipantKind ?? 'user',
|
|
502
|
+
expiresAt: own.expiresAt,
|
|
503
|
+
// Symmetric with the peer projection: a holder reading its own claim
|
|
504
|
+
// sees the same `meta` an observer does.
|
|
505
|
+
...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
|
|
506
|
+
};
|
|
507
|
+
};
|
|
458
508
|
const claimReaders = {
|
|
459
509
|
state(params) {
|
|
460
510
|
// Read interest: a passive observer of a row's claim state must enter that
|
|
@@ -466,20 +516,27 @@ defaultWait) {
|
|
|
466
516
|
// the client skips them, so `state` would return null for a row this client
|
|
467
517
|
// holds. Synthesize the active claim from the stored lease so the holder
|
|
468
518
|
// sees its own claim, honoring the documented contract on `claim.state`.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
519
|
+
return (ownClaimState(params.id) ??
|
|
520
|
+
collaboration?.state({ model: wireModel, id: params.id }) ??
|
|
521
|
+
null);
|
|
522
|
+
},
|
|
523
|
+
/**
|
|
524
|
+
* Every claim on the row, holders first. Sub-row claims on disjoint parts
|
|
525
|
+
* are all granted, so a row can have several holders at once and
|
|
526
|
+
* {@link state} answers with one of them — this is the read that renders
|
|
527
|
+
* all of them. Same list envelope as {@link queue}, reactive on the same
|
|
528
|
+
* snapshot, so a render reads it inline.
|
|
529
|
+
*/
|
|
530
|
+
list(params) {
|
|
531
|
+
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
532
|
+
const own = ownClaimState(params.id);
|
|
533
|
+
const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
|
|
534
|
+
return {
|
|
535
|
+
object: 'list',
|
|
536
|
+
// Own claim first: the server excludes a holder's own presence frames,
|
|
537
|
+
// so it is never among `peers` and the two never duplicate.
|
|
538
|
+
data: own ? [own, ...peers] : [...peers],
|
|
539
|
+
};
|
|
483
540
|
},
|
|
484
541
|
queue(params) {
|
|
485
542
|
return {
|
|
@@ -669,6 +726,11 @@ defaultWait) {
|
|
|
669
726
|
const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
670
727
|
if (autoClaim) {
|
|
671
728
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
729
|
+
// A declined try-claim is `null` only on the standalone verb; a
|
|
730
|
+
// write that could not take its claim is a failed write.
|
|
731
|
+
if (!handle) {
|
|
732
|
+
throw new AbloClaimedError(`${registeredModelName}/${params.id} is held by another participant, so this update's claim could not be taken.`, { code: 'entity_claimed' });
|
|
733
|
+
}
|
|
672
734
|
try {
|
|
673
735
|
return await operations.update({ ...params, claim: handle });
|
|
674
736
|
}
|
|
@@ -728,6 +790,10 @@ defaultWait) {
|
|
|
728
790
|
const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
729
791
|
if (autoClaim) {
|
|
730
792
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
793
|
+
// Same rule as update: a write that could not take its claim fails.
|
|
794
|
+
if (!handle) {
|
|
795
|
+
throw new AbloClaimedError(`${registeredModelName}/${params.id} is held by another participant, so this delete's claim could not be taken.`, { code: 'entity_claimed' });
|
|
796
|
+
}
|
|
731
797
|
try {
|
|
732
798
|
await operations.delete({ ...params, claim: handle });
|
|
733
799
|
}
|
package/dist/client/humans.d.ts
CHANGED
|
@@ -10,19 +10,29 @@
|
|
|
10
10
|
* Transitional shape: the contract fields do real configuration work
|
|
11
11
|
* (`requires.duplex` rejects it on a request-response client at construction
|
|
12
12
|
* time; `materialises` is the five-second membership test as a checkable
|
|
13
|
-
* property), and `init` constructs what the widened context
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* property), and `init` constructs what the widened context carries enough
|
|
14
|
+
* to build — the presence stream, attached to the connection the context
|
|
15
|
+
* carries, and the store cluster (this client's runtime, the component
|
|
16
|
+
* graph, the registered models, and the `BaseSyncedStore`), now that the
|
|
17
|
+
* context carries the resolved url and the credential source. What still
|
|
18
|
+
* lives in the composition root — the credential lifecycle, `ready()`, the
|
|
19
|
+
* model proxies, the resources, the framework bindings — migrates here as
|
|
20
|
+
* the context grows to carry what it needs.
|
|
19
21
|
*/
|
|
20
|
-
import type { PluginContext } from '../transaction/plugin.js';
|
|
22
|
+
import type { PluginContext, AppliedChange } from '../transaction/plugin.js';
|
|
23
|
+
import type { SchemaRecord } from '../transaction/schema/schema.js';
|
|
21
24
|
import { type AttachablePresenceStream } from '../sync/createPresenceStream.js';
|
|
25
|
+
import { kStoreCluster, type StoreCluster } from './storeCluster.js';
|
|
26
|
+
import type { InternalAbloOptions } from './options.js';
|
|
22
27
|
/**
|
|
23
28
|
* What `humans()` contributes to the client — exactly the members that merge
|
|
24
29
|
* onto it, nothing else. Whether a plugin materialises is declared on the
|
|
25
30
|
* plugin itself, never restated on its surface.
|
|
31
|
+
*
|
|
32
|
+
* The one exception is deliberate and invisible to merging:
|
|
33
|
+
* {@link kStoreCluster} is symbol-keyed, and surface members merge onto the
|
|
34
|
+
* client by string key alone — so the cluster reaches the host without ever
|
|
35
|
+
* becoming client API.
|
|
26
36
|
*/
|
|
27
37
|
export interface HumansSurface {
|
|
28
38
|
/**
|
|
@@ -31,6 +41,13 @@ export interface HumansSurface {
|
|
|
31
41
|
* the wire send, and the stream re-announces on every connect.
|
|
32
42
|
*/
|
|
33
43
|
readonly presence: AttachablePresenceStream;
|
|
44
|
+
/**
|
|
45
|
+
* The constructed store cluster, when the context carried enough to build
|
|
46
|
+
* it (the connection, the resolved url, the credential source, and a
|
|
47
|
+
* schema). Absent on a thinner context — the presence stream's own
|
|
48
|
+
* tolerance, applied to the store.
|
|
49
|
+
*/
|
|
50
|
+
readonly [kStoreCluster]?: StoreCluster;
|
|
34
51
|
}
|
|
35
52
|
/**
|
|
36
53
|
* The reactive materialiser. Installed by default when `Ablo({ ... })` is
|
|
@@ -43,6 +60,10 @@ export declare function humans(): {
|
|
|
43
60
|
readonly duplex: true;
|
|
44
61
|
};
|
|
45
62
|
readonly materialises: true;
|
|
46
|
-
readonly
|
|
47
|
-
|
|
63
|
+
readonly stages: {
|
|
64
|
+
readonly apply: ({ changes }: {
|
|
65
|
+
readonly changes: readonly AppliedChange[];
|
|
66
|
+
}) => void;
|
|
67
|
+
};
|
|
68
|
+
readonly init: (context: PluginContext<InternalAbloOptions<SchemaRecord>>) => HumansSurface;
|
|
48
69
|
};
|
package/dist/client/humans.js
CHANGED
|
@@ -10,20 +10,28 @@
|
|
|
10
10
|
* Transitional shape: the contract fields do real configuration work
|
|
11
11
|
* (`requires.duplex` rejects it on a request-response client at construction
|
|
12
12
|
* time; `materialises` is the five-second membership test as a checkable
|
|
13
|
-
* property), and `init` constructs what the widened context
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* property), and `init` constructs what the widened context carries enough
|
|
14
|
+
* to build — the presence stream, attached to the connection the context
|
|
15
|
+
* carries, and the store cluster (this client's runtime, the component
|
|
16
|
+
* graph, the registered models, and the `BaseSyncedStore`), now that the
|
|
17
|
+
* context carries the resolved url and the credential source. What still
|
|
18
|
+
* lives in the composition root — the credential lifecycle, `ready()`, the
|
|
19
|
+
* model proxies, the resources, the framework bindings — migrates here as
|
|
20
|
+
* the context grows to carry what it needs.
|
|
19
21
|
*/
|
|
22
|
+
import { AbloValidationError } from '../transaction/errors.js';
|
|
20
23
|
import { createPresenceStream, } from '../sync/createPresenceStream.js';
|
|
24
|
+
import { buildStoreCluster, kStoreCluster, } from './storeCluster.js';
|
|
21
25
|
/**
|
|
22
26
|
* The reactive materialiser. Installed by default when `Ablo({ ... })` is
|
|
23
27
|
* constructed over the socket with no `plugins` list; listed explicitly, it
|
|
24
28
|
* reads as what it is — this client renders for people.
|
|
25
29
|
*/
|
|
26
30
|
export function humans() {
|
|
31
|
+
// The apply handler's target. `init` binds it to the store it constructs;
|
|
32
|
+
// until then the handler is a no-op, and nothing dispatches before then —
|
|
33
|
+
// the store itself is the pipeline that dispatches.
|
|
34
|
+
let applyChanges = null;
|
|
27
35
|
return {
|
|
28
36
|
id: 'humans',
|
|
29
37
|
// Live queries, presence, and claim push are server-initiated frames, so
|
|
@@ -33,19 +41,37 @@ export function humans() {
|
|
|
33
41
|
// The point of the plugin: it keeps a local copy of rows. A stateless
|
|
34
42
|
// caller can assert its own list is uniformly false.
|
|
35
43
|
materialises: true,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
stages: {
|
|
45
|
+
// Deltas land in the materialised in-memory graph at `apply` — the
|
|
46
|
+
// declaration and the handler are one field, so this cannot name a
|
|
47
|
+
// stage and forget the work.
|
|
48
|
+
apply: ({ changes }) => { applyChanges?.(changes); },
|
|
49
|
+
},
|
|
50
|
+
init: (context) => {
|
|
51
|
+
const cluster = buildStoreCluster(context);
|
|
52
|
+
if (cluster) {
|
|
53
|
+
// One client per humans() instance: the apply handler routes into
|
|
54
|
+
// the store this init built, so a shared instance would silently
|
|
55
|
+
// apply one client's deltas into another client's store.
|
|
56
|
+
if (applyChanges) {
|
|
57
|
+
throw new AbloValidationError('This humans() instance is already installed on a client. ' +
|
|
58
|
+
'Construct a fresh humans() for each Ablo({ ... }) call.', { code: 'invalid_options', param: 'plugins' });
|
|
59
|
+
}
|
|
60
|
+
applyChanges = (changes) => { cluster.store.applyChangesToPool(changes); };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
presence: createPresenceStream({
|
|
64
|
+
participantId: context.participant?.id ?? '',
|
|
65
|
+
syncGroups: [...(context.syncGroups ?? [])],
|
|
66
|
+
// Peers' kinds are server-stamped; only the local `self` entry is ours.
|
|
67
|
+
isAgent: context.participant?.kind === 'agent',
|
|
68
|
+
},
|
|
69
|
+
// The host built the connection before resolving the plugin list, so
|
|
70
|
+
// the stream attaches now and starts the moment the feed opens.
|
|
71
|
+
context.transport ?? null),
|
|
72
|
+
...(cluster ? { [kStoreCluster]: cluster } : {}),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
49
75
|
// `satisfies` rather than a return annotation, so `id` keeps its literal
|
|
50
76
|
// type and a plugin list's surface can be keyed by id at the type level.
|
|
51
77
|
};
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The reactive engine
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* commit/claim/session resources —
|
|
2
|
+
* The reactive engine assembly (ADR 0016). `Ablo({ ... })` resolves auth and
|
|
3
|
+
* capabilities; `humans().init` constructs the store cluster; the lifecycle
|
|
4
|
+
* — first mint, identity, ready() — lives in `./storeLifecycle.ts`. What
|
|
5
|
+
* remains here is assembly around those parts: the claim stream and
|
|
6
|
+
* participant manager, options validation, the typed model proxies, and the
|
|
7
|
+
* commit/claim/session resources — composed into the reactive client.
|
|
8
8
|
*
|
|
9
9
|
* Extracted from the factory so the composition root stays a root: resolve,
|
|
10
|
-
* dispatch, return. The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* dispatch, return. The remaining assembly converts to decoration of a
|
|
11
|
+
* host-built core client with the per-model surface split — the cut's own
|
|
12
|
+
* design step (docs/plans/package-split.md).
|
|
13
13
|
*/
|
|
14
14
|
import type { SchemaRecord } from '../transaction/schema/schema.js';
|
|
15
|
-
import type {
|
|
15
|
+
import type { StoreCluster } from './storeCluster.js';
|
|
16
16
|
import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
|
|
17
17
|
import type { AttachablePresenceStream } from '../sync/createPresenceStream.js';
|
|
18
18
|
import type { AbloOptions } from './options.js';
|
|
@@ -28,7 +28,6 @@ import type { Ablo } from './Ablo.js';
|
|
|
28
28
|
*/
|
|
29
29
|
export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrelude<S> {
|
|
30
30
|
options: AbloOptions<S>;
|
|
31
|
-
executor: MutationExecutor;
|
|
32
31
|
/**
|
|
33
32
|
* The connection, constructed by the factory before the plugin list
|
|
34
33
|
* resolved — the same instance `PluginContext.transport` carries. The
|
|
@@ -38,6 +37,12 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
38
37
|
/** The humans() plugin's contribution — built by its `init`, already
|
|
39
38
|
* attached to the connection the context carried. */
|
|
40
39
|
presence: AttachablePresenceStream;
|
|
40
|
+
/**
|
|
41
|
+
* The store cluster `humans().init` constructed from the widened context:
|
|
42
|
+
* this client's runtime, the component graph, and the store. The engine
|
|
43
|
+
* assembles around it and constructs none of it.
|
|
44
|
+
*/
|
|
45
|
+
cluster: StoreCluster;
|
|
41
46
|
/**
|
|
42
47
|
* Constructs a sibling client (`ablo.agents.create(...)` mints a scoped key
|
|
43
48
|
* and builds a second engine with it). Injected by the factory — a direct
|