@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,54 @@
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
+
26
+ export class RowWatermarks {
27
+ readonly #positions = new WeakMap<object, number>();
28
+
29
+ /** Record that `row`'s pooled copy reflects the log at least through `position`. */
30
+ advance(row: object, position: number | undefined): void {
31
+ if (position === undefined || !(position > 0)) return;
32
+ const known = this.#positions.get(row);
33
+ if (known === undefined || position > known) this.#positions.set(row, position);
34
+ }
35
+
36
+ /** The highest log position `row` is known to reflect, if the client has any evidence. */
37
+ of(row: object): number | undefined {
38
+ return this.#positions.get(row);
39
+ }
40
+
41
+ /**
42
+ * Whether the pooled copy of `row` is known to be ahead of a snapshot that
43
+ * reflects the log through `snapshotPosition`. `snapshotPosition` is a lower
44
+ * bound: the position the snapshot provably includes (a row's evidence stamp,
45
+ * a bootstrap's `lastSyncId`, or the client's own read floor at the moment
46
+ * the read was issued — the server had at least that much when it answered).
47
+ * A snapshot with no known position is never judged stale.
48
+ */
49
+ isAheadOf(row: object, snapshotPosition: number | undefined): boolean {
50
+ if (snapshotPosition === undefined) return false;
51
+ const known = this.#positions.get(row);
52
+ return known !== undefined && known > snapshotPosition;
53
+ }
54
+ }
@@ -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
 
24
31
  import type { InstanceCache } from '../InstanceCache.js';
@@ -30,12 +37,18 @@ import type { ModelRegistry, RegisteredModelClass } from '../ModelRegistry.js';
30
37
  import type { RuntimeContext } from '../RuntimeContext.js';
31
38
  import { postQuery } from '../query/client.js';
32
39
  import type { RecoveryClass } from '@abloatai/transaction/errorCodes';
33
- import type { LoadWhere, Query, WhereClause, WhereOp, WherePrimitive } from '../query/types.js';
40
+ import type { LoadWhere, Query, WhereClause, WhereOp } from '../query/types.js';
41
+ import { normalizeWhere } from '@abloatai/transaction/resources/where';
34
42
  import type { Schema } from '@abloatai/transaction/schema/schema';
43
+ import type { LogPositionPort } from '../logPosition.js';
35
44
 
36
45
  export interface OnDemandLoaderOptions {
37
46
  readonly objectPool: InstanceCache;
38
- readonly database: Database;
47
+ /**
48
+ * The local tier reads and writes rows through a model's store, so store
49
+ * lookup is the whole of the loader's dependency on the database.
50
+ */
51
+ readonly database: Pick<Database, 'getStore'>;
39
52
  readonly registry: ModelRegistry;
40
53
  readonly schema: Schema;
41
54
  /** Bootstrap base URL (without trailing slash), e.g. `https://api.example.com/api`. */
@@ -49,6 +62,13 @@ export interface OnDemandLoaderOptions {
49
62
  readonly getCapabilityToken?: () => string | null;
50
63
  /** The owning client's runtime. Defaults to the module-global bridge. */
51
64
  readonly runtime?: RuntimeContext;
65
+ /**
66
+ * The client's position in the log. Read at the moment a query is issued:
67
+ * the server holds at least that much when it answers, so it is the position
68
+ * every returned row provably reflects — the bound a resident row is judged
69
+ * against before a snapshot may overwrite it (see {@link RowWatermarks}).
70
+ */
71
+ readonly position: Pick<LogPositionPort, 'readFloor'>;
52
72
  }
53
73
 
54
74
  export interface FetchOptions<T> {
@@ -96,28 +116,39 @@ interface SchemaModelDef {
96
116
  >;
97
117
  }
98
118
 
99
- function timestampMs(value: unknown): number | undefined {
100
- if (value instanceof Date) {
101
- const timestamp = value.getTime();
102
- return Number.isFinite(timestamp) ? timestamp : undefined;
103
- }
104
- if (typeof value === 'number' && Number.isFinite(value)) return value;
105
- if (typeof value !== 'string') return undefined;
106
- const parsed = Date.parse(value);
107
- return Number.isNaN(parsed) ? undefined : parsed;
108
- }
119
+ /**
120
+ * Where a row being hydrated came from, and what that says about how it may
121
+ * meet a row already in the pool.
122
+ *
123
+ * A network snapshot is unordered against the delta stream: it may have been
124
+ * issued before a write and answered after it. It carries the position it
125
+ * provably reflects, and a resident row known to be beyond that position is
126
+ * left as it is. A local read (IndexedDB) exists to fill pool misses; the pool
127
+ * is never behind local storage for a row it already holds, so a resident row
128
+ * is kept untouched.
129
+ */
130
+ type HydrationOrigin =
131
+ | { readonly kind: 'network'; readonly position: number | undefined }
132
+ | { readonly kind: 'local' };
133
+
134
+ const LOCAL: HydrationOrigin = { kind: 'local' };
109
135
 
110
136
  /**
111
- * A query response is a snapshot, not an ordered delta. It may have started
112
- * before a local optimistic write and completed after it. Only treat the row
113
- * as authoritative over an existing model when its server timestamp is newer;
114
- * missing timestamps retain the legacy merge behavior for custom sources that
115
- * do not expose `updatedAt`.
137
+ * The position a returned row provably reflects: the greater of its own
138
+ * evidence stamp (the row's watermark, which lags for a row that has not
139
+ * changed in a while) and the client's read floor when the query was issued
140
+ * (which the server had already passed when it answered). Both are lower
141
+ * bounds; the tighter one judges. `undefined` when neither says anything.
116
142
  */
117
- function snapshotDoesNotAdvanceModel(data: Record<string, unknown>, model: Model): boolean {
118
- const incoming = timestampMs(data.updatedAt);
119
- const resident = timestampMs(model.updatedAt);
120
- return incoming !== undefined && resident !== undefined && incoming <= resident;
143
+ function snapshotPosition(
144
+ raw: unknown,
145
+ evidenceById: ReadonlyMap<string, number>,
146
+ readFloorAtIssue: number,
147
+ ): number | undefined {
148
+ const id = raw && typeof raw === 'object' ? (raw as { id?: unknown }).id : undefined;
149
+ const stamp = typeof id === 'string' ? (evidenceById.get(id) ?? 0) : 0;
150
+ const position = Math.max(stamp, readFloorAtIssue);
151
+ return position > 0 ? position : undefined;
121
152
  }
122
153
 
123
154
  export class OnDemandLoader {
@@ -308,7 +339,7 @@ export class OnDemandLoader {
308
339
  if (local.length === 0) {
309
340
  const fromIdb = await scanIdb(this.opts.database, typename, clauses);
310
341
  const idbModels = fromIdb
311
- .map((raw) => this.hydrateOne(raw, typename))
342
+ .map((raw) => this.hydrateOne(raw, LOCAL, typename))
312
343
  .filter((m): m is Model => m !== null);
313
344
  if (idbModels.length > 0) {
314
345
  this.opts.objectPool.addBatch(idbModels, ModelScope.live);
@@ -345,18 +376,29 @@ export class OnDemandLoader {
345
376
  ): Promise<Model[]> {
346
377
  const network = await this.queryNetwork(modelName, clauses, options);
347
378
  const networkRows = network.rows;
379
+ const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
348
380
  const networkModels = networkRows
349
381
  // Strict: a row the server returned whose type name this client never
350
382
  // registered is a genuine schema collision (the pushed schema differs
351
383
  // from the local one). Throw here, naming the cause, rather than silently
352
384
  // dropping the row and failing downstream as `entity_not_found`.
353
- .map((raw) => this.hydrateOne(raw, typename, { strict: true }))
385
+ .map((raw) =>
386
+ this.hydrateOne(
387
+ raw,
388
+ { kind: 'network', position: snapshotPosition(raw, evidenceById, network.position) },
389
+ typename,
390
+ { strict: true },
391
+ ),
392
+ )
354
393
  .filter((m): m is Model => m !== null);
355
394
 
356
- const evidenceById = new Map(network.evidence.map((entry) => [entry.id, entry.stamp]));
357
395
  for (const model of networkModels) {
358
396
  const stamp = evidenceById.get(model.id);
359
- if (stamp !== undefined) this.readEvidence.set(model, stamp);
397
+ if (stamp === undefined) continue;
398
+ // The read's evidence, kept for the premise a guarded write may cite;
399
+ // and the position the pooled row now reflects, for freshness.
400
+ this.readEvidence.set(model, stamp);
401
+ this.opts.objectPool.watermarks.advance(model, stamp);
360
402
  }
361
403
 
362
404
  if (networkModels.length > 0) {
@@ -437,7 +479,7 @@ export class OnDemandLoader {
437
479
 
438
480
  const rows = await this.readChildrenLocal(targetTypename, foreignKey, missing);
439
481
  const models = rows
440
- .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), targetTypename))
482
+ .map((raw) => this.hydrateOne(this.stampTypename(raw, targetTypename), LOCAL, targetTypename))
441
483
  .filter((m): m is Model => m !== null);
442
484
  if (models.length > 0) {
443
485
  this.opts.objectPool.addBatch(models, ModelScope.live);
@@ -492,6 +534,7 @@ export class OnDemandLoader {
492
534
 
493
535
  private hydrateOne(
494
536
  raw: unknown,
537
+ origin: HydrationOrigin,
495
538
  typename?: string,
496
539
  opts?: { strict?: boolean },
497
540
  ): Model | null {
@@ -502,26 +545,27 @@ export class OnDemandLoader {
502
545
  // Keep the existing instance alive when a query refreshes it. A query
503
546
  // can carry fresher server state after a missed delta, but unlike the
504
547
  // ordered delta stream it can also finish late with an older snapshot;
505
- // the reconciliation below distinguishes those cases before applying.
548
+ // the origin decides which before anything is applied.
506
549
  const existing = this.opts.objectPool.get(obj.id);
507
550
  if (existing) {
551
+ if (origin.kind === 'local') return existing;
552
+ // A request that began before an optimistic write can return afterward
553
+ // with the old row; applying it would visibly snap the live model
554
+ // back, and the matching authoritative delta cannot repair it because
555
+ // own echoes are suppressed. The pool knows the position the row
556
+ // already reflects; a snapshot from before it is left unapplied.
557
+ if (this.opts.objectPool.watermarks.isAheadOf(existing, origin.position)) return existing;
558
+
508
559
  const stamped = this.stampTypename(obj, typename) as Record<string, unknown>;
509
- // Network queries are unordered snapshots. A request that began before
510
- // an optimistic resize can return afterward with the old row; applying
511
- // it here would visibly snap the live model back, and the matching
512
- // authoritative delta cannot repair it because own echoes are
513
- // intentionally suppressed. Keep a newer resident row intact.
514
- if (snapshotDoesNotAdvanceModel(stamped, existing)) return existing;
515
-
516
- // If the source has no comparable timestamp, retain pending local
517
- // fields while accepting unrelated server fields. This is the same
518
- // local-first merge contract used by SyncClient's delta resolver.
560
+ // Retain pending local fields while accepting the server's others
561
+ // the same local-first merge contract SyncClient's delta resolver uses.
519
562
  const localChanges = existing.getChanges();
520
563
  existing.updateFromData(
521
564
  Object.keys(localChanges).length > 0
522
565
  ? { ...stamped, ...localChanges, updatedAt: existing.updatedAt }
523
566
  : stamped,
524
567
  );
568
+ this.opts.objectPool.watermarks.advance(existing, origin.position);
525
569
  return existing;
526
570
  }
527
571
  return null;
@@ -556,7 +600,7 @@ export class OnDemandLoader {
556
600
  // that disagrees with the schema's: these rows were returned FOR this
557
601
  // model's query, so the schema typename is correct by construction — and
558
602
  // without stripping it, the spread would put the row's variant (a server
559
- // echoing the schema KEY `tasks` instead of the typename `Task`) back on
603
+ // echoing the schema KEY `items` instead of the typename `Item`) back on
560
604
  // top of the stamp, sending hydration to the strict unknown-model error.
561
605
  const { _Typename: _dropMangled, __typename: _dropRowVariant, ...rest } = obj as Record<
562
606
  string,
@@ -574,6 +618,8 @@ export class OnDemandLoader {
574
618
  ): Promise<{
575
619
  rows: unknown[];
576
620
  evidence: readonly { id: string; stamp: number }[];
621
+ /** The client's read floor when the query was issued — what every returned row provably reflects. */
622
+ position: number;
577
623
  }> {
578
624
  const typename = this.resolveTypename(modelName);
579
625
  const orderEntries = options?.orderBy ? Object.entries(options.orderBy) : [];
@@ -592,6 +638,9 @@ export class OnDemandLoader {
592
638
  ? { related: options.expand }
593
639
  : {}),
594
640
  };
641
+ // Read before the request leaves: the server holds at least this much of
642
+ // the log when it answers, so it is the position the response reflects.
643
+ const position = this.opts.position.readFloor;
595
644
  const result = await postQuery(
596
645
  {
597
646
  baseUrl: this.opts.baseUrl,
@@ -620,9 +669,9 @@ export class OnDemandLoader {
620
669
  // own typed pool, then leave the nested arrays in place on the
621
670
  // primary row.
622
671
  if (options?.expand && options.expand.length > 0) {
623
- this.hydrateExpanded(modelName, normalized, options.expand);
672
+ this.hydrateExpanded(modelName, normalized, options.expand, position);
624
673
  }
625
- return { rows: normalized, evidence };
674
+ return { rows: normalized, evidence, position };
626
675
  }
627
676
 
628
677
  /**
@@ -636,8 +685,12 @@ export class OnDemandLoader {
636
685
  parentModelName: string,
637
686
  rows: unknown[],
638
687
  relationNames: readonly string[],
688
+ position: number,
639
689
  ): void {
640
690
  const parentDef = this.getModelDef(parentModelName);
691
+ // Nested rows carry no evidence of their own; the read floor at issue
692
+ // time is what they provably reflect. A floor of zero says nothing.
693
+ const origin: HydrationOrigin = { kind: 'network', position: position > 0 ? position : undefined };
641
694
 
642
695
  for (const row of rows) {
643
696
  if (!row || typeof row !== 'object') continue;
@@ -655,7 +708,7 @@ export class OnDemandLoader {
655
708
  for (const item of items) {
656
709
  const stamped = this.stampTypename(item, targetTypename);
657
710
  stampedItems.push(stamped);
658
- const m = this.hydrateOne(stamped);
711
+ const m = this.hydrateOne(stamped, origin);
659
712
  if (m) models.push(m);
660
713
  }
661
714
  if (models.length > 0) {
@@ -756,7 +809,7 @@ function scanPool(
756
809
  }
757
810
 
758
811
  async function scanIdb(
759
- database: Database,
812
+ database: Pick<Database, 'getStore'>,
760
813
  modelName: string,
761
814
  clauses: readonly WhereClause[],
762
815
  ): Promise<unknown[]> {
@@ -805,34 +858,10 @@ async function scanIdb(
805
858
  }
806
859
  }
807
860
 
808
- /**
809
- * Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
810
- * tuple form used throughout `runFetch`. Tuple inputs pass through; object
811
- * inputs become one `['col', '=', val]` or `['col', 'IN', vals]` per key.
812
- *
813
- * Detection: an array whose first element is itself an array is treated
814
- * as tuple form. Object form is the fallback.
815
- *
816
- * Exported so callers can pre-normalize (e.g., for tests, or to inspect
817
- * the canonical clauses before passing them to `load`/`subscribe`).
818
- */
819
- export function normalizeWhere(where: unknown): readonly WhereClause[] {
820
- if (where == null) return [];
821
- if (Array.isArray(where)) {
822
- // Tuple form — assumed to already use server-side column names.
823
- return where as readonly WhereClause[];
824
- }
825
- if (typeof where === 'object') {
826
- const obj = where as Record<string, unknown>;
827
- return Object.entries(obj).map(([key, value]) => {
828
- if (Array.isArray(value)) {
829
- return [key, 'IN', value as readonly WherePrimitive[]] as WhereClause;
830
- }
831
- return [key, value as WherePrimitive] as WhereClause;
832
- });
833
- }
834
- return [];
835
- }
861
+ // `normalizeWhere` lives with the grammar it produces, so both transports read
862
+ // the same one; re-exported here for callers that pre-normalize (tests, or
863
+ // inspecting the canonical clauses before `load`/`subscribe`).
864
+ export { normalizeWhere };
836
865
 
837
866
  /** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
838
867
  function extractEqClauses(clauses: readonly WhereClause[]): Record<string, unknown> {
@@ -17,6 +17,7 @@
17
17
  import { globalRuntime } from '../context.js';
18
18
  import type { RuntimeContext } from '../RuntimeContext.js';
19
19
  import type { BootstrapResult } from '../Database.js';
20
+ import type { BootstrapSnapshot } from '../SyncClient.js';
20
21
  import type { SyncDelta } from './SyncWebSocket.js';
21
22
 
22
23
  /** Counts describing what applying a bootstrap changed in the pool: entities
@@ -43,7 +44,7 @@ export interface PoolContext {
43
44
  applyDeltaBatchToPool(results: NonNullable<BootstrapResult['deltaResults']>): void;
44
45
  /** Writes bootstrap data into the pool: creates models, heals partial rows, upserts, and removes stale local copies the server no longer reports. */
45
46
  applyBootstrapDataToPool(
46
- bootstrapData: { models?: Record<string, unknown[]>; failedModels?: string[] },
47
+ bootstrapData: BootstrapSnapshot,
47
48
  protectedIds?: ReadonlySet<string>,
48
49
  ): { added: number; updated: number; removed: number; skipped: number; healed: number };
49
50
  /** Pool size — for the completion log line. */
@@ -396,7 +396,7 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
396
396
  // A sustained stream can refill the detached queue before every
397
397
  // persistence promise settles. Promise-only looping then forms an
398
398
  // unbounded microtask chain that starves WebSocket reads, timers and
399
- // replication keepalives. Give the host one macrotask turn between
399
+ // replication keepalives. Give the host one macroitem turn between
400
400
  // owned batches; Node has setImmediate, browsers fall back to a timer.
401
401
  await yieldToHost();
402
402
  }
@@ -92,7 +92,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
92
92
  // Bootstrap from server if needed.
93
93
  //
94
94
  // `bootstrapMode: 'none'` participants (headless workers and
95
- // task runners) skip baseline replication — they read via
95
+ // item runners) skip baseline replication — they read via
96
96
  // `model.get()` round-trips and rely on covering deltas
97
97
  // from filtered subscriptions to populate the pool lazily. The
98
98
  // WS is already open by `setupWebSocketSync` above, so live
@@ -110,7 +110,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
110
110
  // initiates the upgrade, but it does NOT await the 'connected'
111
111
  // event — it returns synchronously after wiring listeners.
112
112
  // For bootstrapMode='none' consumers (headless workers and
113
- // task runners), this branch is the entire body of initialize()
113
+ // item runners), this branch is the entire body of initialize()
114
114
  // after the WS is set up, so `ready()` would otherwise resolve
115
115
  // while the WS is still in 'connecting' state. The very next
116
116
  // `commits.create` then throws "SyncWebSocket not connected".
@@ -645,7 +645,7 @@ export class MutationQueue extends EventEmitter {
645
645
  /**
646
646
  * Relates stale notifications back to write targets without assuming the
647
647
  * server's canonical model name uses the same spelling as the public schema
648
- * key (`Task` versus `tasks`). Exact `(model,id)` wins; a globally unique id
648
+ * key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
649
649
  * is the compatibility fallback. An ambiguous same-id cross-model mismatch
650
650
  * is deliberately left unclassified, so it cannot falsely settle a queued
651
651
  * write. A notification with no write-target id (or an explicit group) is a
@@ -0,0 +1,112 @@
1
+ import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
2
+
3
+ type ModelData = Record<string, unknown>;
4
+
5
+ /** One mutation retained in the durable local transaction journal. */
6
+ interface PersistedMutation {
7
+ type: 'create' | 'update' | 'delete' | 'archive';
8
+ modelData: ModelData;
9
+ modelName: string;
10
+ timestamp: string;
11
+ writeOptions?: {
12
+ readAt?: number | null;
13
+ onStale?: OnStaleMode | null;
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Persisted transaction for offline/retry support.
19
+ *
20
+ * The index signature is part of the contract: this targets the generic
21
+ * record-shaped storage layer (`InMemoryObjectStore.put` and its IndexedDB
22
+ * equivalent), both of which take `Record<string, unknown>`.
23
+ */
24
+ export interface PersistedTransaction {
25
+ id: string;
26
+ type?: string;
27
+ timestamp?: number;
28
+ createdAt?: number;
29
+ mutations?: PersistedMutation[];
30
+ // Awaiting-delta transactions survive a tab close. Reconnect and delta
31
+ // catch-up confirm them during the next session.
32
+ awaitingDelta?: {
33
+ syncIdNeeded: number;
34
+ modelName: string;
35
+ modelId: string;
36
+ operationType: string;
37
+ };
38
+ [key: string]: unknown;
39
+ }
40
+
41
+ /** Compare the stable request identity while ignoring local seal timing. */
42
+ export function isSameOutboxRecord(
43
+ existing: PersistedTransaction,
44
+ candidate: PersistedTransaction,
45
+ ): boolean {
46
+ if (
47
+ existing.type === 'http_commit_envelope' &&
48
+ candidate.type === 'http_commit_envelope'
49
+ ) {
50
+ const identity = (record: PersistedTransaction): unknown => ({
51
+ id: record.id,
52
+ type: record.type,
53
+ storageVersion: record.storageVersion,
54
+ idempotencyKey: record.idempotencyKey,
55
+ // Pre-versioning HTTP outbox rows are v1. Normalizing them preserves
56
+ // idempotency when the same request is resealed after an upgrade.
57
+ protocolVersion: record.protocolVersion ?? 1,
58
+ request: record.request,
59
+ scopeNamespace: record.scopeNamespace,
60
+ });
61
+ if (
62
+ existing.correlationId !== undefined &&
63
+ candidate.correlationId !== undefined &&
64
+ existing.correlationId !== candidate.correlationId
65
+ ) {
66
+ return false;
67
+ }
68
+ return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
69
+ }
70
+
71
+ if (
72
+ existing.type === 'commit_envelope' &&
73
+ candidate.type === 'commit_envelope'
74
+ ) {
75
+ const identity = (record: PersistedTransaction): unknown => ({
76
+ id: record.id,
77
+ type: record.type,
78
+ storageVersion: record.storageVersion,
79
+ origin: record.origin,
80
+ idempotencyKey: record.idempotencyKey,
81
+ operations: record.operations,
82
+ sourceMutationIds: record.sourceMutationIds,
83
+ commitOptions: record.commitOptions,
84
+ scope: record.scope,
85
+ });
86
+ if (
87
+ existing.correlationId !== undefined &&
88
+ candidate.correlationId !== undefined &&
89
+ existing.correlationId !== candidate.correlationId
90
+ ) {
91
+ return false;
92
+ }
93
+ return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
94
+ }
95
+
96
+ return JSON.stringify(existing) === JSON.stringify(candidate);
97
+ }
98
+
99
+ /** An accepted envelope may replace the otherwise-identical pending envelope. */
100
+ export function isAcceptedOutboxPromotion(
101
+ existing: PersistedTransaction | undefined,
102
+ candidate: PersistedTransaction,
103
+ ): boolean {
104
+ return (
105
+ existing !== undefined &&
106
+ (existing.type === 'commit_envelope' ||
107
+ existing.type === 'http_commit_envelope') &&
108
+ existing.type === candidate.type &&
109
+ existing.acceptedAt === undefined &&
110
+ candidate.acceptedAt !== undefined
111
+ );
112
+ }
@@ -77,7 +77,7 @@ export function M1<T extends M1Target>(
77
77
  };
78
78
 
79
79
  // Skip if target has its own observability setup
80
- // This allows models like Task to handle their own MobX setup
80
+ // This allows models like Item to handle their own MobX setup
81
81
  if (target.setupObservability || target._hasCustomObservability) {
82
82
  getContext().modelDebugLogger?.logDebug(`${target.constructor.name} has custom observability, skipping M1`);
83
83
  return;
package/src/plugin.ts CHANGED
@@ -94,6 +94,13 @@ export interface AppliedChange {
94
94
  * no client transaction behind them.
95
95
  */
96
96
  transactionId?: string;
97
+ /**
98
+ * The log position of the delta this change answers — its `sync_deltas` id.
99
+ * The apply stage records it per row so a later snapshot can be judged
100
+ * against what the row already reflects. Absent when the source carried no
101
+ * position.
102
+ */
103
+ syncId?: number;
97
104
  }
98
105
 
99
106
  /**
@@ -675,10 +675,10 @@ export function usePeers(scope?: ParticipantScope): readonly Peer[] {
675
675
  /**
676
676
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
677
677
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
678
- * where you need direct access (e.g., `sync.tasks.onChange(cb)`).
678
+ * where you need direct access (e.g., `sync.items.onChange(cb)`).
679
679
  *
680
680
  * The generic parameter narrows the return type to your schema's
681
- * model record so call sites get typed `sync.tasks.findMany()` /
681
+ * model record so call sites get typed `sync.items.findMany()` /
682
682
  * `sync.sections.create(...)` without a cast at the call site:
683
683
  *
684
684
  * ```ts
@@ -19,7 +19,7 @@ export interface SyncReactContext {
19
19
  organizationId: string;
20
20
  /**
21
21
  * An optional schema. When provided, hooks that take a model by name (such as
22
- * `useQuery('tasks')`) read that model's metadata from this schema, so
22
+ * `useQuery('items')`) read that model's metadata from this schema, so
23
23
  * callers don't pass a schema at every call site. When omitted, those hooks
24
24
  * require the schema as an argument instead.
25
25
  *
@@ -60,7 +60,7 @@ export interface SyncProviderProps {
60
60
  organizationId: string;
61
61
  /**
62
62
  * An optional schema. Provide it to enable hooks that take a model by name
63
- * (such as `useQuery('tasks')`); the model types also narrow through your
63
+ * (such as `useQuery('items')`); the model types also narrow through your
64
64
  * `Register` augmentation. Omit it to pass the schema to those hooks directly
65
65
  * instead.
66
66
  */
@@ -138,13 +138,13 @@ function snapshotValue<T>(value: T): T {
138
138
  * // With the Register augmentation (recommended):
139
139
  * const ablo = useAblo();
140
140
  * if (!ablo) return <Loading />;
141
- * const doc = await ablo.documents.get({ id }); // async server read
141
+ * const doc = await ablo.records.get({ id }); // async server read
142
142
  *
143
143
  * // Reactive selector (a synchronous local snapshot). The selector's reads
144
144
  * // are typed as snapshot rows — data fields + computeds, no relation
145
145
  * // accessors — matching what the hook actually returns:
146
- * const doc = useAblo((ablo) => ablo.documents.local.get(id)) ?? serverDoc;
147
- * const active = useAblo((ablo) => ablo.documents.claim.state({ id }));
146
+ * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
147
+ * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
148
148
  *
149
149
  * // Without the augmentation, pass the schema as a type argument:
150
150
  * const ablo = useAblo<(typeof schema)['models']>();