@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.
- package/dist/local/Database.js +19 -2
- package/dist/local/InstanceCache.d.ts +9 -0
- package/dist/local/InstanceCache.js +9 -0
- package/dist/local/SyncClient.d.ts +23 -9
- package/dist/local/SyncClient.js +42 -34
- package/dist/local/client/createInternalComponents.js +4 -0
- package/dist/local/client/createModelProxy.js +20 -2
- package/dist/local/rowWatermarks.d.ts +40 -0
- package/dist/local/rowWatermarks.js +53 -0
- package/dist/local/sync/OnDemandLoader.d.ts +22 -13
- package/dist/local/sync/OnDemandLoader.js +57 -72
- package/dist/local/sync/bootstrapApply.d.ts +2 -4
- package/dist/plugin.d.ts +7 -0
- package/package.json +2 -2
- package/src/local/Database.ts +22 -2
- package/src/local/InstanceCache.ts +10 -0
- package/src/local/SyncClient.ts +79 -38
- package/src/local/client/createInternalComponents.ts +4 -0
- package/src/local/client/createModelProxy.ts +24 -3
- package/src/local/rowWatermarks.ts +54 -0
- package/src/local/sync/OnDemandLoader.ts +98 -69
- package/src/local/sync/bootstrapApply.ts +2 -1
- package/src/plugin.ts +7 -0
package/dist/local/Database.js
CHANGED
|
@@ -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 {
|
|
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
|
|
508
|
-
*
|
|
509
|
-
* delta is not clobbered back to the
|
|
510
|
-
* removal is skipped, because a subset
|
|
511
|
-
* the same type that belong to other,
|
|
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
|
}): {
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -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
|
-
//
|
|
1734
|
-
//
|
|
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.
|
|
1737
|
-
if (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
|
|
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
|
|
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
|
-
|
|
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
|