@abloatai/humans 0.51.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/client.d.ts +4 -4
- package/dist/local/Database.d.ts +1 -34
- package/dist/local/Database.js +16 -55
- package/dist/local/InstanceCache.d.ts +9 -0
- package/dist/local/InstanceCache.js +9 -0
- package/dist/local/Model.js +0 -18
- package/dist/local/SyncClient.d.ts +23 -11
- package/dist/local/SyncClient.js +44 -53
- package/dist/local/client/createInternalComponents.js +4 -0
- package/dist/local/client/createModelProxy.js +21 -3
- package/dist/local/interfaces/index.d.ts +3 -3
- 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 +58 -73
- package/dist/local/sync/bootstrapApply.d.ts +2 -4
- package/dist/local/sync/deltaPipeline.js +1 -1
- package/dist/local/sync/initialize.js +2 -2
- package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -1
- package/dist/local/transactions/mutations/MutationQueue.js +1 -1
- package/dist/local/transactions/persistedTransaction.d.ts +39 -0
- package/dist/local/transactions/persistedTransaction.js +53 -0
- package/dist/local/utils/mobxSetup.js +1 -1
- package/dist/plugin.d.ts +7 -0
- package/dist/react/AbloProvider.d.ts +2 -2
- package/dist/react/AbloProvider.js +2 -2
- package/dist/react/context.d.ts +2 -2
- package/dist/react/useAblo.d.ts +3 -3
- package/package.json +2 -2
- package/src/client.ts +4 -4
- package/src/local/BaseSyncedStore.ts +1 -1
- package/src/local/Database.ts +22 -110
- package/src/local/InstanceCache.ts +10 -0
- package/src/local/Model.ts +0 -20
- package/src/local/SyncClient.ts +81 -66
- package/src/local/client/createInternalComponents.ts +4 -0
- package/src/local/client/createModelProxy.ts +26 -5
- package/src/local/interfaces/index.ts +3 -3
- package/src/local/rowWatermarks.ts +54 -0
- package/src/local/sync/OnDemandLoader.ts +99 -70
- package/src/local/sync/bootstrapApply.ts +2 -1
- package/src/local/sync/deltaPipeline.ts +1 -1
- package/src/local/sync/initialize.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +1 -1
- package/src/local/transactions/persistedTransaction.ts +112 -0
- package/src/local/utils/mobxSetup.ts +1 -1
- package/src/plugin.ts +7 -0
- package/src/react/AbloProvider.tsx +2 -2
- package/src/react/context.ts +2 -2
- package/src/react/useAblo.ts +3 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
//
|
|
510
|
-
//
|
|
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;
|
|
@@ -556,7 +600,7 @@ export class OnDemandLoader {
|
|
|
556
600
|
// that disagrees with the schema's: these rows were returned FOR this
|
|
557
601
|
// model's query, so the schema typename is correct by construction — and
|
|
558
602
|
// without stripping it, the spread would put the row's variant (a server
|
|
559
|
-
// echoing the schema KEY `
|
|
603
|
+
// echoing the schema KEY `items` instead of the typename `Item`) back on
|
|
560
604
|
// top of the stamp, sending hydration to the strict unknown-model error.
|
|
561
605
|
const { _Typename: _dropMangled, __typename: _dropRowVariant, ...rest } = obj as Record<
|
|
562
606
|
string,
|
|
@@ -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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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:
|
|
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. */
|
|
@@ -396,7 +396,7 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
|
|
|
396
396
|
// A sustained stream can refill the detached queue before every
|
|
397
397
|
// persistence promise settles. Promise-only looping then forms an
|
|
398
398
|
// unbounded microtask chain that starves WebSocket reads, timers and
|
|
399
|
-
// replication keepalives. Give the host one
|
|
399
|
+
// replication keepalives. Give the host one macroitem turn between
|
|
400
400
|
// owned batches; Node has setImmediate, browsers fall back to a timer.
|
|
401
401
|
await yieldToHost();
|
|
402
402
|
}
|
|
@@ -92,7 +92,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
92
92
|
// Bootstrap from server if needed.
|
|
93
93
|
//
|
|
94
94
|
// `bootstrapMode: 'none'` participants (headless workers and
|
|
95
|
-
//
|
|
95
|
+
// item runners) skip baseline replication — they read via
|
|
96
96
|
// `model.get()` round-trips and rely on covering deltas
|
|
97
97
|
// from filtered subscriptions to populate the pool lazily. The
|
|
98
98
|
// WS is already open by `setupWebSocketSync` above, so live
|
|
@@ -110,7 +110,7 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
110
110
|
// initiates the upgrade, but it does NOT await the 'connected'
|
|
111
111
|
// event — it returns synchronously after wiring listeners.
|
|
112
112
|
// For bootstrapMode='none' consumers (headless workers and
|
|
113
|
-
//
|
|
113
|
+
// item runners), this branch is the entire body of initialize()
|
|
114
114
|
// after the WS is set up, so `ready()` would otherwise resolve
|
|
115
115
|
// while the WS is still in 'connecting' state. The very next
|
|
116
116
|
// `commits.create` then throws "SyncWebSocket not connected".
|
|
@@ -645,7 +645,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
645
645
|
/**
|
|
646
646
|
* Relates stale notifications back to write targets without assuming the
|
|
647
647
|
* server's canonical model name uses the same spelling as the public schema
|
|
648
|
-
* key (`
|
|
648
|
+
* key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
|
|
649
649
|
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
650
650
|
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
651
651
|
* write. A notification with no write-target id (or an explicit group) is a
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
|
|
2
|
+
|
|
3
|
+
type ModelData = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
/** One mutation retained in the durable local transaction journal. */
|
|
6
|
+
interface PersistedMutation {
|
|
7
|
+
type: 'create' | 'update' | 'delete' | 'archive';
|
|
8
|
+
modelData: ModelData;
|
|
9
|
+
modelName: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
writeOptions?: {
|
|
12
|
+
readAt?: number | null;
|
|
13
|
+
onStale?: OnStaleMode | null;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Persisted transaction for offline/retry support.
|
|
19
|
+
*
|
|
20
|
+
* The index signature is part of the contract: this targets the generic
|
|
21
|
+
* record-shaped storage layer (`InMemoryObjectStore.put` and its IndexedDB
|
|
22
|
+
* equivalent), both of which take `Record<string, unknown>`.
|
|
23
|
+
*/
|
|
24
|
+
export interface PersistedTransaction {
|
|
25
|
+
id: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
timestamp?: number;
|
|
28
|
+
createdAt?: number;
|
|
29
|
+
mutations?: PersistedMutation[];
|
|
30
|
+
// Awaiting-delta transactions survive a tab close. Reconnect and delta
|
|
31
|
+
// catch-up confirm them during the next session.
|
|
32
|
+
awaitingDelta?: {
|
|
33
|
+
syncIdNeeded: number;
|
|
34
|
+
modelName: string;
|
|
35
|
+
modelId: string;
|
|
36
|
+
operationType: string;
|
|
37
|
+
};
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compare the stable request identity while ignoring local seal timing. */
|
|
42
|
+
export function isSameOutboxRecord(
|
|
43
|
+
existing: PersistedTransaction,
|
|
44
|
+
candidate: PersistedTransaction,
|
|
45
|
+
): boolean {
|
|
46
|
+
if (
|
|
47
|
+
existing.type === 'http_commit_envelope' &&
|
|
48
|
+
candidate.type === 'http_commit_envelope'
|
|
49
|
+
) {
|
|
50
|
+
const identity = (record: PersistedTransaction): unknown => ({
|
|
51
|
+
id: record.id,
|
|
52
|
+
type: record.type,
|
|
53
|
+
storageVersion: record.storageVersion,
|
|
54
|
+
idempotencyKey: record.idempotencyKey,
|
|
55
|
+
// Pre-versioning HTTP outbox rows are v1. Normalizing them preserves
|
|
56
|
+
// idempotency when the same request is resealed after an upgrade.
|
|
57
|
+
protocolVersion: record.protocolVersion ?? 1,
|
|
58
|
+
request: record.request,
|
|
59
|
+
scopeNamespace: record.scopeNamespace,
|
|
60
|
+
});
|
|
61
|
+
if (
|
|
62
|
+
existing.correlationId !== undefined &&
|
|
63
|
+
candidate.correlationId !== undefined &&
|
|
64
|
+
existing.correlationId !== candidate.correlationId
|
|
65
|
+
) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (
|
|
72
|
+
existing.type === 'commit_envelope' &&
|
|
73
|
+
candidate.type === 'commit_envelope'
|
|
74
|
+
) {
|
|
75
|
+
const identity = (record: PersistedTransaction): unknown => ({
|
|
76
|
+
id: record.id,
|
|
77
|
+
type: record.type,
|
|
78
|
+
storageVersion: record.storageVersion,
|
|
79
|
+
origin: record.origin,
|
|
80
|
+
idempotencyKey: record.idempotencyKey,
|
|
81
|
+
operations: record.operations,
|
|
82
|
+
sourceMutationIds: record.sourceMutationIds,
|
|
83
|
+
commitOptions: record.commitOptions,
|
|
84
|
+
scope: record.scope,
|
|
85
|
+
});
|
|
86
|
+
if (
|
|
87
|
+
existing.correlationId !== undefined &&
|
|
88
|
+
candidate.correlationId !== undefined &&
|
|
89
|
+
existing.correlationId !== candidate.correlationId
|
|
90
|
+
) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An accepted envelope may replace the otherwise-identical pending envelope. */
|
|
100
|
+
export function isAcceptedOutboxPromotion(
|
|
101
|
+
existing: PersistedTransaction | undefined,
|
|
102
|
+
candidate: PersistedTransaction,
|
|
103
|
+
): boolean {
|
|
104
|
+
return (
|
|
105
|
+
existing !== undefined &&
|
|
106
|
+
(existing.type === 'commit_envelope' ||
|
|
107
|
+
existing.type === 'http_commit_envelope') &&
|
|
108
|
+
existing.type === candidate.type &&
|
|
109
|
+
existing.acceptedAt === undefined &&
|
|
110
|
+
candidate.acceptedAt !== undefined
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -77,7 +77,7 @@ export function M1<T extends M1Target>(
|
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
// Skip if target has its own observability setup
|
|
80
|
-
// This allows models like
|
|
80
|
+
// This allows models like Item to handle their own MobX setup
|
|
81
81
|
if (target.setupObservability || target._hasCustomObservability) {
|
|
82
82
|
getContext().modelDebugLogger?.logDebug(`${target.constructor.name} has custom observability, skipping M1`);
|
|
83
83
|
return;
|
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
|
/**
|
|
@@ -675,10 +675,10 @@ export function usePeers(scope?: ParticipantScope): readonly Peer[] {
|
|
|
675
675
|
/**
|
|
676
676
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
677
677
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
678
|
-
* where you need direct access (e.g., `sync.
|
|
678
|
+
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
679
679
|
*
|
|
680
680
|
* The generic parameter narrows the return type to your schema's
|
|
681
|
-
* model record so call sites get typed `sync.
|
|
681
|
+
* model record so call sites get typed `sync.items.findMany()` /
|
|
682
682
|
* `sync.sections.create(...)` without a cast at the call site:
|
|
683
683
|
*
|
|
684
684
|
* ```ts
|
package/src/react/context.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface SyncReactContext {
|
|
|
19
19
|
organizationId: string;
|
|
20
20
|
/**
|
|
21
21
|
* An optional schema. When provided, hooks that take a model by name (such as
|
|
22
|
-
* `useQuery('
|
|
22
|
+
* `useQuery('items')`) read that model's metadata from this schema, so
|
|
23
23
|
* callers don't pass a schema at every call site. When omitted, those hooks
|
|
24
24
|
* require the schema as an argument instead.
|
|
25
25
|
*
|
|
@@ -60,7 +60,7 @@ export interface SyncProviderProps {
|
|
|
60
60
|
organizationId: string;
|
|
61
61
|
/**
|
|
62
62
|
* An optional schema. Provide it to enable hooks that take a model by name
|
|
63
|
-
* (such as `useQuery('
|
|
63
|
+
* (such as `useQuery('items')`); the model types also narrow through your
|
|
64
64
|
* `Register` augmentation. Omit it to pass the schema to those hooks directly
|
|
65
65
|
* instead.
|
|
66
66
|
*/
|
package/src/react/useAblo.ts
CHANGED
|
@@ -138,13 +138,13 @@ function snapshotValue<T>(value: T): T {
|
|
|
138
138
|
* // With the Register augmentation (recommended):
|
|
139
139
|
* const ablo = useAblo();
|
|
140
140
|
* if (!ablo) return <Loading />;
|
|
141
|
-
* const doc = await ablo.
|
|
141
|
+
* const doc = await ablo.records.get({ id }); // async server read
|
|
142
142
|
*
|
|
143
143
|
* // Reactive selector (a synchronous local snapshot). The selector's reads
|
|
144
144
|
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
145
145
|
* // accessors — matching what the hook actually returns:
|
|
146
|
-
* const doc = useAblo((ablo) => ablo.
|
|
147
|
-
* const active = useAblo((ablo) => ablo.
|
|
146
|
+
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
147
|
+
* const active = useAblo((ablo) => ablo.records.claim.state({ id }));
|
|
148
148
|
*
|
|
149
149
|
* // Without the augmentation, pass the schema as a type argument:
|
|
150
150
|
* const ablo = useAblo<(typeof schema)['models']>();
|