@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.
@@ -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;
@@ -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. */
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
  /**