@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
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
|
238
|
-
|
|
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
|
|
367
|
+
// the origin decides which before anything is applied.
|
|
366
368
|
const existing = this.opts.objectPool.get(obj.id);
|
|
367
369
|
if (existing) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// an optimistic
|
|
371
|
-
// it
|
|
372
|
-
// authoritative delta cannot repair it because
|
|
373
|
-
//
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
// fields while accepting
|
|
378
|
-
// local-first merge contract
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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.
|
|
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.
|
|
87
|
+
"@abloatai/transaction": "^0.53.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/local/Database.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
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)
|
|
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:
|
|
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
|
|
2068
|
-
*
|
|
2069
|
-
* delta is not clobbered back to the
|
|
2070
|
-
* removal is skipped, because a subset
|
|
2071
|
-
* the same type that belong to other,
|
|
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
|
-
//
|
|
2114
|
-
//
|
|
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.
|
|
2117
|
-
if (existing &&
|
|
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)
|
|
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
|
-
|
|
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
|
|
1198
|
+
return page;
|
|
1178
1199
|
});
|
|
1179
1200
|
|
|
1180
1201
|
const operations: ModelOperations<T, C> = {
|