@abloatai/ablo 0.10.1 → 0.11.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -1
  3. package/dist/BaseSyncedStore.d.ts +75 -0
  4. package/dist/BaseSyncedStore.js +193 -8
  5. package/dist/Database.d.ts +10 -2
  6. package/dist/Database.js +15 -1
  7. package/dist/SyncClient.d.ts +12 -1
  8. package/dist/SyncClient.js +110 -26
  9. package/dist/agent/Agent.d.ts +9 -9
  10. package/dist/agent/Agent.js +16 -16
  11. package/dist/agent/index.d.ts +1 -1
  12. package/dist/agent/index.js +2 -2
  13. package/dist/agent/types.d.ts +1 -1
  14. package/dist/agent/types.js +1 -1
  15. package/dist/ai-sdk/{intent-broadcast.d.ts → claim-broadcast.d.ts} +10 -10
  16. package/dist/ai-sdk/{intent-broadcast.js → claim-broadcast.js} +6 -6
  17. package/dist/ai-sdk/coordination-context.d.ts +9 -9
  18. package/dist/ai-sdk/coordination-context.js +8 -8
  19. package/dist/ai-sdk/index.d.ts +1 -1
  20. package/dist/ai-sdk/index.js +1 -1
  21. package/dist/ai-sdk/wrap.d.ts +4 -4
  22. package/dist/ai-sdk/wrap.js +4 -4
  23. package/dist/api/index.d.ts +2 -2
  24. package/dist/cli.cjs +254 -48
  25. package/dist/client/Ablo.d.ts +30 -63
  26. package/dist/client/Ablo.js +108 -102
  27. package/dist/client/ApiClient.d.ts +6 -5
  28. package/dist/client/ApiClient.js +83 -62
  29. package/dist/client/createModelProxy.d.ts +16 -54
  30. package/dist/client/createModelProxy.js +44 -16
  31. package/dist/client/httpClient.d.ts +2 -0
  32. package/dist/client/httpClient.js +1 -1
  33. package/dist/client/index.d.ts +3 -3
  34. package/dist/client/writeOptionsSchema.d.ts +4 -4
  35. package/dist/client/writeOptionsSchema.js +4 -4
  36. package/dist/coordination/schema.d.ts +249 -38
  37. package/dist/coordination/schema.js +172 -39
  38. package/dist/core/index.d.ts +2 -2
  39. package/dist/core/index.js +4 -4
  40. package/dist/errorCodes.d.ts +9 -9
  41. package/dist/errorCodes.js +15 -15
  42. package/dist/errors.d.ts +51 -2
  43. package/dist/errors.js +94 -5
  44. package/dist/interfaces/index.d.ts +8 -4
  45. package/dist/policy/index.d.ts +1 -1
  46. package/dist/policy/types.d.ts +13 -13
  47. package/dist/policy/types.js +8 -8
  48. package/dist/react/AbloProvider.d.ts +51 -4
  49. package/dist/react/AbloProvider.js +95 -11
  50. package/dist/react/context.d.ts +26 -9
  51. package/dist/react/context.js +2 -2
  52. package/dist/react/index.d.ts +4 -4
  53. package/dist/react/index.js +4 -4
  54. package/dist/react/useAblo.js +5 -5
  55. package/dist/react/{useIntent.d.ts → useClaim.d.ts} +9 -9
  56. package/dist/react/useClaim.js +42 -0
  57. package/dist/schema/index.js +1 -1
  58. package/dist/schema/sugar.d.ts +3 -3
  59. package/dist/schema/sugar.js +3 -3
  60. package/dist/schema/sync-delta-wire.d.ts +8 -8
  61. package/dist/server/commit.d.ts +2 -2
  62. package/dist/sync/AreaOfInterestManager.d.ts +162 -0
  63. package/dist/sync/AreaOfInterestManager.js +233 -0
  64. package/dist/sync/BootstrapHelper.d.ts +9 -1
  65. package/dist/sync/BootstrapHelper.js +15 -5
  66. package/dist/sync/NetworkProbe.d.ts +1 -1
  67. package/dist/sync/NetworkProbe.js +1 -1
  68. package/dist/sync/SyncWebSocket.d.ts +59 -25
  69. package/dist/sync/SyncWebSocket.js +123 -26
  70. package/dist/sync/awaitClaimGrant.d.ts +40 -0
  71. package/dist/sync/awaitClaimGrant.js +86 -0
  72. package/dist/sync/createClaimStream.d.ts +34 -0
  73. package/dist/sync/{createIntentStream.js → createClaimStream.js} +92 -81
  74. package/dist/sync/createPresenceStream.js +3 -2
  75. package/dist/sync/participants.d.ts +10 -10
  76. package/dist/sync/participants.js +17 -10
  77. package/dist/sync/schemas.d.ts +8 -8
  78. package/dist/transactions/TransactionQueue.d.ts +12 -0
  79. package/dist/transactions/TransactionQueue.js +126 -8
  80. package/dist/types/global.d.ts +10 -10
  81. package/dist/types/global.js +3 -3
  82. package/dist/types/index.d.ts +9 -7
  83. package/dist/types/index.js +2 -2
  84. package/dist/types/streams.d.ts +114 -98
  85. package/dist/types/streams.js +1 -1
  86. package/dist/utils/asyncIterator.d.ts +1 -1
  87. package/dist/utils/asyncIterator.js +1 -1
  88. package/dist/wire/frames.d.ts +2 -2
  89. package/package.json +3 -2
  90. package/dist/react/useIntent.js +0 -42
  91. package/dist/sync/awaitIntentGrant.d.ts +0 -40
  92. package/dist/sync/awaitIntentGrant.js +0 -62
  93. package/dist/sync/createIntentStream.d.ts +0 -34
@@ -225,6 +225,7 @@ export class TransactionQueue extends EventEmitter {
225
225
  // Per-model in-flight tracking and merge buffer
226
226
  inFlightByModel = new Set();
227
227
  pendingMergeByModel = new Map();
228
+ deferredDeletesByCreate = new Map();
228
229
  // Commit lane: pre-built atomic multi-op envelopes from `ablo.commits.create()`.
229
230
  // Drained serially (one envelope at a time) since each is atomic; no
230
231
  // coalescing with model-proxy transactions.
@@ -242,6 +243,102 @@ export class TransactionQueue extends EventEmitter {
242
243
  transaction.priorityScore = this.computePriorityScore(transaction.type, transaction.modelName);
243
244
  }
244
245
  }
246
+ entityKey(modelName, modelId) {
247
+ return `${modelName}:${modelId}`;
248
+ }
249
+ isTransactionForModel(transaction, modelName, modelId) {
250
+ return transaction.modelName === modelName && transaction.modelId === modelId;
251
+ }
252
+ resolveConfirmation(transaction) {
253
+ const resolver = this.confirmationResolvers.get(transaction.id);
254
+ if (!resolver)
255
+ return;
256
+ this.confirmationResolvers.delete(transaction.id);
257
+ resolver.resolve();
258
+ }
259
+ takeUnsentCreateForModel(modelName, modelId) {
260
+ const isUnsentCreate = (tx) => tx.type === 'create' &&
261
+ tx.status === 'pending' &&
262
+ tx.attempts === 0 &&
263
+ this.isTransactionForModel(tx, modelName, modelId);
264
+ const stagedIndex = this.createdTransactions.findIndex(isUnsentCreate);
265
+ if (stagedIndex >= 0) {
266
+ return this.createdTransactions.splice(stagedIndex, 1)[0];
267
+ }
268
+ const queuedIndex = this.executionQueue.findIndex(isUnsentCreate);
269
+ if (queuedIndex >= 0) {
270
+ return this.executionQueue.splice(queuedIndex, 1)[0];
271
+ }
272
+ return this.store.getByStatus('pending').find(isUnsentCreate);
273
+ }
274
+ async cancelUnsentCreateForDelete(transaction) {
275
+ this.store.updateStatus(transaction.id, 'rolled_back');
276
+ if (this.config.enableOptimistic) {
277
+ await this.rollbackOptimistic(transaction, 'model_cancelled');
278
+ }
279
+ this.resolveConfirmation(transaction);
280
+ }
281
+ findCreateBarrierForDelete(modelName, modelId) {
282
+ const liveCreates = [
283
+ ...this.store.getByStatus('pending'),
284
+ ...this.store.getByStatus('executing'),
285
+ ...this.store.getByStatus('awaiting_delta'),
286
+ ].filter((tx) => tx.type === 'create' &&
287
+ this.isTransactionForModel(tx, modelName, modelId) &&
288
+ // A never-attempted pending create can be cancelled instead. Once the
289
+ // create has been sent, even a retry-pending state is a causal barrier:
290
+ // the server may already have applied it and only the response was lost.
291
+ (tx.status !== 'pending' || tx.attempts > 0));
292
+ return liveCreates.sort((a, b) => b.createdAt - a.createdAt)[0];
293
+ }
294
+ completeLocalDelete(model, context, writeOptions) {
295
+ const actualModelName = model.getModelName();
296
+ const modelKey = normalizeModelKey(actualModelName);
297
+ const transaction = {
298
+ id: this.generateId(),
299
+ type: 'delete',
300
+ modelName: actualModelName,
301
+ modelId: model.id,
302
+ modelKey,
303
+ priorityScore: this.computePriorityScore('delete', actualModelName),
304
+ previousData: model.toJSON ? model.toJSON() : { ...model },
305
+ context,
306
+ status: 'completed',
307
+ createdAt: Date.now(),
308
+ attempts: 0,
309
+ priority: 'high',
310
+ writeOptions,
311
+ localOnly: true,
312
+ };
313
+ this.attachConfirmation(transaction);
314
+ this.store.add(transaction);
315
+ if (this.config.enableOptimistic) {
316
+ this.applyOptimisticDelete(model, transaction);
317
+ }
318
+ this.emit('transaction:created', transaction);
319
+ this.emit('transaction:completed', transaction);
320
+ this.emit(`transaction:completed:${transaction.id}`, transaction);
321
+ this.optimisticUpdates.delete(transaction.id);
322
+ return transaction;
323
+ }
324
+ deferDeleteUntilCreateSettles(createTransaction, deleteTransaction) {
325
+ const key = this.entityKey(createTransaction.modelName, createTransaction.modelId);
326
+ const deferred = this.deferredDeletesByCreate.get(key) ?? [];
327
+ deferred.push(deleteTransaction);
328
+ this.deferredDeletesByCreate.set(key, deferred);
329
+ }
330
+ releaseDeferredDeletesForCreate(createTransaction) {
331
+ const key = this.entityKey(createTransaction.modelName, createTransaction.modelId);
332
+ const deferred = this.deferredDeletesByCreate.get(key);
333
+ if (!deferred || deferred.length === 0)
334
+ return;
335
+ this.deferredDeletesByCreate.delete(key);
336
+ for (const deleteTransaction of deferred) {
337
+ if (this.store.get(deleteTransaction.id)?.status !== 'pending')
338
+ continue;
339
+ this.enqueue(deleteTransaction);
340
+ }
341
+ }
245
342
  // Merge two GraphQL update payloads with special handling for metadata fields
246
343
  mergeUpdateData(left, right, _modelName) {
247
344
  const out = { ...(left || {}) };
@@ -374,6 +471,9 @@ export class TransactionQueue extends EventEmitter {
374
471
  this.confirmationResolvers.delete(tx.id);
375
472
  r.resolve();
376
473
  }
474
+ if (tx.type === 'create') {
475
+ this.releaseDeferredDeletesForCreate(tx);
476
+ }
377
477
  });
378
478
  this.on('transaction:failed', ({ transaction, error }) => {
379
479
  const r = this.confirmationResolvers.get(transaction.id);
@@ -381,6 +481,9 @@ export class TransactionQueue extends EventEmitter {
381
481
  this.confirmationResolvers.delete(transaction.id);
382
482
  r.reject(error);
383
483
  }
484
+ if (transaction.type === 'create') {
485
+ this.releaseDeferredDeletesForCreate(transaction);
486
+ }
384
487
  });
385
488
  }
386
489
  /**
@@ -726,6 +829,7 @@ export class TransactionQueue extends EventEmitter {
726
829
  attempts: 0,
727
830
  priority: 'high',
728
831
  writeOptions,
832
+ localOnly: true,
729
833
  // Activity deletes complete synchronously (audit-record skip path).
730
834
  // Pre-resolved so consumers can still `await tx.confirmation` uniformly.
731
835
  confirmation: Promise.resolve(),
@@ -740,6 +844,11 @@ export class TransactionQueue extends EventEmitter {
740
844
  }
741
845
  const modelKey = normalizeModelKey(actualModelName);
742
846
  const priorityScore = this.computePriorityScore('delete', actualModelName);
847
+ const unsentCreate = this.takeUnsentCreateForModel(actualModelName, model.id);
848
+ if (unsentCreate) {
849
+ await this.cancelUnsentCreateForDelete(unsentCreate);
850
+ return this.completeLocalDelete(model, context, writeOptions);
851
+ }
743
852
  const transaction = {
744
853
  id: this.generateId(),
745
854
  type: 'delete',
@@ -760,15 +869,22 @@ export class TransactionQueue extends EventEmitter {
760
869
  // Cancel any pending/in-flight updates for this model to prevent "no rows" errors
761
870
  // when the delete executes before the update (race condition fix)
762
871
  this.cancelTransactionsForModel(model.id, 'update');
763
- this.pendingMergeByModel.delete(`${actualModelName}:${model.id}`);
764
- this.inFlightByModel.delete(`${actualModelName}:${model.id}`);
872
+ const entityKey = this.entityKey(actualModelName, model.id);
873
+ this.pendingMergeByModel.delete(entityKey);
874
+ this.inFlightByModel.delete(entityKey);
765
875
  // Apply optimistic delete
766
876
  if (this.config.enableOptimistic) {
767
877
  this.applyOptimisticDelete(model, transaction);
768
878
  }
769
- // LINEAR PATTERN: Stage transaction for microtask commit
770
- // All deletes in same event loop will be batched together
771
- this.stageTransaction(transaction);
879
+ const createBarrier = this.findCreateBarrierForDelete(actualModelName, model.id);
880
+ if (createBarrier) {
881
+ this.deferDeleteUntilCreateSettles(createBarrier, transaction);
882
+ }
883
+ else {
884
+ // LINEAR PATTERN: Stage transaction for microtask commit
885
+ // All deletes in same event loop will be batched together
886
+ this.stageTransaction(transaction);
887
+ }
772
888
  this.emit('transaction:created', transaction);
773
889
  return transaction;
774
890
  }
@@ -1015,7 +1131,7 @@ export class TransactionQueue extends EventEmitter {
1015
1131
  tx.syncIdNeededForCompletion = lastSyncId;
1016
1132
  // Safety net: when lastSyncId is 0, DELETE transactions should be confirmed
1017
1133
  // immediately. DELETEs are idempotent — if no delta was emitted, the entity
1018
- // is already gone and the intent was achieved. Parking DELETEs in awaiting_delta
1134
+ // is already gone and the claim was achieved. Parking DELETEs in awaiting_delta
1019
1135
  // with threshold 0 causes 30s reconciliation delays.
1020
1136
  if (lastSyncId === 0 && tx.type === 'delete') {
1021
1137
  this.store.updateStatus(tx.id, 'completed');
@@ -1110,14 +1226,14 @@ export class TransactionQueue extends EventEmitter {
1110
1226
  });
1111
1227
  // LINEAR PATTERN: Handle "no rows in result set" gracefully
1112
1228
  // This error means the entity was already deleted - for UPDATE/DELETE ops, this is success
1113
- // The intent was achieved (the data doesn't exist), so treat as completed
1229
+ // The claim was achieved (the data doesn't exist), so treat as completed
1114
1230
  if (errorMessage.includes('no rows in result set')) {
1115
1231
  getContext().logger.info('[TransactionQueue] Graceful handling: entity already deleted', {
1116
1232
  batchSize: batchOps.length,
1117
1233
  });
1118
1234
  for (const { tx, op } of batchOps) {
1119
1235
  if (op.type === 'UPDATE' || op.type === 'DELETE') {
1120
- // Entity gone = intent achieved, mark as completed
1236
+ // Entity gone = claim achieved, mark as completed
1121
1237
  this.store.updateStatus(tx.id, 'completed');
1122
1238
  this.emit('transaction:completed', tx);
1123
1239
  getContext().logger.debug('[TransactionQueue] Orphaned transaction treated as success', {
@@ -1973,6 +2089,8 @@ export class TransactionQueue extends EventEmitter {
1973
2089
  this.store.clear();
1974
2090
  this.optimisticUpdates.clear();
1975
2091
  this.executionQueue = [];
2092
+ this.createdTransactions = [];
2093
+ this.deferredDeletesByCreate.clear();
1976
2094
  // Clear event listeners
1977
2095
  this.removeAllListeners();
1978
2096
  // Reset state
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Type registration point for SDK consumers.
3
3
  *
4
- * A consumer registers their Schema, Presence, Intents, and UserMeta ONCE by
4
+ * A consumer registers their Schema, Presence, Claims, and UserMeta ONCE by
5
5
  * augmenting the {@link Register} interface, and every SDK hook — `useAblo`,
6
- * `useQuery`, `useOne`, `usePresence`, `useIntent` — reads its types from the
6
+ * `useQuery`, `useOne`, `usePresence`, `useClaim` — reads its types from the
7
7
  * resolved registration. No generics at call sites, no `schema` arg per call.
8
8
  *
9
9
  * Registration is done via **module augmentation** of `@abloatai/ablo` —
@@ -22,7 +22,7 @@
22
22
  * interface Register {
23
23
  * Schema: typeof schema;
24
24
  * Presence: { cursor: { x: number; y: number } | null };
25
- * Intents: { editLayer: { layerId: string } };
25
+ * Claims: { editLayer: { layerId: string } };
26
26
  * UserMeta: { id: string; email: string };
27
27
  * }
28
28
  * }
@@ -44,7 +44,7 @@ export interface DefaultSyncShape {
44
44
  readonly models: Record<string, unknown>;
45
45
  };
46
46
  readonly Presence: Record<string, unknown>;
47
- readonly Intents: Record<string, unknown>;
47
+ readonly Claims: Record<string, unknown>;
48
48
  readonly UserMeta: {
49
49
  readonly id: string;
50
50
  };
@@ -75,13 +75,13 @@ export type ResolvePresence = Register extends {
75
75
  Presence: infer P;
76
76
  } ? P : DefaultSyncShape['Presence'];
77
77
  /**
78
- * The consumer's intent vocabulary, or the default if unregistered. Keys are
79
- * intent names; values are the claim payload for each intent. Used by
80
- * `useIntent(intentName)`.
78
+ * The consumer's claim vocabulary, or the default if unregistered. Keys are
79
+ * claim names; values are the claim payload for each claim. Used by
80
+ * `useClaim(claimName)`.
81
81
  */
82
- export type ResolveIntents = Register extends {
83
- Intents: infer I;
84
- } ? I : DefaultSyncShape['Intents'];
82
+ export type ResolveClaims = Register extends {
83
+ Claims: infer I;
84
+ } ? I : DefaultSyncShape['Claims'];
85
85
  /**
86
86
  * The consumer's user-metadata shape, or the default if unregistered. Carries
87
87
  * identity info the consumer trusts from their auth layer — not SDK-validated.
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Type registration point for SDK consumers.
3
3
  *
4
- * A consumer registers their Schema, Presence, Intents, and UserMeta ONCE by
4
+ * A consumer registers their Schema, Presence, Claims, and UserMeta ONCE by
5
5
  * augmenting the {@link Register} interface, and every SDK hook — `useAblo`,
6
- * `useQuery`, `useOne`, `usePresence`, `useIntent` — reads its types from the
6
+ * `useQuery`, `useOne`, `usePresence`, `useClaim` — reads its types from the
7
7
  * resolved registration. No generics at call sites, no `schema` arg per call.
8
8
  *
9
9
  * Registration is done via **module augmentation** of `@abloatai/ablo` —
@@ -22,7 +22,7 @@
22
22
  * interface Register {
23
23
  * Schema: typeof schema;
24
24
  * Presence: { cursor: { x: number; y: number } | null };
25
- * Intents: { editLayer: { layerId: string } };
25
+ * Claims: { editLayer: { layerId: string } };
26
26
  * UserMeta: { id: string; email: string };
27
27
  * }
28
28
  * }
@@ -4,6 +4,7 @@
4
4
  * Foundational type definitions for the model-driven sync architecture.
5
5
  * These types define how properties are tracked, loaded, and synchronized.
6
6
  */
7
+ import type { FieldMeta } from '../schema/field.js';
7
8
  /**
8
9
  * Model Scope - lifecycle filter for queries.
9
10
  * Controls whether live, archived, or all entities are returned.
@@ -108,14 +109,15 @@ export interface ModelMetadata {
108
109
  * JSON-typed values) inside the transaction queue.
109
110
  *
110
111
  * Populated by `registerModelsFromSchema`. Each entry carries the
111
- * sync-engine type tag (`'string' | 'number' | 'boolean' | 'date' |
112
- * 'enum' | 'json'`), which tells the wire serializer how to handle
113
- * the value. Missing → projection becomes identity pass-through
114
- * (back-compat for models registered outside the schema path).
112
+ * sync-engine type tag (the canonical {@link FieldMeta.type} union),
113
+ * which tells the wire serializer how to handle the value. Missing →
114
+ * projection becomes identity pass-through (back-compat for models
115
+ * registered outside the schema path).
116
+ *
117
+ * Narrowed to the canonical union via `Pick` rather than re-declared —
118
+ * a hand-rolled copy silently drifts when a new field type lands.
115
119
  */
116
- fields?: Readonly<Record<string, {
117
- type: 'string' | 'number' | 'boolean' | 'date' | 'enum' | 'json';
118
- }>>;
120
+ fields?: Readonly<Record<string, Pick<FieldMeta, 'type'>>>;
119
121
  /**
120
122
  * Fields to back-fill from the sync client identity when missing
121
123
  * during IndexedDB self-healing. Populated from
@@ -65,6 +65,6 @@ export var MutationOperationType;
65
65
  })(MutationOperationType || (MutationOperationType = {}));
66
66
  // Re-export stream + snapshot + principal types for the engine surface
67
67
  // (PresenceStream,
68
- // IntentStream, Snapshot, etc.) consumed by `Ablo({...}).presence`,
69
- // `.intents`, `.snapshot()`.
68
+ // ClaimStream, Snapshot, etc.) consumed by `Ablo({...}).presence`,
69
+ // `.claims`, `.snapshot()`.
70
70
  export * from "./streams.js";