@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.
@@ -16,6 +16,20 @@ import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
16
16
  import { logPositionSchema } from './logPosition.js';
17
17
  import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
18
18
  import { isAcceptedOutboxPromotion, isSameOutboxRecord, } from './transactions/persistedTransaction.js';
19
+ /**
20
+ * Carry each input delta's log position onto the change that answers it.
21
+ * `processDeltaBatch` builds its results index-aligned with its input, so the
22
+ * position is stamped once here rather than at every construction site.
23
+ */
24
+ function stampSyncIds(results, deltas) {
25
+ for (let index = 0; index < results.length; index++) {
26
+ const change = results[index];
27
+ const syncId = deltas[index]?.syncId;
28
+ if (change && typeof syncId === 'number')
29
+ change.syncId = syncId;
30
+ }
31
+ return results;
32
+ }
19
33
  export class Database {
20
34
  // Core database components
21
35
  databaseManager;
@@ -831,7 +845,10 @@ export class Database {
831
845
  updatedAt: new Date(),
832
846
  };
833
847
  }
834
- return { results: inMemResults, persistedSyncId: inMemPersistedSyncId };
848
+ return {
849
+ results: stampSyncIds(inMemResults, deltas),
850
+ persistedSyncId: inMemPersistedSyncId,
851
+ };
835
852
  }
836
853
  // Prepare results aligned with input order
837
854
  const results = new Array(deltas.length);
@@ -1186,7 +1203,7 @@ export class Database {
1186
1203
  gap: highestSyncId - highestPersistedSyncId,
1187
1204
  });
1188
1205
  }
1189
- return { results, persistedSyncId: highestPersistedSyncId };
1206
+ return { results: stampSyncIds(results, deltas), persistedSyncId: highestPersistedSyncId };
1190
1207
  }
1191
1208
  /** Get raw data for hydration */
1192
1209
  async hydrateModels(modelName) {
@@ -12,6 +12,7 @@ import type { RuntimeContext } from './RuntimeContext.js';
12
12
  import { ModelScope } from '@abloatai/transaction/types';
13
13
  import { ViewRegistry } from './views/ViewRegistry.js';
14
14
  import { QueryView, type QueryViewOptions } from './views/QueryView.js';
15
+ import { RowWatermarks } from './rowWatermarks.js';
15
16
  /** Constructor type for Model subclasses - uses abstract to handle variance */
16
17
  type ModelConstructor<T extends Model> = abstract new (...args: never[]) => T;
17
18
  export { ModelScope };
@@ -51,6 +52,14 @@ export declare class InstanceCache {
51
52
  private gcTimer?;
52
53
  readonly registry: ModelRegistry;
53
54
  readonly viewRegistry: ViewRegistry;
55
+ /**
56
+ * The log position each pooled row is known to reflect. Every door a row
57
+ * enters through (delta, own ack, bootstrap, server read) advances it, and
58
+ * every snapshot that would overwrite a resident row is judged against it —
59
+ * see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
60
+ * pooled model.
61
+ */
62
+ readonly watermarks: RowWatermarks;
54
63
  private subscriptions;
55
64
  /**
56
65
  * Subscribe to updates for a specific model type.
@@ -14,6 +14,7 @@ import { AbloValidationError } from '@abloatai/transaction/errors';
14
14
  import { ModelScope, PropertyType } from '@abloatai/transaction/types';
15
15
  import { ViewRegistry } from './views/ViewRegistry.js';
16
16
  import { QueryView } from './views/QueryView.js';
17
+ import { RowWatermarks } from './rowWatermarks.js';
17
18
  // Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
18
19
  export { ModelScope };
19
20
  /**
@@ -80,6 +81,14 @@ export class InstanceCache {
80
81
  registry;
81
82
  // ViewRegistry — tracks active QueryViews for incremental view maintenance
82
83
  viewRegistry = new ViewRegistry();
84
+ /**
85
+ * The log position each pooled row is known to reflect. Every door a row
86
+ * enters through (delta, own ack, bootstrap, server read) advances it, and
87
+ * every snapshot that would overwrite a resident row is judged against it —
88
+ * see {@link RowWatermarks}. Keyed by instance so it lives and dies with the
89
+ * pooled model.
90
+ */
91
+ watermarks = new RowWatermarks();
83
92
  // Subscription registry
84
93
  subscriptions = new Map();
85
94
  /**
@@ -17,6 +17,7 @@ import { type CommitLatencySample } from './transactions/mutations/commitLatency
17
17
  import { type UnconfirmedWritesMetrics } from './transactions/mutations/UnconfirmedWrites.js';
18
18
  import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
19
19
  import type { Database } from './Database.js';
20
+ import type { BootstrapData } from './sync/BootstrapFetcher.js';
20
21
  import type { WriteOptions } from './interfaces/index.js';
21
22
  import { LogPosition } from './logPosition.js';
22
23
  interface SyncObserver {
@@ -43,6 +44,14 @@ export interface RehydrationStats {
43
44
  healed: number;
44
45
  elapsedMs: number;
45
46
  }
47
+ /**
48
+ * The slice of a bootstrap answer the pool applies: its rows, the models whose
49
+ * server query failed, and the log position the snapshot was taken at — the
50
+ * position every row in it reflects. `lastSyncId` is optional only for callers
51
+ * applying rows with no snapshot position to speak of; the fetcher always
52
+ * names one.
53
+ */
54
+ export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
46
55
  export declare class SyncClient extends EventEmitter {
47
56
  private readonly runtime;
48
57
  private objectPool;
@@ -127,6 +136,13 @@ export declare class SyncClient extends EventEmitter {
127
136
  * deliver the missing deltas and confirm the transaction.
128
137
  */
129
138
  private setupAwaitingTransactionPersistence;
139
+ /**
140
+ * Advance the pooled rows a completed transaction wrote to the log position
141
+ * its acknowledgement named. A model mutation names one row; an explicit
142
+ * commit names one per operation. Rows no longer pooled have nothing to
143
+ * advance — a fresh instance starts without evidence.
144
+ */
145
+ private noteOwnWritePositions;
130
146
  /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
131
147
  private persistAwaitingTransaction;
132
148
  /** Drop the persisted awaiting-row once confirmed (never rejects). */
@@ -497,18 +513,16 @@ export declare class SyncClient extends EventEmitter {
497
513
  * Apply bootstrap data to the InstanceCache with ghost removal.
498
514
  * Owns: model creation, batch upsert, ghost detection + removal.
499
515
  */
500
- applyBootstrapDataToPool(bootstrapData: {
501
- models?: Record<string, unknown[]>;
502
- failedModels?: string[];
503
- }, protectedIds?: ReadonlySet<string>, options?: {
516
+ applyBootstrapDataToPool(bootstrapData: BootstrapSnapshot, protectedIds?: ReadonlySet<string>, options?: {
504
517
  /**
505
518
  * Scoped backfill for the hydrate-on-enter path: the snapshot covers only
506
519
  * the groups just entered, not the whole model type. Two behaviors change
507
- * so the subset cannot corrupt the pool. First, the upsert is
508
- * version-guarded ({@link InstanceCache.upsertIfNewer}) so a concurrent live
509
- * delta is not clobbered back to the snapshot version. Second, ghost
510
- * removal is skipped, because a subset snapshot must never evict rows of
511
- * the same type that belong to other, unhydrated groups.
520
+ * so the subset cannot corrupt the pool. First, a row the pool already
521
+ * knows to reflect a position beyond the snapshot's `lastSyncId` is
522
+ * skipped, so a concurrent live delta is not clobbered back to the
523
+ * snapshot version. Second, ghost removal is skipped, because a subset
524
+ * snapshot must never evict rows of the same type that belong to other,
525
+ * unhydrated groups.
512
526
  */
513
527
  scoped?: boolean;
514
528
  }): {
@@ -24,32 +24,6 @@ import { LogPosition } from './logPosition.js';
24
24
  import { createLocalMutationPort } from './transactions/localMutation.js';
25
25
  import { createReconnectDrain } from './transactions/reconnectDrain.js';
26
26
  import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
27
- /**
28
- * Reports whether an incoming snapshot record is strictly newer than the
29
- * model already in the pool. The comparison uses the server-stamped
30
- * `updatedAt` timestamp, since rows carry no numeric version and the delta
31
- * pipeline resolves order by arrival (last write wins). An undefined incoming
32
- * timestamp counts as not newer, so a known row is never clobbered; an
33
- * undefined existing timestamp means the pooled row is unversioned, so the
34
- * incoming record wins. The scoped hydrate-on-enter path uses this to drop
35
- * snapshot rows that a live delta has already advanced past.
36
- */
37
- function rawRecordIsNewer(data, existing) {
38
- const raw = data.updatedAt;
39
- const inMs = raw instanceof Date
40
- ? raw.getTime()
41
- : typeof raw === 'string'
42
- ? (Number.isNaN(Date.parse(raw)) ? undefined : Date.parse(raw))
43
- : typeof raw === 'number'
44
- ? raw
45
- : undefined;
46
- const exMs = existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : undefined;
47
- if (inMs === undefined)
48
- return false;
49
- if (exMs === undefined)
50
- return true;
51
- return inMs > exMs;
52
- }
53
27
  /**
54
28
  * Converts an untyped server `updatedAt` value — an ISO string, epoch number,
55
29
  * or Date read off an untyped row — into epoch milliseconds for
@@ -352,10 +326,16 @@ export class SyncClient extends EventEmitter {
352
326
  // void is safe: the handler's body is fully try/catch'd.
353
327
  void this.persistAwaitingTransaction(event);
354
328
  });
355
- // Clean up persisted awaiting transactions when they're finally confirmed
329
+ // Clean up persisted awaiting transactions when they're finally confirmed,
330
+ // and record the confirmed position on every row the transaction wrote.
331
+ // The acknowledgement is the earliest proof of where this client's own
332
+ // write landed in the log — earlier than its delta echo, which the pool
333
+ // suppresses on apply — so a snapshot read before the write cannot regress
334
+ // the row in the window between the two.
356
335
  this.mutationQueue.on('transaction:completed', (tx) => {
357
336
  // void is safe: the handler's body is fully try/catch'd.
358
337
  void this.removeAwaitingTransaction(tx.id);
338
+ this.noteOwnWritePositions(tx);
359
339
  });
360
340
  // Echo detection bridge. When the queue stages a transaction, the
361
341
  // client has already optimistically applied the change to the
@@ -373,6 +353,23 @@ export class SyncClient extends EventEmitter {
373
353
  this.echoTracker.drainOnRollback(event.transaction.id);
374
354
  });
375
355
  }
356
+ /**
357
+ * Advance the pooled rows a completed transaction wrote to the log position
358
+ * its acknowledgement named. A model mutation names one row; an explicit
359
+ * commit names one per operation. Rows no longer pooled have nothing to
360
+ * advance — a fresh instance starts without evidence.
361
+ */
362
+ noteOwnWritePositions(tx) {
363
+ const position = tx.lastSyncId ?? tx.syncIdNeededForCompletion;
364
+ if (position === undefined)
365
+ return;
366
+ const rowIds = tx.operations !== undefined ? tx.operations.map((op) => op.id) : [tx.modelId];
367
+ for (const rowId of rowIds) {
368
+ const row = this.objectPool.peek(rowId);
369
+ if (row)
370
+ this.objectPool.watermarks.advance(row, position);
371
+ }
372
+ }
376
373
  /** Persist an unconfirmed transaction to IndexedDB (never rejects — failures are captured). */
377
374
  async persistAwaitingTransaction(event) {
378
375
  if (!this.database)
@@ -1585,7 +1582,13 @@ export class SyncClient extends EventEmitter {
1585
1582
  idsBeingRemoved.add(r.modelId);
1586
1583
  }
1587
1584
  for (const result of dbResults) {
1588
- const { modelName, modelId, action, transactionId } = result;
1585
+ const { modelName, modelId, action, transactionId, syncId } = result;
1586
+ // Every delta names the log position the row now reflects — recorded
1587
+ // before echo detection, because an own echo is exactly a position the
1588
+ // pooled row has reached even though its fields are not re-applied.
1589
+ const resident = this.objectPool.peek(modelId);
1590
+ if (resident)
1591
+ this.objectPool.watermarks.advance(resident, syncId);
1589
1592
  // Echo detection: if this delta carries a transaction id that matches
1590
1593
  // one already applied optimistically, the pool already reflects the
1591
1594
  // mutation, so the pool operation is skipped. The IndexedDB write in
@@ -1621,8 +1624,10 @@ export class SyncClient extends EventEmitter {
1621
1624
  const model = this.objectPool.createFromData(data, undefined, {
1622
1625
  deferObservability: true,
1623
1626
  });
1624
- if (model)
1627
+ if (model) {
1628
+ this.objectPool.watermarks.advance(model, syncId);
1625
1629
  modelsToAdd.push(model);
1630
+ }
1626
1631
  }
1627
1632
  break;
1628
1633
  }
@@ -1695,6 +1700,7 @@ export class SyncClient extends EventEmitter {
1695
1700
  if (!bootstrapData.models) {
1696
1701
  return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
1697
1702
  }
1703
+ const snapshotPosition = bootstrapData.lastSyncId;
1698
1704
  const allModels = [];
1699
1705
  const serverIdsByType = new Map();
1700
1706
  let healedCount = 0;
@@ -1730,19 +1736,21 @@ export class SyncClient extends EventEmitter {
1730
1736
  // taken at a server watermark. If a concurrent live delta already
1731
1737
  // advanced this row past the snapshot, skip it. `createFromData`
1732
1738
  // mutates the pooled model in place to keep instances alive, so this
1733
- // version guard has to run before it; a guard at the upsert layer would
1734
- // be too late, because the row would already be clobbered.
1739
+ // guard has to run before it; a guard at the upsert layer would be too
1740
+ // late, because the row would already be clobbered.
1735
1741
  if (options?.scoped && recordId) {
1736
- const existing = this.objectPool.get(recordId);
1737
- if (existing && !rawRecordIsNewer(data, existing)) {
1742
+ const existing = this.objectPool.peek(recordId);
1743
+ if (existing && this.objectPool.watermarks.isAheadOf(existing, snapshotPosition)) {
1738
1744
  skippedCount++;
1739
1745
  continue;
1740
1746
  }
1741
1747
  }
1742
1748
  try {
1743
1749
  const model = this.objectPool.createFromData(data);
1744
- if (model)
1750
+ if (model) {
1751
+ this.objectPool.watermarks.advance(model, snapshotPosition);
1745
1752
  allModels.push(model);
1753
+ }
1746
1754
  }
1747
1755
  catch {
1748
1756
  skippedCount++;
@@ -62,6 +62,10 @@ export function createInternalComponents(input) {
62
62
  baseUrl: bootstrapBaseUrl,
63
63
  getAuthToken: auth?.getAuthToken,
64
64
  runtime,
65
+ // The one canonical log position; the loader reads its floor when a query
66
+ // leaves so a late answer cannot overwrite a row the pool already knows to
67
+ // be further along.
68
+ position: syncClient.position,
65
69
  });
66
70
  // Drop the lazy-lane hydration ledger on reconnect. While connected, the
67
71
  // WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
@@ -19,6 +19,7 @@ import { toMs } from '@abloatai/transaction/utils/duration';
19
19
  import { LEASE_TTL_MS } from '@abloatai/transaction/wire/protocol';
20
20
  import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
21
21
  import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
22
+ import { modelList } from '@abloatai/transaction/resources/httpResources';
22
23
  import { subTarget } from '@abloatai/transaction/coordination';
23
24
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
24
25
  // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
@@ -115,6 +116,15 @@ hydration, collaboration, readSetContext) {
115
116
  };
116
117
  };
117
118
  const load = async (options) => {
119
+ if (options?.cursor !== undefined) {
120
+ // The live client hydrates a working set into the local graph rather than
121
+ // handing back pages, so there is no cursor for this read to resume from.
122
+ // Accepting the option and ignoring it would return page one every time
123
+ // while the caller believed it was advancing.
124
+ throw new AbloValidationError('`cursor` resumes a page of the stateless read. This client keeps a ' +
125
+ 'local graph and loads a working set instead of pages: narrow the ' +
126
+ '`where`, or construct the client with `transport: \'http\'` to page.', { code: 'invalid_options', param: 'cursor' });
127
+ }
118
128
  const rows = await hydration.fetch(schemaKey, options);
119
129
  return rows.map((row) => modelAsRow(row));
120
130
  };
@@ -649,8 +659,16 @@ hydration, collaboration, readSetContext) {
649
659
  const list = guard(async (options) => {
650
660
  const registry = readSetContext?.getStore();
651
661
  const rows = await load(options);
662
+ // This transport loads a working set rather than pages, so there is no
663
+ // cursor to hand back. `limit` can still cut the set short, and a full
664
+ // count is exactly the case where the caller cannot tell: report it rather
665
+ // than claim completeness this read cannot vouch for.
666
+ const page = modelList(rows, {
667
+ hasMore: options?.limit !== undefined && rows.length >= options.limit,
668
+ nextCursor: null,
669
+ });
652
670
  if (!registry)
653
- return rows;
671
+ return page;
654
672
  for (const row of rows) {
655
673
  const stamp = hydration.getReadEvidence?.(row);
656
674
  if (stamp === undefined) {
@@ -665,7 +683,7 @@ hydration, collaboration, readSetContext) {
665
683
  }
666
684
  capturePointRead(readSetContext, readSetClientIdentity, wireModel, id, row, stamp);
667
685
  }
668
- return rows;
686
+ return page;
669
687
  });
670
688
  const operations = {
671
689
  local,
@@ -0,0 +1,40 @@
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 declare class RowWatermarks {
26
+ #private;
27
+ /** Record that `row`'s pooled copy reflects the log at least through `position`. */
28
+ advance(row: object, position: number | undefined): void;
29
+ /** The highest log position `row` is known to reflect, if the client has any evidence. */
30
+ of(row: object): number | undefined;
31
+ /**
32
+ * Whether the pooled copy of `row` is known to be ahead of a snapshot that
33
+ * reflects the log through `snapshotPosition`. `snapshotPosition` is a lower
34
+ * bound: the position the snapshot provably includes (a row's evidence stamp,
35
+ * a bootstrap's `lastSyncId`, or the client's own read floor at the moment
36
+ * the read was issued — the server had at least that much when it answered).
37
+ * A snapshot with no known position is never judged stale.
38
+ */
39
+ isAheadOf(row: object, snapshotPosition: number | undefined): boolean;
40
+ }
@@ -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