@abloatai/humans 0.52.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.
@@ -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;
@@ -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;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.52.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.52.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"
@@ -34,6 +34,23 @@ import {
34
34
  /** Generic record type for model data */
35
35
  type ModelData = Record<string, unknown>;
36
36
 
37
+ /**
38
+ * Carry each input delta's log position onto the change that answers it.
39
+ * `processDeltaBatch` builds its results index-aligned with its input, so the
40
+ * position is stamped once here rather than at every construction site.
41
+ */
42
+ function stampSyncIds(
43
+ results: AppliedChange[],
44
+ deltas: readonly { syncId?: number }[],
45
+ ): AppliedChange[] {
46
+ for (let index = 0; index < results.length; index++) {
47
+ const change = results[index];
48
+ const syncId = deltas[index]?.syncId;
49
+ if (change && typeof syncId === 'number') change.syncId = syncId;
50
+ }
51
+ return results;
52
+ }
53
+
37
54
  // Re-exported, not redeclared. `@abloatai/transaction`'s `types` module owns this
38
55
  // vocabulary and documents what each mode does; this package held a byte-identical
39
56
  // second copy while its own test fixtures already imported the canonical one.
@@ -1119,7 +1136,10 @@ export class Database {
1119
1136
  updatedAt: new Date(),
1120
1137
  };
1121
1138
  }
1122
- return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
1139
+ return {
1140
+ results: stampSyncIds(inMemResults, deltas),
1141
+ persistedSyncId: inMemPersistedSyncId,
1142
+ };
1123
1143
  }
1124
1144
 
1125
1145
  // Prepare results aligned with input order
@@ -1540,7 +1560,7 @@ export class Database {
1540
1560
  });
1541
1561
  }
1542
1562
 
1543
- return { results, persistedSyncId: highestPersistedSyncId };
1563
+ return { results: stampSyncIds(results, deltas), persistedSyncId: highestPersistedSyncId };
1544
1564
  }
1545
1565
 
1546
1566
  /** Get raw data for hydration */
@@ -16,6 +16,7 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
16
16
  import { ModelScope, PropertyType } from '@abloatai/transaction/types';
17
17
  import { ViewRegistry } from './views/ViewRegistry.js';
18
18
  import { QueryView, type QueryViewOptions } from './views/QueryView.js';
19
+ import { RowWatermarks } from './rowWatermarks.js';
19
20
 
20
21
  /** Constructor type for Model subclasses - uses abstract to handle variance */
21
22
  type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
@@ -124,6 +125,15 @@ export class InstanceCache {
124
125
  // ViewRegistry — tracks active QueryViews for incremental view maintenance
125
126
  readonly viewRegistry: ViewRegistry = new ViewRegistry();
126
127
 
128
+ /**
129
+ * The log position each pooled row is known to reflect. Every door a row
130
+ * enters through (delta, own ack, bootstrap, server read) advances it, and
131
+ * every snapshot that would overwrite a resident row is judged against it —
132
+ * see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
133
+ * pooled model.
134
+ */
135
+ readonly watermarks = new RowWatermarks();
136
+
127
137
  // Subscription registry
128
138
  private subscriptions = new Map<string, Set<(model: Model) => void>>();
129
139
 
@@ -34,7 +34,9 @@ import {
34
34
  type UnconfirmedWritesMetrics,
35
35
  } from './transactions/mutations/UnconfirmedWrites.js';
36
36
  import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
37
+ import type { CommitTransaction } from './transactions/mutations/commitLane.js';
37
38
  import type { Database } from './Database.js';
39
+ import type { BootstrapData } from './sync/BootstrapFetcher.js';
38
40
  import type { MutationPersistencePort } from './mutationPersistence.js';
39
41
  import type { WriteOptions } from './interfaces/index.js';
40
42
  import { LogPosition } from './logPosition.js';
@@ -73,30 +75,29 @@ export interface RehydrationStats {
73
75
  type EventHandler = () => void;
74
76
 
75
77
  /**
76
- * Reports whether an incoming snapshot record is strictly newer than the
77
- * model already in the pool. The comparison uses the server-stamped
78
- * `updatedAt` timestamp, since rows carry no numeric version and the delta
79
- * pipeline resolves order by arrival (last write wins). An undefined incoming
80
- * timestamp counts as not newer, so a known row is never clobbered; an
81
- * undefined existing timestamp means the pooled row is unversioned, so the
82
- * incoming record wins. The scoped hydrate-on-enter path uses this to drop
83
- * snapshot rows that a live delta has already advanced past.
78
+ * The slice of a bootstrap answer the pool applies: its rows, the models whose
79
+ * server query failed, and the log position the snapshot was taken at — the
80
+ * position every row in it reflects. `lastSyncId` is optional only for callers
81
+ * applying rows with no snapshot position to speak of; the fetcher always
82
+ * names one.
84
83
  */
85
- function rawRecordIsNewer(data: Record<string, unknown>, existing: Model): boolean {
86
- const raw = data.updatedAt;
87
- const inMs =
88
- raw instanceof Date
89
- ? raw.getTime()
90
- : typeof raw === 'string'
91
- ? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
92
- : typeof raw === 'number'
93
- ? raw
94
- : undefined;
95
- const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
96
- if (inMs === undefined) return false;
97
- if (exMs === undefined) return true;
98
- return inMs > exMs;
99
- }
84
+ export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> &
85
+ Partial<Pick<BootstrapData, 'lastSyncId'>>;
86
+
87
+ /**
88
+ * What `transaction:completed` carries: a model mutation (one row, confirmed
89
+ * at `syncIdNeededForCompletion`) or an explicit commit (one row per operation,
90
+ * confirmed at `lastSyncId`). Each arm projects its own queue record.
91
+ */
92
+ type CompletedTransaction =
93
+ | (Pick<QueuedMutation, 'id' | 'modelId' | 'syncIdNeededForCompletion'> & {
94
+ lastSyncId?: undefined;
95
+ operations?: undefined;
96
+ })
97
+ | (Pick<CommitTransaction, 'id' | 'lastSyncId' | 'operations'> & {
98
+ modelId?: undefined;
99
+ syncIdNeededForCompletion?: undefined;
100
+ });
100
101
 
101
102
  /**
102
103
  * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
@@ -464,12 +465,18 @@ export class SyncClient extends EventEmitter {
464
465
  }
465
466
  );
466
467
 
467
- // Clean up persisted awaiting transactions when they're finally confirmed
468
+ // Clean up persisted awaiting transactions when they're finally confirmed,
469
+ // and record the confirmed position on every row the transaction wrote.
470
+ // The acknowledgement is the earliest proof of where this client's own
471
+ // write landed in the log — earlier than its delta echo, which the pool
472
+ // suppresses on apply — so a snapshot read before the write cannot regress
473
+ // the row in the window between the two.
468
474
  this.mutationQueue.on(
469
475
  'transaction:completed',
470
- (tx: { id: string; modelName: string; modelId: string }) => {
476
+ (tx: CompletedTransaction) => {
471
477
  // void is safe: the handler's body is fully try/catch'd.
472
478
  void this.removeAwaitingTransaction(tx.id);
479
+ this.noteOwnWritePositions(tx);
473
480
  }
474
481
  );
475
482
 
@@ -495,6 +502,23 @@ export class SyncClient extends EventEmitter {
495
502
  );
496
503
  }
497
504
 
505
+ /**
506
+ * Advance the pooled rows a completed transaction wrote to the log position
507
+ * its acknowledgement named. A model mutation names one row; an explicit
508
+ * commit names one per operation. Rows no longer pooled have nothing to
509
+ * advance — a fresh instance starts without evidence.
510
+ */
511
+ private noteOwnWritePositions(tx: CompletedTransaction): void {
512
+ const position = tx.lastSyncId ?? tx.syncIdNeededForCompletion;
513
+ if (position === undefined) return;
514
+ const rowIds =
515
+ tx.operations !== undefined ? tx.operations.map((op) => op.id) : [tx.modelId];
516
+ for (const rowId of rowIds) {
517
+ const row = this.objectPool.peek(rowId);
518
+ if (row) this.objectPool.watermarks.advance(row, position);
519
+ }
520
+ }
521
+
498
522
  /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
499
523
  private async persistAwaitingTransaction(event: {
500
524
  txId: string;
@@ -1952,7 +1976,13 @@ export class SyncClient extends EventEmitter {
1952
1976
  }
1953
1977
 
1954
1978
  for (const result of dbResults) {
1955
- const { modelName, modelId, action, transactionId } = result;
1979
+ const { modelName, modelId, action, transactionId, syncId } = result;
1980
+
1981
+ // Every delta names the log position the row now reflects — recorded
1982
+ // before echo detection, because an own echo is exactly a position the
1983
+ // pooled row has reached even though its fields are not re-applied.
1984
+ const resident = this.objectPool.peek(modelId);
1985
+ if (resident) this.objectPool.watermarks.advance(resident, syncId);
1956
1986
 
1957
1987
  // Echo detection: if this delta carries a transaction id that matches
1958
1988
  // one already applied optimistically, the pool already reflects the
@@ -1990,7 +2020,10 @@ export class SyncClient extends EventEmitter {
1990
2020
  const model = this.objectPool.createFromData(data, undefined, {
1991
2021
  deferObservability: true,
1992
2022
  });
1993
- if (model) modelsToAdd.push(model);
2023
+ if (model) {
2024
+ this.objectPool.watermarks.advance(model, syncId);
2025
+ modelsToAdd.push(model);
2026
+ }
1994
2027
  }
1995
2028
  break;
1996
2029
  }
@@ -2058,17 +2091,18 @@ export class SyncClient extends EventEmitter {
2058
2091
  * Owns: model creation, batch upsert, ghost detection + removal.
2059
2092
  */
2060
2093
  applyBootstrapDataToPool(
2061
- bootstrapData: { models?: Record<string, unknown[]>; failedModels?: string[] },
2094
+ bootstrapData: BootstrapSnapshot,
2062
2095
  protectedIds?: ReadonlySet<string>,
2063
2096
  options?: {
2064
2097
  /**
2065
2098
  * Scoped backfill for the hydrate-on-enter path: the snapshot covers only
2066
2099
  * the groups just entered, not the whole model type. Two behaviors change
2067
- * so the subset cannot corrupt the pool. First, the upsert is
2068
- * version-guarded ({@link InstanceCache.upsertIfNewer}) so a concurrent live
2069
- * delta is not clobbered back to the snapshot version. Second, ghost
2070
- * removal is skipped, because a subset snapshot must never evict rows of
2071
- * the same type that belong to other, unhydrated groups.
2100
+ * so the subset cannot corrupt the pool. First, a row the pool already
2101
+ * knows to reflect a position beyond the snapshot's `lastSyncId` is
2102
+ * skipped, so a concurrent live delta is not clobbered back to the
2103
+ * snapshot version. Second, ghost removal is skipped, because a subset
2104
+ * snapshot must never evict rows of the same type that belong to other,
2105
+ * unhydrated groups.
2072
2106
  */
2073
2107
  scoped?: boolean;
2074
2108
  },
@@ -2076,6 +2110,7 @@ export class SyncClient extends EventEmitter {
2076
2110
  if (!bootstrapData.models) {
2077
2111
  return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
2078
2112
  }
2113
+ const snapshotPosition = bootstrapData.lastSyncId;
2079
2114
 
2080
2115
  const allModels: Model[] = [];
2081
2116
  const serverIdsByType = new Map<string, Set<string>>();
@@ -2110,16 +2145,22 @@ export class SyncClient extends EventEmitter {
2110
2145
  // taken at a server watermark. If a concurrent live delta already
2111
2146
  // advanced this row past the snapshot, skip it. `createFromData`
2112
2147
  // mutates the pooled model in place to keep instances alive, so this
2113
- // version guard has to run before it; a guard at the upsert layer would
2114
- // be too late, because the row would already be clobbered.
2148
+ // guard has to run before it; a guard at the upsert layer would be too
2149
+ // late, because the row would already be clobbered.
2115
2150
  if (options?.scoped && recordId) {
2116
- const existing = this.objectPool.get(recordId);
2117
- if (existing && !rawRecordIsNewer(data, existing)) { skippedCount++; continue; }
2151
+ const existing = this.objectPool.peek(recordId);
2152
+ if (existing && this.objectPool.watermarks.isAheadOf(existing, snapshotPosition)) {
2153
+ skippedCount++;
2154
+ continue;
2155
+ }
2118
2156
  }
2119
2157
 
2120
2158
  try {
2121
2159
  const model = this.objectPool.createFromData(data);
2122
- if (model) allModels.push(model);
2160
+ if (model) {
2161
+ this.objectPool.watermarks.advance(model, snapshotPosition);
2162
+ allModels.push(model);
2163
+ }
2123
2164
  } catch {
2124
2165
  skippedCount++;
2125
2166
  }
@@ -121,6 +121,10 @@ export function createInternalComponents<S extends SchemaRecord>(
121
121
  baseUrl: bootstrapBaseUrl,
122
122
  getAuthToken: auth?.getAuthToken,
123
123
  runtime,
124
+ // The one canonical log position; the loader reads its floor when a query
125
+ // leaves so a late answer cannot overwrite a row the pool already knows to
126
+ // be further along.
127
+ position: syncClient.position,
124
128
  });
125
129
 
126
130
  // Drop the lazy-lane hydration ledger on reconnect. While connected, the
@@ -38,6 +38,7 @@ import {
38
38
  startClaimHeartbeatLoop,
39
39
  } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
40
40
  import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
41
+ import { modelList, type ModelList } from '@abloatai/transaction/resources/httpResources';
41
42
  import { subTarget } from '@abloatai/transaction/coordination';
42
43
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
43
44
  // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
@@ -504,6 +505,18 @@ export function createModelProxy<T, C>(
504
505
  };
505
506
 
506
507
  const load = async (options?: ServerReadOptions<T>): Promise<T[]> => {
508
+ if (options?.cursor !== undefined) {
509
+ // The live client hydrates a working set into the local graph rather than
510
+ // handing back pages, so there is no cursor for this read to resume from.
511
+ // Accepting the option and ignoring it would return page one every time
512
+ // while the caller believed it was advancing.
513
+ throw new AbloValidationError(
514
+ '`cursor` resumes a page of the stateless read. This client keeps a ' +
515
+ 'local graph and loads a working set instead of pages: narrow the ' +
516
+ '`where`, or construct the client with `transport: \'http\'` to page.',
517
+ { code: 'invalid_options', param: 'cursor' },
518
+ );
519
+ }
507
520
  const rows = await hydration.fetch<T>(schemaKey, options);
508
521
  return rows.map((row) => modelAsRow<T>(row));
509
522
  };
@@ -1146,10 +1159,18 @@ export function createModelProxy<T, C>(
1146
1159
 
1147
1160
  const list = guard(async (
1148
1161
  options?: ServerReadOptions<T>,
1149
- ): Promise<CapturedRow<T>[]> => {
1162
+ ): Promise<ModelList<CapturedRow<T>>> => {
1150
1163
  const registry = readSetContext?.getStore();
1151
1164
  const rows = await load(options);
1152
- if (!registry) return rows as CapturedRow<T>[];
1165
+ // This transport loads a working set rather than pages, so there is no
1166
+ // cursor to hand back. `limit` can still cut the set short, and a full
1167
+ // count is exactly the case where the caller cannot tell: report it rather
1168
+ // than claim completeness this read cannot vouch for.
1169
+ const page = modelList<CapturedRow<T>>(rows as CapturedRow<T>[], {
1170
+ hasMore: options?.limit !== undefined && rows.length >= options.limit,
1171
+ nextCursor: null,
1172
+ });
1173
+ if (!registry) return page;
1153
1174
  for (const row of rows) {
1154
1175
  const stamp = hydration.getReadEvidence?.(row as object);
1155
1176
  if (stamp === undefined) {
@@ -1174,7 +1195,7 @@ export function createModelProxy<T, C>(
1174
1195
  stamp,
1175
1196
  );
1176
1197
  }
1177
- return rows as CapturedRow<T>[];
1198
+ return page;
1178
1199
  });
1179
1200
 
1180
1201
  const operations: ModelOperations<T, C> = {