@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,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
|
|
@@ -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;
|
|
@@ -415,7 +419,7 @@ export class OnDemandLoader {
|
|
|
415
419
|
// that disagrees with the schema's: these rows were returned FOR this
|
|
416
420
|
// model's query, so the schema typename is correct by construction — and
|
|
417
421
|
// without stripping it, the spread would put the row's variant (a server
|
|
418
|
-
// echoing the schema KEY `
|
|
422
|
+
// echoing the schema KEY `items` instead of the typename `Item`) back on
|
|
419
423
|
// top of the stamp, sending hydration to the strict unknown-model error.
|
|
420
424
|
const { _Typename: _dropMangled, __typename: _dropRowVariant, ...rest } = obj;
|
|
421
425
|
void _dropMangled;
|
|
@@ -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;
|
|
@@ -276,7 +276,7 @@ async function drainPendingDeltas(ctx) {
|
|
|
276
276
|
// A sustained stream can refill the detached queue before every
|
|
277
277
|
// persistence promise settles. Promise-only looping then forms an
|
|
278
278
|
// unbounded microtask chain that starves WebSocket reads, timers and
|
|
279
|
-
// replication keepalives. Give the host one
|
|
279
|
+
// replication keepalives. Give the host one macroitem turn between
|
|
280
280
|
// owned batches; Node has setImmediate, browsers fall back to a timer.
|
|
281
281
|
await yieldToHost();
|
|
282
282
|
}
|
|
@@ -43,7 +43,7 @@ export function* initialize(host, context, signal) {
|
|
|
43
43
|
// Bootstrap from server if needed.
|
|
44
44
|
//
|
|
45
45
|
// `bootstrapMode: 'none'` participants (headless workers and
|
|
46
|
-
//
|
|
46
|
+
// item runners) skip baseline replication — they read via
|
|
47
47
|
// `model.get()` round-trips and rely on covering deltas
|
|
48
48
|
// from filtered subscriptions to populate the pool lazily. The
|
|
49
49
|
// WS is already open by `setupWebSocketSync` above, so live
|
|
@@ -55,7 +55,7 @@ export function* initialize(host, context, signal) {
|
|
|
55
55
|
// initiates the upgrade, but it does NOT await the 'connected'
|
|
56
56
|
// event — it returns synchronously after wiring listeners.
|
|
57
57
|
// For bootstrapMode='none' consumers (headless workers and
|
|
58
|
-
//
|
|
58
|
+
// item runners), this branch is the entire body of initialize()
|
|
59
59
|
// after the WS is set up, so `ready()` would otherwise resolve
|
|
60
60
|
// while the WS is still in 'connecting' state. The very next
|
|
61
61
|
// `commits.create` then throws "SyncWebSocket not connected".
|
|
@@ -167,7 +167,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
167
167
|
/**
|
|
168
168
|
* Relates stale notifications back to write targets without assuming the
|
|
169
169
|
* server's canonical model name uses the same spelling as the public schema
|
|
170
|
-
* key (`
|
|
170
|
+
* key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
|
|
171
171
|
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
172
172
|
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
173
173
|
* write. A notification with no write-target id (or an explicit group) is a
|
|
@@ -417,7 +417,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
417
417
|
/**
|
|
418
418
|
* Relates stale notifications back to write targets without assuming the
|
|
419
419
|
* server's canonical model name uses the same spelling as the public schema
|
|
420
|
-
* key (`
|
|
420
|
+
* key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
|
|
421
421
|
* is the compatibility fallback. An ambiguous same-id cross-model mismatch
|
|
422
422
|
* is deliberately left unclassified, so it cannot falsely settle a queued
|
|
423
423
|
* write. A notification with no write-target id (or an explicit group) is a
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { OnStaleMode } from '@abloatai/transaction/coordination/schema';
|
|
2
|
+
type ModelData = Record<string, unknown>;
|
|
3
|
+
/** One mutation retained in the durable local transaction journal. */
|
|
4
|
+
interface PersistedMutation {
|
|
5
|
+
type: 'create' | 'update' | 'delete' | 'archive';
|
|
6
|
+
modelData: ModelData;
|
|
7
|
+
modelName: string;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
writeOptions?: {
|
|
10
|
+
readAt?: number | null;
|
|
11
|
+
onStale?: OnStaleMode | null;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Persisted transaction for offline/retry support.
|
|
16
|
+
*
|
|
17
|
+
* The index signature is part of the contract: this targets the generic
|
|
18
|
+
* record-shaped storage layer (`InMemoryObjectStore.put` and its IndexedDB
|
|
19
|
+
* equivalent), both of which take `Record<string, unknown>`.
|
|
20
|
+
*/
|
|
21
|
+
export interface PersistedTransaction {
|
|
22
|
+
id: string;
|
|
23
|
+
type?: string;
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
createdAt?: number;
|
|
26
|
+
mutations?: PersistedMutation[];
|
|
27
|
+
awaitingDelta?: {
|
|
28
|
+
syncIdNeeded: number;
|
|
29
|
+
modelName: string;
|
|
30
|
+
modelId: string;
|
|
31
|
+
operationType: string;
|
|
32
|
+
};
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
/** Compare the stable request identity while ignoring local seal timing. */
|
|
36
|
+
export declare function isSameOutboxRecord(existing: PersistedTransaction, candidate: PersistedTransaction): boolean;
|
|
37
|
+
/** An accepted envelope may replace the otherwise-identical pending envelope. */
|
|
38
|
+
export declare function isAcceptedOutboxPromotion(existing: PersistedTransaction | undefined, candidate: PersistedTransaction): boolean;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Compare the stable request identity while ignoring local seal timing. */
|
|
2
|
+
export function isSameOutboxRecord(existing, candidate) {
|
|
3
|
+
if (existing.type === 'http_commit_envelope' &&
|
|
4
|
+
candidate.type === 'http_commit_envelope') {
|
|
5
|
+
const identity = (record) => ({
|
|
6
|
+
id: record.id,
|
|
7
|
+
type: record.type,
|
|
8
|
+
storageVersion: record.storageVersion,
|
|
9
|
+
idempotencyKey: record.idempotencyKey,
|
|
10
|
+
// Pre-versioning HTTP outbox rows are v1. Normalizing them preserves
|
|
11
|
+
// idempotency when the same request is resealed after an upgrade.
|
|
12
|
+
protocolVersion: record.protocolVersion ?? 1,
|
|
13
|
+
request: record.request,
|
|
14
|
+
scopeNamespace: record.scopeNamespace,
|
|
15
|
+
});
|
|
16
|
+
if (existing.correlationId !== undefined &&
|
|
17
|
+
candidate.correlationId !== undefined &&
|
|
18
|
+
existing.correlationId !== candidate.correlationId) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
22
|
+
}
|
|
23
|
+
if (existing.type === 'commit_envelope' &&
|
|
24
|
+
candidate.type === 'commit_envelope') {
|
|
25
|
+
const identity = (record) => ({
|
|
26
|
+
id: record.id,
|
|
27
|
+
type: record.type,
|
|
28
|
+
storageVersion: record.storageVersion,
|
|
29
|
+
origin: record.origin,
|
|
30
|
+
idempotencyKey: record.idempotencyKey,
|
|
31
|
+
operations: record.operations,
|
|
32
|
+
sourceMutationIds: record.sourceMutationIds,
|
|
33
|
+
commitOptions: record.commitOptions,
|
|
34
|
+
scope: record.scope,
|
|
35
|
+
});
|
|
36
|
+
if (existing.correlationId !== undefined &&
|
|
37
|
+
candidate.correlationId !== undefined &&
|
|
38
|
+
existing.correlationId !== candidate.correlationId) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(identity(existing)) === JSON.stringify(identity(candidate));
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(existing) === JSON.stringify(candidate);
|
|
44
|
+
}
|
|
45
|
+
/** An accepted envelope may replace the otherwise-identical pending envelope. */
|
|
46
|
+
export function isAcceptedOutboxPromotion(existing, candidate) {
|
|
47
|
+
return (existing !== undefined &&
|
|
48
|
+
(existing.type === 'commit_envelope' ||
|
|
49
|
+
existing.type === 'http_commit_envelope') &&
|
|
50
|
+
existing.type === candidate.type &&
|
|
51
|
+
existing.acceptedAt === undefined &&
|
|
52
|
+
candidate.acceptedAt !== undefined);
|
|
53
|
+
}
|
|
@@ -48,7 +48,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
|
|
|
48
48
|
return false;
|
|
49
49
|
};
|
|
50
50
|
// Skip if target has its own observability setup
|
|
51
|
-
// This allows models like
|
|
51
|
+
// This allows models like Item to handle their own MobX setup
|
|
52
52
|
if (target.setupObservability || target._hasCustomObservability) {
|
|
53
53
|
getContext().modelDebugLogger?.logDebug(`${target.constructor.name} has custom observability, skipping M1`);
|
|
54
54
|
return;
|
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
|
|
@@ -206,10 +206,10 @@ export declare function usePeers(scope?: ParticipantScope): readonly Peer[];
|
|
|
206
206
|
/**
|
|
207
207
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
208
208
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
209
|
-
* where you need direct access (e.g., `sync.
|
|
209
|
+
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
210
210
|
*
|
|
211
211
|
* The generic parameter narrows the return type to your schema's
|
|
212
|
-
* model record so call sites get typed `sync.
|
|
212
|
+
* model record so call sites get typed `sync.items.findMany()` /
|
|
213
213
|
* `sync.sections.create(...)` without a cast at the call site:
|
|
214
214
|
*
|
|
215
215
|
* ```ts
|
|
@@ -400,10 +400,10 @@ export function usePeers(scope) {
|
|
|
400
400
|
/**
|
|
401
401
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
402
402
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
403
|
-
* where you need direct access (e.g., `sync.
|
|
403
|
+
* where you need direct access (e.g., `sync.items.onChange(cb)`).
|
|
404
404
|
*
|
|
405
405
|
* The generic parameter narrows the return type to your schema's
|
|
406
|
-
* model record so call sites get typed `sync.
|
|
406
|
+
* model record so call sites get typed `sync.items.findMany()` /
|
|
407
407
|
* `sync.sections.create(...)` without a cast at the call site:
|
|
408
408
|
*
|
|
409
409
|
* ```ts
|
package/dist/react/context.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface SyncReactContext {
|
|
|
8
8
|
organizationId: string;
|
|
9
9
|
/**
|
|
10
10
|
* An optional schema. When provided, hooks that take a model by name (such as
|
|
11
|
-
* `useQuery('
|
|
11
|
+
* `useQuery('items')`) read that model's metadata from this schema, so
|
|
12
12
|
* callers don't pass a schema at every call site. When omitted, those hooks
|
|
13
13
|
* require the schema as an argument instead.
|
|
14
14
|
*
|
|
@@ -38,7 +38,7 @@ export interface SyncProviderProps {
|
|
|
38
38
|
organizationId: string;
|
|
39
39
|
/**
|
|
40
40
|
* An optional schema. Provide it to enable hooks that take a model by name
|
|
41
|
-
* (such as `useQuery('
|
|
41
|
+
* (such as `useQuery('items')`); the model types also narrow through your
|
|
42
42
|
* `Register` augmentation. Omit it to pass the schema to those hooks directly
|
|
43
43
|
* instead.
|
|
44
44
|
*/
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -54,13 +54,13 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
|
|
|
54
54
|
* // With the Register augmentation (recommended):
|
|
55
55
|
* const ablo = useAblo();
|
|
56
56
|
* if (!ablo) return <Loading />;
|
|
57
|
-
* const doc = await ablo.
|
|
57
|
+
* const doc = await ablo.records.get({ id }); // async server read
|
|
58
58
|
*
|
|
59
59
|
* // Reactive selector (a synchronous local snapshot). The selector's reads
|
|
60
60
|
* // are typed as snapshot rows — data fields + computeds, no relation
|
|
61
61
|
* // accessors — matching what the hook actually returns:
|
|
62
|
-
* const doc = useAblo((ablo) => ablo.
|
|
63
|
-
* const active = useAblo((ablo) => ablo.
|
|
62
|
+
* const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
|
|
63
|
+
* const active = useAblo((ablo) => ablo.records.claim.state({ id }));
|
|
64
64
|
*
|
|
65
65
|
* // Without the augmentation, pass the schema as a type argument:
|
|
66
66
|
* const ablo = useAblo<(typeof schema)['models']>();
|
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"
|