@abloatai/humans 0.51.0 → 0.53.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 (50) hide show
  1. package/dist/client.d.ts +4 -4
  2. package/dist/local/Database.d.ts +1 -34
  3. package/dist/local/Database.js +16 -55
  4. package/dist/local/InstanceCache.d.ts +9 -0
  5. package/dist/local/InstanceCache.js +9 -0
  6. package/dist/local/Model.js +0 -18
  7. package/dist/local/SyncClient.d.ts +23 -11
  8. package/dist/local/SyncClient.js +44 -53
  9. package/dist/local/client/createInternalComponents.js +4 -0
  10. package/dist/local/client/createModelProxy.js +21 -3
  11. package/dist/local/interfaces/index.d.ts +3 -3
  12. package/dist/local/rowWatermarks.d.ts +40 -0
  13. package/dist/local/rowWatermarks.js +53 -0
  14. package/dist/local/sync/OnDemandLoader.d.ts +22 -13
  15. package/dist/local/sync/OnDemandLoader.js +58 -73
  16. package/dist/local/sync/bootstrapApply.d.ts +2 -4
  17. package/dist/local/sync/deltaPipeline.js +1 -1
  18. package/dist/local/sync/initialize.js +2 -2
  19. package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -1
  20. package/dist/local/transactions/mutations/MutationQueue.js +1 -1
  21. package/dist/local/transactions/persistedTransaction.d.ts +39 -0
  22. package/dist/local/transactions/persistedTransaction.js +53 -0
  23. package/dist/local/utils/mobxSetup.js +1 -1
  24. package/dist/plugin.d.ts +7 -0
  25. package/dist/react/AbloProvider.d.ts +2 -2
  26. package/dist/react/AbloProvider.js +2 -2
  27. package/dist/react/context.d.ts +2 -2
  28. package/dist/react/useAblo.d.ts +3 -3
  29. package/package.json +2 -2
  30. package/src/client.ts +4 -4
  31. package/src/local/BaseSyncedStore.ts +1 -1
  32. package/src/local/Database.ts +22 -110
  33. package/src/local/InstanceCache.ts +10 -0
  34. package/src/local/Model.ts +0 -20
  35. package/src/local/SyncClient.ts +81 -66
  36. package/src/local/client/createInternalComponents.ts +4 -0
  37. package/src/local/client/createModelProxy.ts +26 -5
  38. package/src/local/interfaces/index.ts +3 -3
  39. package/src/local/rowWatermarks.ts +54 -0
  40. package/src/local/sync/OnDemandLoader.ts +99 -70
  41. package/src/local/sync/bootstrapApply.ts +2 -1
  42. package/src/local/sync/deltaPipeline.ts +1 -1
  43. package/src/local/sync/initialize.ts +2 -2
  44. package/src/local/transactions/mutations/MutationQueue.ts +1 -1
  45. package/src/local/transactions/persistedTransaction.ts +112 -0
  46. package/src/local/utils/mobxSetup.ts +1 -1
  47. package/src/plugin.ts +7 -0
  48. package/src/react/AbloProvider.tsx +2 -2
  49. package/src/react/context.ts +2 -2
  50. package/src/react/useAblo.ts +3 -3
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The log position each pooled row is known to reflect — the client-side
3
+ * companion of the server's per-row watermark (`ModelListEvidence.stamp`).
4
+ *
5
+ * A row's copy in the pool moves through four doors, and every one of them
6
+ * names the log position it delivers: the ordered delta stream (the delta's
7
+ * id), the acknowledgement of this client's own commit (`lastSyncId`), a
8
+ * bootstrap snapshot (its `lastSyncId`), and a server read (the row's evidence
9
+ * stamp). Recording that position per row is what lets a later snapshot be
10
+ * judged. A snapshot taken at position P cannot carry anything the log did not
11
+ * hold at P, so when the pooled copy already reflects a position beyond P the
12
+ * snapshot is stale for that row and is left unapplied. Deltas repair every
13
+ * peer change a skipped snapshot would have carried; nothing repairs a
14
+ * snapshot that regresses this client's own confirmed write, because own
15
+ * echoes are suppressed on apply — which is why the rule errs toward keeping
16
+ * the resident copy.
17
+ *
18
+ * The row's `updatedAt` is not consulted. It is an application field the
19
+ * server never stamps and the client fabricates when a row arrives without one,
20
+ * so it orders nothing; the log does.
21
+ *
22
+ * Positions are `sync_deltas` ids, the same space as {@link LogPosition}. Zero
23
+ * and `undefined` mean "no evidence" and never advance a row.
24
+ */
25
+ export class RowWatermarks {
26
+ #positions = new WeakMap();
27
+ /** Record that `row`'s pooled copy reflects the log at least through `position`. */
28
+ advance(row, position) {
29
+ if (position === undefined || !(position > 0))
30
+ return;
31
+ const known = this.#positions.get(row);
32
+ if (known === undefined || position > known)
33
+ this.#positions.set(row, position);
34
+ }
35
+ /** The highest log position `row` is known to reflect, if the client has any evidence. */
36
+ of(row) {
37
+ return this.#positions.get(row);
38
+ }
39
+ /**
40
+ * Whether the pooled copy of `row` is known to be ahead of a snapshot that
41
+ * reflects the log through `snapshotPosition`. `snapshotPosition` is a lower
42
+ * bound: the position the snapshot provably includes (a row's evidence stamp,
43
+ * a bootstrap's `lastSyncId`, or the client's own read floor at the moment
44
+ * the read was issued — the server had at least that much when it answered).
45
+ * A snapshot with no known position is never judged stale.
46
+ */
47
+ isAheadOf(row, snapshotPosition) {
48
+ if (snapshotPosition === undefined)
49
+ return false;
50
+ const known = this.#positions.get(row);
51
+ return known !== undefined && known > snapshotPosition;
52
+ }
53
+ }
@@ -19,6 +19,13 @@
19
19
  * loaded models) or the live delta stream (pushed over the WebSocket). It only
20
20
  * fills the gap for lazily loaded models read by id or filter after the engine
21
21
  * is ready.
22
+ *
23
+ * A network answer is a snapshot, unordered against that stream: it may leave
24
+ * before a write and return after it. Each returned row therefore meets the
25
+ * pool by log position — the position the row provably reflects against the
26
+ * position the pooled copy is already known to hold ({@link RowWatermarks}) —
27
+ * never by wall-clock `updatedAt`, which the server does not stamp and which
28
+ * orders nothing.
22
29
  */
23
30
  import type { InstanceCache } from '../InstanceCache.js';
24
31
  import type { Database } from '../Database.js';
@@ -27,10 +34,16 @@ import type { ModelRegistry } from '../ModelRegistry.js';
27
34
  import type { RuntimeContext } from '../RuntimeContext.js';
28
35
  import type { RecoveryClass } from '@abloatai/transaction/errorCodes';
29
36
  import type { LoadWhere, WhereClause } from '../query/types.js';
37
+ import { normalizeWhere } from '@abloatai/transaction/resources/where';
30
38
  import type { Schema } from '@abloatai/transaction/schema/schema';
39
+ import type { LogPositionPort } from '../logPosition.js';
31
40
  export interface OnDemandLoaderOptions {
32
41
  readonly objectPool: InstanceCache;
33
- readonly database: Database;
42
+ /**
43
+ * The local tier reads and writes rows through a model's store, so store
44
+ * lookup is the whole of the loader's dependency on the database.
45
+ */
46
+ readonly database: Pick<Database, 'getStore'>;
34
47
  readonly registry: ModelRegistry;
35
48
  readonly schema: Schema;
36
49
  /** Bootstrap base URL (without trailing slash), e.g. `https://api.example.com/api`. */
@@ -44,6 +57,13 @@ export interface OnDemandLoaderOptions {
44
57
  readonly getCapabilityToken?: () => string | null;
45
58
  /** The owning client's runtime. Defaults to the module-global bridge. */
46
59
  readonly runtime?: RuntimeContext;
60
+ /**
61
+ * The client's position in the log. Read at the moment a query is issued:
62
+ * the server holds at least that much when it answers, so it is the position
63
+ * every returned row provably reflects — the bound a resident row is judged
64
+ * against before a snapshot may overwrite it (see {@link RowWatermarks}).
65
+ */
66
+ readonly position: Pick<LogPositionPort, 'readFloor'>;
47
67
  }
48
68
  export interface FetchOptions<T> {
49
69
  /**
@@ -211,18 +231,7 @@ export declare class OnDemandLoader {
211
231
  private columnizeField;
212
232
  private columnizeClause;
213
233
  }
214
- /**
215
- * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
216
- * tuple form used throughout `runFetch`. Tuple inputs pass through; object
217
- * inputs become one `['col', '=', val]` or `['col', 'IN', vals]` per key.
218
- *
219
- * Detection: an array whose first element is itself an array is treated
220
- * as tuple form. Object form is the fallback.
221
- *
222
- * Exported so callers can pre-normalize (e.g., for tests, or to inspect
223
- * the canonical clauses before passing them to `load`/`subscribe`).
224
- */
225
- export declare function normalizeWhere(where: unknown): readonly WhereClause[];
234
+ export { normalizeWhere };
226
235
  /**
227
236
  * Operator-aware predicate. Mirrors the server's WhereOp semantics for
228
237
  * local matching against pool/IDB rows. LIKE/ILIKE use SQL wildcards
@@ -19,33 +19,31 @@
19
19
  * loaded models) or the live delta stream (pushed over the WebSocket). It only
20
20
  * fills the gap for lazily loaded models read by id or filter after the engine
21
21
  * is ready.
22
+ *
23
+ * A network answer is a snapshot, unordered against that stream: it may leave
24
+ * before a write and return after it. Each returned row therefore meets the
25
+ * pool by log position — the position the row provably reflects against the
26
+ * position the pooled copy is already known to hold ({@link RowWatermarks}) —
27
+ * never by wall-clock `updatedAt`, which the server does not stamp and which
28
+ * orders nothing.
22
29
  */
23
30
  import { ModelScope } from '../InstanceCache.js';
24
31
  import { AbloValidationError } from '@abloatai/transaction/errors';
25
32
  import { postQuery } from '../query/client.js';
26
- function timestampMs(value) {
27
- if (value instanceof Date) {
28
- const timestamp = value.getTime();
29
- return Number.isFinite(timestamp) ? timestamp : undefined;
30
- }
31
- if (typeof value === 'number' && Number.isFinite(value))
32
- return value;
33
- if (typeof value !== 'string')
34
- return undefined;
35
- const parsed = Date.parse(value);
36
- return Number.isNaN(parsed) ? undefined : parsed;
37
- }
33
+ import { normalizeWhere } from '@abloatai/transaction/resources/where';
34
+ const LOCAL = { kind: 'local' };
38
35
  /**
39
- * A query response is a snapshot, not an ordered delta. It may have started
40
- * before a local optimistic write and completed after it. Only treat the row
41
- * as authoritative over an existing model when its server timestamp is newer;
42
- * missing timestamps retain the legacy merge behavior for custom sources that
43
- * do not expose `updatedAt`.
36
+ * The position a returned row provably reflects: the greater of its own
37
+ * evidence stamp (the row's watermark, which lags for a row that has not
38
+ * changed in a while) and the client's read floor when the query was issued
39
+ * (which the server had already passed when it answered). Both are lower
40
+ * bounds; the tighter one judges. `undefined` when neither says anything.
44
41
  */
45
- function snapshotDoesNotAdvanceModel(data, model) {
46
- const incoming = timestampMs(data.updatedAt);
47
- const resident = timestampMs(model.updatedAt);
48
- return incoming !== undefined && resident !== undefined && incoming <= resident;
42
+ function snapshotPosition(raw, evidenceById, readFloorAtIssue) {
43
+ const id = raw && typeof raw === 'object' ? raw.id : undefined;
44
+ const stamp = typeof id === 'string' ? (evidenceById.get(id) ?? 0) : 0;
45
+ const position = Math.max(stamp, readFloorAtIssue);
46
+ return position > 0 ? position : undefined;
49
47
  }
50
48
  export class OnDemandLoader {
51
49
  opts;
@@ -194,7 +192,7 @@ export class OnDemandLoader {
194
192
  if (local.length === 0) {
195
193
  const fromIdb = await scanIdb(this.opts.database, typename, clauses);
196
194
  const idbModels = fromIdb
197
- .map((raw) => this.hydrateOne(raw, typename))
195
+ .map((raw) => this.hydrateOne(raw, LOCAL, typename))
198
196
  .filter((m) => m !== null);
199
197
  if (idbModels.length > 0) {
200
198
  this.opts.objectPool.addBatch(idbModels, ModelScope.live);
@@ -224,18 +222,22 @@ export class OnDemandLoader {
224
222
  async fetchFromNetwork(modelName, typename, clauses, options) {
225
223
  const network = await this.queryNetwork(modelName, clauses, options);
226
224
  const networkRows = network.rows;
225
+ const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
227
226
  const networkModels = networkRows
228
227
  // Strict: a row the server returned whose type name this client never
229
228
  // registered is a genuine schema collision (the pushed schema differs
230
229
  // from the local one). Throw here, naming the cause, rather than silently
231
230
  // dropping the row and failing downstream as `entity_not_found`.
232
- .map((raw) => this.hydrateOne(raw, typename, { strict: true }))
231
+ .map((raw) => this.hydrateOne(raw, { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) }, typename, { strict: true }))
233
232
  .filter((m) => m !== null);
234
- const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
235
233
  for (const model of networkModels) {
236
234
  const stamp = evidenceById.get(model.id);
237
- if (stamp !== undefined)
238
- this.readEvidence.set(model, stamp);
235
+ if (stamp === undefined)
236
+ continue;
237
+ // The read's evidence, kept for the premise a guarded write may cite;
238
+ // and the position the pooled row now reflects, for freshness.
239
+ this.readEvidence.set(model, stamp);
240
+ this.opts.objectPool.watermarks.advance(model, stamp);
239
241
  }
240
242
  if (networkModels.length > 0) {
241
243
  this.opts.objectPool.addBatch(networkModels, ModelScope.live);
@@ -303,7 +305,7 @@ export class OnDemandLoader {
303
305
  continue;
304
306
  const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
305
307
  const models = rows
306
- .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), targetTypename))
308
+ .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), LOCAL, targetTypename))
307
309
  .filter((m) => m !== null);
308
310
  if (models.length > 0) {
309
311
  this.opts.objectPool.addBatch(models, ModelScope.live);
@@ -352,7 +354,7 @@ export class OnDemandLoader {
352
354
  getModelDef(modelName) {
353
355
  return this.opts.schema.models?.[modelName];
354
356
  }
355
- hydrateOne(raw, typename, opts) {
357
+ hydrateOne(raw, origin, typename, opts) {
356
358
  if (!raw || typeof raw !== 'object')
357
359
  return null;
358
360
  const obj = raw;
@@ -362,24 +364,26 @@ export class OnDemandLoader {
362
364
  // Keep the existing instance alive when a query refreshes it. A query
363
365
  // can carry fresher server state after a missed delta, but unlike the
364
366
  // ordered delta stream it can also finish late with an older snapshot;
365
- // the reconciliation below distinguishes those cases before applying.
367
+ // the origin decides which before anything is applied.
366
368
  const existing = this.opts.objectPool.get(obj.id);
367
369
  if (existing) {
368
- const stamped = this.stampTypename(obj, typename);
369
- // Network queries are unordered snapshots. A request that began before
370
- // an optimistic resize can return afterward with the old row; applying
371
- // it here would visibly snap the live model back, and the matching
372
- // authoritative delta cannot repair it because own echoes are
373
- // intentionally suppressed. Keep a newer resident row intact.
374
- if (snapshotDoesNotAdvanceModel(stamped, existing))
370
+ if (origin.kind === 'local')
371
+ return existing;
372
+ // A request that began before an optimistic write can return afterward
373
+ // with the old row; applying it would visibly snap the live model
374
+ // back, and the matching authoritative delta cannot repair it because
375
+ // own echoes are suppressed. The pool knows the position the row
376
+ // already reflects; a snapshot from before it is left unapplied.
377
+ if (this.opts.objectPool.watermarks.isAheadOf(existing, origin.position))
375
378
  return existing;
376
- // If the source has no comparable timestamp, retain pending local
377
- // fields while accepting unrelated server fields. This is the same
378
- // local-first merge contract used by SyncClient's delta resolver.
379
+ const stamped = this.stampTypename(obj, typename);
380
+ // Retain pending local fields while accepting the server's others
381
+ // the same local-first merge contract SyncClient's delta resolver uses.
379
382
  const localChanges = existing.getChanges();
380
383
  existing.updateFromData(Object.keys(localChanges).length > 0
381
384
  ? { ...stamped, ...localChanges, updatedAt: existing.updatedAt }
382
385
  : stamped);
386
+ this.opts.objectPool.watermarks.advance(existing, origin.position);
383
387
  return existing;
384
388
  }
385
389
  return null;
@@ -415,7 +419,7 @@ export class OnDemandLoader {
415
419
  // that disagrees with the schema's: these rows were returned FOR this
416
420
  // model's query, so the schema typename is correct by construction — and
417
421
  // without stripping it, the spread would put the row's variant (a server
418
- // echoing the schema KEY `tasks` instead of the typename `Task`) back on
422
+ // echoing the schema KEY `items` instead of the typename `Item`) back on
419
423
  // top of the stamp, sending hydration to the strict unknown-model error.
420
424
  const { _Typename: _dropMangled, __typename: _dropRowVariant, ...rest } = obj;
421
425
  void _dropMangled;
@@ -440,6 +444,9 @@ export class OnDemandLoader {
440
444
  ? { related: options.expand }
441
445
  : {}),
442
446
  };
447
+ // Read before the request leaves: the server holds at least this much of
448
+ // the log when it answers, so it is the position the response reflects.
449
+ const position = this.opts.position.readFloor;
443
450
  const result = await postQuery({
444
451
  baseUrl: this.opts.baseUrl,
445
452
  getAuthToken: this.authTokenProvider ?? undefined,
@@ -464,9 +471,9 @@ export class OnDemandLoader {
464
471
  // own typed pool, then leave the nested arrays in place on the
465
472
  // primary row.
466
473
  if (options?.expand && options.expand.length > 0) {
467
- this.hydrateExpanded(modelName, normalized, options.expand);
474
+ this.hydrateExpanded(modelName, normalized, options.expand, position);
468
475
  }
469
- return { rows: normalized, evidence };
476
+ return { rows: normalized, evidence, position };
470
477
  }
471
478
  /**
472
479
  * Hydrate nested expanded rows. Resolves each relation's target
@@ -475,8 +482,11 @@ export class OnDemandLoader {
475
482
  * `__typename` field gets mangled by `postgres.camel` (`__typename`
476
483
  * → `_Typename`), so the SDK can't trust whatever string lands.
477
484
  */
478
- hydrateExpanded(parentModelName, rows, relationNames) {
485
+ hydrateExpanded(parentModelName, rows, relationNames, position) {
479
486
  const parentDef = this.getModelDef(parentModelName);
487
+ // Nested rows carry no evidence of their own; the read floor at issue
488
+ // time is what they provably reflect. A floor of zero says nothing.
489
+ const origin = { kind: 'network', position: position > 0 ? position : undefined };
480
490
  for (const row of rows) {
481
491
  if (!row || typeof row !== 'object')
482
492
  continue;
@@ -495,7 +505,7 @@ export class OnDemandLoader {
495
505
  for (const item of items) {
496
506
  const stamped = this.stampTypename(item, targetTypename);
497
507
  stampedItems.push(stamped);
498
- const m = this.hydrateOne(stamped);
508
+ const m = this.hydrateOne(stamped, origin);
499
509
  if (m)
500
510
  models.push(m);
501
511
  }
@@ -624,35 +634,10 @@ async function scanIdb(database, modelName, clauses) {
624
634
  return [];
625
635
  }
626
636
  }
627
- /**
628
- * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
629
- * tuple form used throughout `runFetch`. Tuple inputs pass through; object
630
- * inputs become one `['col', '=', val]` or `['col', 'IN', vals]` per key.
631
- *
632
- * Detection: an array whose first element is itself an array is treated
633
- * as tuple form. Object form is the fallback.
634
- *
635
- * Exported so callers can pre-normalize (e.g., for tests, or to inspect
636
- * the canonical clauses before passing them to `load`/`subscribe`).
637
- */
638
- export function normalizeWhere(where) {
639
- if (where == null)
640
- return [];
641
- if (Array.isArray(where)) {
642
- // Tuple form — assumed to already use server-side column names.
643
- return where;
644
- }
645
- if (typeof where === 'object') {
646
- const obj = where;
647
- return Object.entries(obj).map(([key, value]) => {
648
- if (Array.isArray(value)) {
649
- return [key, 'IN', value];
650
- }
651
- return [key, value];
652
- });
653
- }
654
- return [];
655
- }
637
+ // `normalizeWhere` lives with the grammar it produces, so both transports read
638
+ // the same one; re-exported here for callers that pre-normalize (tests, or
639
+ // inspecting the canonical clauses before `load`/`subscribe`).
640
+ export { normalizeWhere };
656
641
  /** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
657
642
  function extractEqClauses(clauses) {
658
643
  const out = {};
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { RuntimeContext } from '../RuntimeContext.js';
17
17
  import type { BootstrapResult } from '../Database.js';
18
+ import type { BootstrapSnapshot } from '../SyncClient.js';
18
19
  import type { SyncDelta } from './SyncWebSocket.js';
19
20
  /** Counts describing what applying a bootstrap changed in the pool: entities
20
21
  * added, updated, removed, skipped, and healed, plus the elapsed time. */
@@ -38,10 +39,7 @@ export interface PoolContext {
38
39
  /** Applies persisted delta results to the in-memory pool, with the host's relation enrichment bound. */
39
40
  applyDeltaBatchToPool(results: NonNullable<BootstrapResult['deltaResults']>): void;
40
41
  /** Writes bootstrap data into the pool: creates models, heals partial rows, upserts, and removes stale local copies the server no longer reports. */
41
- applyBootstrapDataToPool(bootstrapData: {
42
- models?: Record<string, unknown[]>;
43
- failedModels?: string[];
44
- }, protectedIds?: ReadonlySet<string>): {
42
+ applyBootstrapDataToPool(bootstrapData: BootstrapSnapshot, protectedIds?: ReadonlySet<string>): {
45
43
  added: number;
46
44
  updated: number;
47
45
  removed: number;
@@ -276,7 +276,7 @@ async function drainPendingDeltas(ctx) {
276
276
  // A sustained stream can refill the detached queue before every
277
277
  // persistence promise settles. Promise-only looping then forms an
278
278
  // unbounded microtask chain that starves WebSocket reads, timers and
279
- // replication keepalives. Give the host one macrotask turn between
279
+ // replication keepalives. Give the host one macroitem turn between
280
280
  // owned batches; Node has setImmediate, browsers fall back to a timer.
281
281
  await yieldToHost();
282
282
  }
@@ -43,7 +43,7 @@ export function* initialize(host, context, signal) {
43
43
  // Bootstrap from server if needed.
44
44
  //
45
45
  // `bootstrapMode: 'none'` participants (headless workers and
46
- // task runners) skip baseline replication — they read via
46
+ // item runners) skip baseline replication — they read via
47
47
  // `model.get()` round-trips and rely on covering deltas
48
48
  // from filtered subscriptions to populate the pool lazily. The
49
49
  // WS is already open by `setupWebSocketSync` above, so live
@@ -55,7 +55,7 @@ export function* initialize(host, context, signal) {
55
55
  // initiates the upgrade, but it does NOT await the 'connected'
56
56
  // event — it returns synchronously after wiring listeners.
57
57
  // For bootstrapMode='none' consumers (headless workers and
58
- // task runners), this branch is the entire body of initialize()
58
+ // item runners), this branch is the entire body of initialize()
59
59
  // after the WS is set up, so `ready()` would otherwise resolve
60
60
  // while the WS is still in 'connecting' state. The very next
61
61
  // `commits.create` then throws "SyncWebSocket not connected".
@@ -167,7 +167,7 @@ export declare class MutationQueue extends EventEmitter {
167
167
  /**
168
168
  * Relates stale notifications back to write targets without assuming the
169
169
  * server's canonical model name uses the same spelling as the public schema
170
- * key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
170
+ * key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
171
171
  * is the compatibility fallback. An ambiguous same-id cross-model mismatch
172
172
  * is deliberately left unclassified, so it cannot falsely settle a queued
173
173
  * write. A notification with no write-target id (or an explicit group) is a
@@ -417,7 +417,7 @@ export class MutationQueue extends EventEmitter {
417
417
  /**
418
418
  * Relates stale notifications back to write targets without assuming the
419
419
  * server's canonical model name uses the same spelling as the public schema
420
- * key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
420
+ * key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
421
421
  * is the compatibility fallback. An ambiguous same-id cross-model mismatch
422
422
  * is deliberately left unclassified, so it cannot falsely settle a queued
423
423
  * write. A notification with no write-target id (or an explicit group) is a
@@ -0,0 +1,39 @@
1
+ import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
2
+ type ModelData = Record<string, unknown>;
3
+ /** One mutation retained in the durable local transaction journal. */
4
+ interface PersistedMutation {
5
+ type: 'create' | 'update' | 'delete' | 'archive';
6
+ modelData: ModelData;
7
+ modelName: string;
8
+ timestamp: string;
9
+ writeOptions?: {
10
+ readAt?: number | null;
11
+ onStale?: OnStaleMode | null;
12
+ };
13
+ }
14
+ /**
15
+ * Persisted transaction for offline/retry support.
16
+ *
17
+ * The index signature is part of the contract: this targets the generic
18
+ * record-shaped storage layer (`InMemoryObjectStore.put` and its IndexedDB
19
+ * equivalent), both of which take `Record<string, unknown>`.
20
+ */
21
+ export interface PersistedTransaction {
22
+ id: string;
23
+ type?: string;
24
+ timestamp?: number;
25
+ createdAt?: number;
26
+ mutations?: PersistedMutation[];
27
+ awaitingDelta?: {
28
+ syncIdNeeded: number;
29
+ modelName: string;
30
+ modelId: string;
31
+ operationType: string;
32
+ };
33
+ [key: string]: unknown;
34
+ }
35
+ /** Compare the stable request identity while ignoring local seal timing. */
36
+ export declare function isSameOutboxRecord(existing: PersistedTransaction, candidate: PersistedTransaction): boolean;
37
+ /** An accepted envelope may replace the otherwise-identical pending envelope. */
38
+ export declare function isAcceptedOutboxPromotion(existing: PersistedTransaction | undefined, candidate: PersistedTransaction): boolean;
39
+ export {};
@@ -0,0 +1,53 @@
1
+ /** Compare the stable request identity while ignoring local seal timing. */
2
+ export function isSameOutboxRecord(existing, candidate) {
3
+ if (existing.type === 'http_commit_envelope' &&
4
+ candidate.type === 'http_commit_envelope') {
5
+ const identity = (record) => ({
6
+ id: record.id,
7
+ type: record.type,
8
+ storageVersion: record.storageVersion,
9
+ idempotencyKey: record.idempotencyKey,
10
+ // Pre-versioning HTTP outbox rows are v1. Normalizing them preserves
11
+ // idempotency when the same request is resealed after an upgrade.
12
+ protocolVersion: record.protocolVersion ?? 1,
13
+ request: record.request,
14
+ scopeNamespace: record.scopeNamespace,
15
+ });
16
+ if (existing.correlationId !== undefined &&
17
+ candidate.correlationId !== undefined &&
18
+ existing.correlationId !== candidate.correlationId) {
19
+ return false;
20
+ }
21
+ return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
22
+ }
23
+ if (existing.type === 'commit_envelope' &&
24
+ candidate.type === 'commit_envelope') {
25
+ const identity = (record) => ({
26
+ id: record.id,
27
+ type: record.type,
28
+ storageVersion: record.storageVersion,
29
+ origin: record.origin,
30
+ idempotencyKey: record.idempotencyKey,
31
+ operations: record.operations,
32
+ sourceMutationIds: record.sourceMutationIds,
33
+ commitOptions: record.commitOptions,
34
+ scope: record.scope,
35
+ });
36
+ if (existing.correlationId !== undefined &&
37
+ candidate.correlationId !== undefined &&
38
+ existing.correlationId !== candidate.correlationId) {
39
+ return false;
40
+ }
41
+ return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
42
+ }
43
+ return JSON.stringify(existing) === JSON.stringify(candidate);
44
+ }
45
+ /** An accepted envelope may replace the otherwise-identical pending envelope. */
46
+ export function isAcceptedOutboxPromotion(existing, candidate) {
47
+ return (existing !== undefined &&
48
+ (existing.type === 'commit_envelope' ||
49
+ existing.type === 'http_commit_envelope') &&
50
+ existing.type === candidate.type &&
51
+ existing.acceptedAt === undefined &&
52
+ candidate.acceptedAt !== undefined);
53
+ }
@@ -48,7 +48,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
48
48
  return false;
49
49
  };
50
50
  // Skip if target has its own observability setup
51
- // This allows models like Task to handle their own MobX setup
51
+ // This allows models like Item to handle their own MobX setup
52
52
  if (target.setupObservability || target._hasCustomObservability) {
53
53
  getContext().modelDebugLogger?.logDebug(`${target.constructor.name} has custom observability, skipping M1`);
54
54
  return;
package/dist/plugin.d.ts CHANGED
@@ -77,6 +77,13 @@ export interface AppliedChange {
77
77
  * no client transaction behind them.
78
78
  */
79
79
  transactionId?: string;
80
+ /**
81
+ * The log position of the delta this change answers — its `sync_deltas` id.
82
+ * The apply stage records it per row so a later snapshot can be judged
83
+ * against what the row already reflects. Absent when the source carried no
84
+ * position.
85
+ */
86
+ syncId?: number;
80
87
  }
81
88
  /**
82
89
  * What each stage hands its handlers. Read off the delta pipeline these
@@ -206,10 +206,10 @@ export declare function usePeers(scope?: ParticipantScope): readonly Peer[];
206
206
  /**
207
207
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
208
208
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
209
- * where you need direct access (e.g., `sync.tasks.onChange(cb)`).
209
+ * where you need direct access (e.g., `sync.items.onChange(cb)`).
210
210
  *
211
211
  * The generic parameter narrows the return type to your schema's
212
- * model record so call sites get typed `sync.tasks.findMany()` /
212
+ * model record so call sites get typed `sync.items.findMany()` /
213
213
  * `sync.sections.create(...)` without a cast at the call site:
214
214
  *
215
215
  * ```ts
@@ -400,10 +400,10 @@ export function usePeers(scope) {
400
400
  /**
401
401
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
402
402
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
403
- * where you need direct access (e.g., `sync.tasks.onChange(cb)`).
403
+ * where you need direct access (e.g., `sync.items.onChange(cb)`).
404
404
  *
405
405
  * The generic parameter narrows the return type to your schema's
406
- * model record so call sites get typed `sync.tasks.findMany()` /
406
+ * model record so call sites get typed `sync.items.findMany()` /
407
407
  * `sync.sections.create(...)` without a cast at the call site:
408
408
  *
409
409
  * ```ts
@@ -8,7 +8,7 @@ export interface SyncReactContext {
8
8
  organizationId: string;
9
9
  /**
10
10
  * An optional schema. When provided, hooks that take a model by name (such as
11
- * `useQuery('tasks')`) read that model's metadata from this schema, so
11
+ * `useQuery('items')`) read that model's metadata from this schema, so
12
12
  * callers don't pass a schema at every call site. When omitted, those hooks
13
13
  * require the schema as an argument instead.
14
14
  *
@@ -38,7 +38,7 @@ export interface SyncProviderProps {
38
38
  organizationId: string;
39
39
  /**
40
40
  * An optional schema. Provide it to enable hooks that take a model by name
41
- * (such as `useQuery('tasks')`); the model types also narrow through your
41
+ * (such as `useQuery('items')`); the model types also narrow through your
42
42
  * `Register` augmentation. Omit it to pass the schema to those hooks directly
43
43
  * instead.
44
44
  */
@@ -54,13 +54,13 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
54
54
  * // With the Register augmentation (recommended):
55
55
  * const ablo = useAblo();
56
56
  * if (!ablo) return <Loading />;
57
- * const doc = await ablo.documents.get({ id }); // async server read
57
+ * const doc = await ablo.records.get({ id }); // async server read
58
58
  *
59
59
  * // Reactive selector (a synchronous local snapshot). The selector's reads
60
60
  * // are typed as snapshot rows — data fields + computeds, no relation
61
61
  * // accessors — matching what the hook actually returns:
62
- * const doc = useAblo((ablo) => ablo.documents.local.get(id)) ?? serverDoc;
63
- * const active = useAblo((ablo) => ablo.documents.claim.state({ id }));
62
+ * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
63
+ * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
64
64
  *
65
65
  * // Without the augmentation, pass the schema as a type argument:
66
66
  * const ablo = useAblo<(typeof schema)['models']>();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.51.0",
87
+ "@abloatai/transaction": "^0.53.0",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"