@abloatai/ablo 0.29.3 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +377 -84
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
@@ -12,7 +12,6 @@
12
12
  */
13
13
  import { autorun } from 'mobx';
14
14
  import { AbloClaimedError, AbloStaleContextError, AbloValidationError, formatClaimedErrorMessage, toAbloError, } from '../errors.js';
15
- import { descriptionFromMeta } from '../coordination/schema.js';
16
15
  import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
17
16
  import { Model, modelAsRow } from '../Model.js';
18
17
  import { toMs } from '../utils/duration.js';
@@ -77,7 +76,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
77
76
  // `claim({ id })` took, without a per-call handle. Released on dispose,
78
77
  // explicit release, or TTL expiry.
79
78
  //
80
- // `target`, `reason`, and `expiresAt` are kept alongside the lease so
79
+ // `target`, `description`, and `expiresAt` are kept alongside the lease so
81
80
  // `claim.state` can synthesize a self-claim: the server excludes a holder's
82
81
  // own presence frames, so this proxy is the only place that knows the client
83
82
  // holds the row. `expiresAt` is the client's best estimate from the requested
@@ -94,19 +93,13 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
94
93
  value.object === 'claim' &&
95
94
  typeof value.id === 'string' &&
96
95
  typeof value.release === 'function';
97
- const claimMeta = (options) => {
98
- if (!options?.description)
99
- return options?.meta;
100
- return { ...(options.meta ?? {}), description: options.description };
101
- };
96
+ const claimMeta = (options) => options?.meta;
102
97
  const claimContextFromClaim = (claim) => {
103
- const description = claim.description ?? descriptionFromMeta(claim.target.meta);
104
98
  return {
105
99
  id: claim.id,
106
100
  actor: claim.heldBy,
107
101
  participantKind: claim.participantKind,
108
- reason: claim.reason,
109
- ...(description ? { description } : {}),
102
+ description: claim.description,
110
103
  field: claim.target.field,
111
104
  status: claim.status,
112
105
  expiresAt: claim.expiresAt,
@@ -192,7 +185,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
192
185
  ...(options.range ? { range: options.range } : {}),
193
186
  ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
194
187
  },
195
- reason: options.reason ?? 'editing',
188
+ description: options.description ?? 'editing',
196
189
  ttl: options.ttl,
197
190
  queue: !failFast,
198
191
  maxQueueDepth: options.maxQueueDepth,
@@ -215,7 +208,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
215
208
  model = objectPool.get(id) ?? model;
216
209
  }
217
210
  const snapshot = collaboration.createSnapshot(schemaKey, id);
218
- const reason = options.reason ?? 'editing';
211
+ const description = options.description ?? 'editing';
219
212
  // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
220
213
  // report (`state` maps `held.target.model` to `type`), so a holder and a
221
214
  // peer see the same `target.type` for one row — the wire model token.
@@ -233,7 +226,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
233
226
  lease,
234
227
  snapshot,
235
228
  target: selfTarget,
236
- reason,
229
+ description,
237
230
  expiresAt,
238
231
  });
239
232
  const target = {
@@ -281,9 +274,11 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
281
274
  object: 'claim',
282
275
  id: lease.id,
283
276
  readAt: snapshot.stamp,
277
+ // The fencing token the server minted for this grant, forwarded from the
278
+ // lease so writes taken under this handle carry it (Option B).
279
+ ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
284
280
  target,
285
- reason,
286
- ...(options.description ? { description: options.description } : {}),
281
+ description,
287
282
  data: modelAsRow(model),
288
283
  release,
289
284
  revoke: () => {
@@ -293,12 +288,153 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
293
288
  [Symbol.asyncDispose]: release,
294
289
  };
295
290
  };
296
- const claim = (params) => takeClaim(params);
291
+ // The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
292
+ // lives only in the customer's own database and was never synced into Ablo.
293
+ // Everything about the lease — the fail-fast contention check, the scope pin,
294
+ // the `createClaim` grant, the heartbeat wiring, and the `activeClaims`
295
+ // bookkeeping — is identical; what's dropped is the object-pool `load` (and its
296
+ // `entity_not_found` throw), the post-grant re-read, and the `.data` field,
297
+ // since there is no local row to hydrate or return.
298
+ const takeRowFreeClaim = async (id, options) => {
299
+ if (!collaboration) {
300
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
301
+ }
302
+ // Is someone else already on this target? Read the local coordination
303
+ // snapshot up front so a `queue: false` caller can reject before announcing
304
+ // a claim the server would refuse.
305
+ const held = collaboration.state({ model: wireModel, id });
306
+ const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
307
+ const failFast = options.queue === false;
308
+ // Fail-fast (`queue: false`): reject now if a holder is already visible.
309
+ // Best-effort at the client — a row this participant never synced usually
310
+ // carries no local claim state either, so a peer gets the deterministic
311
+ // rejection only once it has observed the holder (entered the row's entity
312
+ // scope). The server's queue is the backstop for the queuing path.
313
+ if (failFast && contended) {
314
+ const claim = claimContextFromClaim(held);
315
+ throw new AbloClaimedError(formatClaimedErrorMessage({
316
+ targetLabel: `${registeredModelName}/${id}`,
317
+ heldBy: held.heldBy,
318
+ claim,
319
+ fallback: `${registeredModelName}/${id} is held by ${held.heldBy ?? 'another participant'}.`,
320
+ }), { code: 'entity_claimed', claims: [claim] });
321
+ }
322
+ // Enter the entity scope before acquiring the lease so the holder's claim
323
+ // presence broadcasts to everyone in this entity group — the same ordering
324
+ // the row-bearing path relies on. No pool `load` and no `entity_not_found`
325
+ // throw: the row lives only in the customer's database, so there is nothing
326
+ // to hydrate here and nothing to re-read after the grant.
327
+ await collaboration.pinScope?.({ [schemaKey]: id });
328
+ const lease = await collaboration.createClaim({
329
+ target: {
330
+ model: wireModel,
331
+ id,
332
+ ...(options.field ? { field: options.field } : {}),
333
+ ...(options.path ? { path: options.path } : {}),
334
+ ...(options.range ? { range: options.range } : {}),
335
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
336
+ },
337
+ description: options.description ?? 'editing',
338
+ ttl: options.ttl,
339
+ queue: !failFast,
340
+ maxQueueDepth: options.maxQueueDepth,
341
+ });
342
+ // A watermark-only snapshot: `createSnapshot` still reads the engine's
343
+ // current `lastSyncId` even though the pool holds no row (the bucket is
344
+ // empty). It costs nothing extra and gives a write taken under this lease a
345
+ // real `readAt` to guard against changes since the lease was acquired.
346
+ const snapshot = collaboration.createSnapshot(schemaKey, id);
347
+ const description = options.description ?? 'editing';
348
+ const selfTarget = {
349
+ type: wireModel,
350
+ id,
351
+ ...(options.field ? { field: options.field } : {}),
352
+ ...(options.path ? { path: options.path } : {}),
353
+ ...(options.range ? { range: options.range } : {}),
354
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
355
+ };
356
+ const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
357
+ const expiresAt = Date.now() + ttlMs;
358
+ activeClaims.set(id, {
359
+ lease,
360
+ snapshot,
361
+ target: selfTarget,
362
+ description,
363
+ expiresAt,
364
+ });
365
+ const target = {
366
+ type: schemaKey,
367
+ id,
368
+ ...(options.field ? { field: options.field } : {}),
369
+ ...(options.path ? { path: options.path } : {}),
370
+ ...(options.range ? { range: options.range } : {}),
371
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
372
+ };
373
+ // A beat resolves with the server's extended expiry; keep the local
374
+ // self-claim estimate in step so `claim.state` renders the real window.
375
+ const heartbeat = async (beatOptions) => {
376
+ if (!lease.heartbeat) {
377
+ 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' });
378
+ }
379
+ const resolved = resolveHeartbeatOptions(beatOptions);
380
+ const beat = await lease.heartbeat({
381
+ ttl: resolved.ttl ?? options.ttl,
382
+ ...(resolved.details !== undefined ? { details: resolved.details } : {}),
383
+ });
384
+ const held = activeClaims.get(id);
385
+ if (held)
386
+ held.expiresAt = beat.expiresAt;
387
+ options.onHeartbeat?.(beat);
388
+ return beat;
389
+ };
390
+ const stopHeartbeatLoop = options.heartbeat
391
+ ? startClaimHeartbeatLoop({
392
+ beat: () => heartbeat(),
393
+ intervalMs: heartbeatCadenceMs(ttlMs, options.heartbeat),
394
+ ...(options.onHeartbeatLost
395
+ ? { onLost: options.onHeartbeatLost }
396
+ : {}),
397
+ })
398
+ : undefined;
399
+ const release = () => {
400
+ stopHeartbeatLoop?.();
401
+ return releaseClaim(id);
402
+ };
403
+ return {
404
+ object: 'claim',
405
+ id: lease.id,
406
+ readAt: snapshot.stamp,
407
+ // Forward the grant's fencing token so writes under this row-free lease
408
+ // carry it (Option B), exactly as the row-bearing claim does.
409
+ ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
410
+ target,
411
+ description,
412
+ release,
413
+ revoke: () => {
414
+ void release();
415
+ },
416
+ heartbeat,
417
+ [Symbol.asyncDispose]: release,
418
+ };
419
+ };
420
+ // `claim` overloads on its first argument: an options object claims a synced
421
+ // row and resolves to a HeldClaim (carrying `.data`); a bare id claims a key
422
+ // whose row Ablo doesn't hold and resolves to a HeldLease (no `.data`). Both
423
+ // route their throws through `toAbloError` via the guarded takers, so the
424
+ // two-signature shape survives — wrapping the dispatcher itself in `guard`
425
+ // would collapse the overloads to one.
426
+ const guardedTakeClaim = guard(takeClaim);
427
+ const guardedTakeRowFreeClaim = guard(takeRowFreeClaim);
428
+ function claim(arg, opts) {
429
+ return typeof arg === 'string'
430
+ ? guardedTakeRowFreeClaim(arg, opts ?? {})
431
+ : guardedTakeClaim(arg);
432
+ }
297
433
  // `claim` is a callable namespace: invoke it to take a claim, reach its
298
434
  // members to read/steer the coordination plane. Attach the readers to the
299
- // guarded callable so `ablo.<model>.claim(...)` and `ablo.<model>.claim.state(...)`
435
+ // callable so `ablo.<model>.claim(...)` and `ablo.<model>.claim.state(...)`
300
436
  // are the same object.
301
- const claimApi = Object.assign(guard(claim), {
437
+ const claimApi = Object.assign(claim, {
302
438
  state(params) {
303
439
  // Read interest: a passive observer of a row's claim state must enter that
304
440
  // row's entity scope, or it sits only on broader `org:`/`user:` groups and
@@ -316,7 +452,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
316
452
  id: own.lease.id,
317
453
  status: 'active',
318
454
  target: own.target,
319
- reason: own.reason,
455
+ description: own.description,
320
456
  heldBy: collaboration?.selfParticipantId ?? '',
321
457
  participantKind: collaboration?.selfParticipantKind ?? 'user',
322
458
  expiresAt: own.expiresAt,
@@ -416,7 +552,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
416
552
  ...(claim.range ? { range: claim.range } : {}),
417
553
  ...(claimMeta(claim) ? { meta: claimMeta(claim) } : {}),
418
554
  },
419
- reason: claim.reason ?? 'creating',
555
+ description: claim.description ?? 'creating',
420
556
  ttl: claim.ttl,
421
557
  queue: claim.queue !== false,
422
558
  maxQueueDepth: claim.maxQueueDepth,
@@ -540,6 +676,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
540
676
  wait: 'confirmed',
541
677
  readAt: handle.readAt,
542
678
  onStale: 'reject',
679
+ ...(handle.fenceToken !== undefined
680
+ ? { fenceToken: handle.fenceToken }
681
+ : {}),
543
682
  }
544
683
  : {}),
545
684
  ...opts,
@@ -589,6 +728,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
589
728
  readAt: claimed.snapshot.stamp,
590
729
  onStale: 'reject',
591
730
  claimRef: { id: claimed.lease.id },
731
+ ...(claimed.lease.fenceToken !== undefined
732
+ ? { fenceToken: claimed.lease.fenceToken }
733
+ : {}),
592
734
  ...opts,
593
735
  }
594
736
  : {
@@ -60,7 +60,7 @@ export interface HttpModelClient<T, C = T> {
60
60
  export type AbloHttpClient<S extends SchemaRecord> = {
61
61
  readonly [K in keyof S & string]: HttpModelClient<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
62
62
  } & {
63
- /** Runs one-time setup, such as registering a configured `databaseUrl` data source, before the client is used. It also runs lazily ahead of the first request, so calling it yourself is optional. */
63
+ /** Runs one-time setup (durable-outbox scope resolution and replay) before the client is used. It also runs lazily ahead of the first request, so calling it yourself is optional. */
64
64
  ready(): Promise<void>;
65
65
  /** Replays every pending durable HTTP write in seal order and waits for settlement. */
66
66
  waitForFlush(): Promise<void>;