@spooky-sync/core 0.0.1-canary.161 → 0.0.1-canary.162
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/index.d.ts +294 -4
- package/dist/index.js +1033 -293
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +98 -1
- package/package.json +3 -3
- package/src/modules/crdt/crdt-hydration.test.ts +4 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +109 -7
- package/src/modules/data/data.membership.test.ts +297 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +291 -39
- package/src/modules/data/window-query.ts +53 -13
- package/src/modules/sync/sync.live-removal.test.ts +134 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.ts +143 -43
- package/src/services/database/connection-supervisor.test.ts +255 -0
- package/src/services/database/connection-supervisor.ts +351 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +21 -1
- package/src/services/database/index.ts +1 -0
- package/src/services/database/local-migrator.ts +1 -0
- package/src/services/database/remote.ts +99 -3
- package/src/services/database/sqlite-cache-engine.ts +1 -0
- package/src/sp00ky.ts +32 -1
- package/src/types.ts +99 -0
- package/src/utils/index.ts +38 -0
|
@@ -44,7 +44,12 @@ import {
|
|
|
44
44
|
} from '../../utils/index';
|
|
45
45
|
import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
46
46
|
import type { PushEventOptions } from '../../events/index';
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
buildIdSetPlan,
|
|
49
|
+
buildIdSetSurql,
|
|
50
|
+
buildWindowMaterialization,
|
|
51
|
+
buildWindowMaterializationPlan,
|
|
52
|
+
} from './window-query';
|
|
48
53
|
import { mintMutationId } from './mutation-id';
|
|
49
54
|
|
|
50
55
|
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
@@ -236,7 +241,8 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
236
241
|
params,
|
|
237
242
|
ttl,
|
|
238
243
|
tableName,
|
|
239
|
-
plan
|
|
244
|
+
plan,
|
|
245
|
+
await this.calculateMembershipKey({ surql: surqlString, params })
|
|
240
246
|
);
|
|
241
247
|
this.pendingQueries.set(hash, promise);
|
|
242
248
|
try {
|
|
@@ -442,54 +448,144 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
442
448
|
await this.processStreamUpdate(pending);
|
|
443
449
|
}
|
|
444
450
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Materialize a query's result rows from the local store.
|
|
453
|
+
*
|
|
454
|
+
* A query's rows are its MEMBERSHIP — the id-set the server put in
|
|
455
|
+
* `_00_list_ref` (`remoteArray`) — not "every local body that matches the
|
|
456
|
+
* WHERE". Those two disagree, and the disagreement was the bug: when a row
|
|
457
|
+
* leaves a query's window but still exists upstream, `handleRemovedRecords`
|
|
458
|
+
* keeps its local body and never re-fetches it, so a predicate re-scan finds
|
|
459
|
+
* that stale body still matching and keeps rendering the row. Selecting the
|
|
460
|
+
* id-set directly is also the only correct thing for a windowed query, where
|
|
461
|
+
* re-applying `START m` against the shared local store skips the window's own
|
|
462
|
+
* rows entirely (sparse windowing) and returns nothing.
|
|
463
|
+
*
|
|
464
|
+
* The rendered set is:
|
|
465
|
+
*
|
|
466
|
+
* (membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes
|
|
467
|
+
*
|
|
468
|
+
* The middle term keeps optimistic writes visible without re-admitting stale
|
|
469
|
+
* rows. Every local write is fed to the SSP (`cache.saveBatch` →
|
|
470
|
+
* `ingestMany`), so `localArray` answers "does this row match the predicate
|
|
471
|
+
* per LOCAL truth". A pending write that moves a row into the window is in
|
|
472
|
+
* `localArray` and shows; one that moves a row out is absent and does not; a
|
|
473
|
+
* stale body the server dropped has no pending write at all, so it stays out.
|
|
474
|
+
* `pendingDeletes` covers the reverse lag — the server still lists a row whose
|
|
475
|
+
* DELETE is sitting in our outbox.
|
|
476
|
+
*
|
|
477
|
+
* Falls back to the predicate scan only when membership has never been
|
|
478
|
+
* established (a query first run on this device), so an offline first paint
|
|
479
|
+
* still shows something.
|
|
480
|
+
*/
|
|
452
481
|
private async materializeRecords(
|
|
453
482
|
queryState: QueryState,
|
|
454
483
|
sspArray?: Array<[string, number]>
|
|
455
484
|
): Promise<Record<string, any>[]> {
|
|
456
485
|
const t0 = performance.now();
|
|
457
|
-
const
|
|
458
|
-
|
|
486
|
+
const records = await this.materializeFromConfig(queryState.config, sspArray);
|
|
487
|
+
// Local SurrealDB record-fetch time → DevTools "localFetch" phase.
|
|
488
|
+
this.recordPhase(queryState, 'localFetch', performance.now() - t0);
|
|
489
|
+
return records;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The materialization itself, without the DevTools timing wrapper. Split out so
|
|
494
|
+
* cold-start seeding can use it before a `QueryState` exists.
|
|
495
|
+
*/
|
|
496
|
+
private async materializeFromConfig(
|
|
497
|
+
config: QueryConfig,
|
|
498
|
+
sspArray?: Array<[string, number]>
|
|
499
|
+
): Promise<Record<string, any>[]> {
|
|
500
|
+
const plan = config.plan;
|
|
501
|
+
const membership = this.resolveMembership(config, sspArray);
|
|
459
502
|
let records: Record<string, any>[];
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
(sspArray?.length && sspArray) ||
|
|
464
|
-
queryState.config.localArray ||
|
|
465
|
-
[];
|
|
466
|
-
const winIds = win.map(([id]) => parseRecordIdString(id));
|
|
503
|
+
|
|
504
|
+
if (membership) {
|
|
505
|
+
const ids = await this.buildRenderIds(config, membership, sspArray);
|
|
467
506
|
if (plan) {
|
|
468
|
-
|
|
469
|
-
// keeping ORDER BY + relations. Works on any local engine.
|
|
470
|
-
const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
|
|
471
|
-
records = await this.local.select(winPlan, queryState.config.params);
|
|
507
|
+
records = await this.local.select(buildIdSetPlan(plan, ids), config.params);
|
|
472
508
|
} else {
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
509
|
+
const idSetSurql = buildIdSetSurql(config.surql);
|
|
510
|
+
if (idSetSurql) {
|
|
511
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(idSetSurql.query, {
|
|
512
|
+
...config.params,
|
|
513
|
+
__win: ids,
|
|
514
|
+
});
|
|
515
|
+
records = rows || [];
|
|
516
|
+
} else {
|
|
517
|
+
// Unparseable shape (no top-level FROM): the predicate scan is the
|
|
518
|
+
// only option left. Rare — the query-builder always supplies a plan.
|
|
519
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(
|
|
520
|
+
config.surql,
|
|
521
|
+
config.params
|
|
522
|
+
);
|
|
523
|
+
records = rows || [];
|
|
524
|
+
}
|
|
478
525
|
}
|
|
479
526
|
} else if (plan) {
|
|
480
|
-
records = await this.local.select(plan,
|
|
527
|
+
records = await this.local.select(plan, config.params);
|
|
481
528
|
} else {
|
|
482
|
-
const [rows] = await this.local.query<[Record<string, any>[]]>(
|
|
483
|
-
queryState.config.surql,
|
|
484
|
-
queryState.config.params
|
|
485
|
-
);
|
|
529
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(config.surql, config.params);
|
|
486
530
|
records = rows || [];
|
|
487
531
|
}
|
|
488
|
-
// Local SurrealDB record-fetch time → DevTools "localFetch" phase.
|
|
489
|
-
this.recordPhase(queryState, 'localFetch', performance.now() - t0);
|
|
490
532
|
return records;
|
|
491
533
|
}
|
|
492
534
|
|
|
535
|
+
/**
|
|
536
|
+
* The authoritative membership list to render from, or `null` when membership
|
|
537
|
+
* has never been established and the caller must fall back to a scan.
|
|
538
|
+
*
|
|
539
|
+
* A windowed query has no usable fallback — re-running its `START m` locally
|
|
540
|
+
* returns the wrong rows — so it renders from whatever id-set is on hand
|
|
541
|
+
* (SSP's included) rather than degrading to a scan. That is the pre-existing
|
|
542
|
+
* behavior for windows and is preserved.
|
|
543
|
+
*/
|
|
544
|
+
private resolveMembership(
|
|
545
|
+
config: QueryConfig,
|
|
546
|
+
sspArray?: Array<[string, number]>
|
|
547
|
+
): RecordVersionArray | null {
|
|
548
|
+
if (config.membershipKnown) return config.remoteArray ?? [];
|
|
549
|
+
if (buildWindowMaterialization(config.surql) !== null) {
|
|
550
|
+
return (
|
|
551
|
+
(config.remoteArray?.length && config.remoteArray) ||
|
|
552
|
+
(sspArray?.length && sspArray) ||
|
|
553
|
+
config.localArray ||
|
|
554
|
+
[]
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Apply the pending-write union and pending-delete subtraction, and map to
|
|
561
|
+
* RecordIds for the engines' id-set path. */
|
|
562
|
+
private async buildRenderIds(
|
|
563
|
+
config: QueryConfig,
|
|
564
|
+
membership: RecordVersionArray,
|
|
565
|
+
sspArray?: Array<[string, number]>
|
|
566
|
+
): Promise<unknown[]> {
|
|
567
|
+
const { writes, deletes } = await this.getPendingRecordIds();
|
|
568
|
+
const ordered: string[] = [];
|
|
569
|
+
const seen = new Set<string>();
|
|
570
|
+
for (const [id] of membership) {
|
|
571
|
+
if (deletes.has(id) || seen.has(id)) continue;
|
|
572
|
+
seen.add(id);
|
|
573
|
+
ordered.push(id);
|
|
574
|
+
}
|
|
575
|
+
if (writes.size > 0) {
|
|
576
|
+
// Only pending writes the SSP agrees currently match this query — see the
|
|
577
|
+
// formula in `materializeRecords`. `sspArray` is the fresher signal when a
|
|
578
|
+
// stream update triggered this pass; `localArray` is the persisted one.
|
|
579
|
+
const localView = (sspArray?.length && sspArray) || config.localArray || [];
|
|
580
|
+
for (const [id] of localView) {
|
|
581
|
+
if (!writes.has(id) || deletes.has(id) || seen.has(id)) continue;
|
|
582
|
+
seen.add(id);
|
|
583
|
+
ordered.push(id);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return ordered.map((id) => parseRecordIdString(id));
|
|
587
|
+
}
|
|
588
|
+
|
|
493
589
|
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
494
590
|
const { queryHash, localArray, materializationTimeMs } = update;
|
|
495
591
|
const queryState = this.activeQueries.get(queryHash);
|
|
@@ -823,11 +919,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
823
919
|
if (epoch !== this.local.epoch) return;
|
|
824
920
|
|
|
825
921
|
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
826
|
-
//
|
|
827
|
-
//
|
|
922
|
+
// renders from it and it feeds the version dedup. Registration later
|
|
923
|
+
// overwrites it with the authoritative `_00_list_ref`.
|
|
828
924
|
queryState.config.remoteArray = rows.map(
|
|
829
925
|
(r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
|
|
830
926
|
);
|
|
927
|
+
// These rows ARE the server's answer to this query, so membership is now
|
|
928
|
+
// known and rendering can stop falling back to a predicate scan. The durable
|
|
929
|
+
// `_00_window` mirror is deliberately left to `updateQueryRemoteArray` (the
|
|
930
|
+
// single write point) — the registration that follows lands there anyway.
|
|
931
|
+
queryState.config.membershipKnown = true;
|
|
831
932
|
|
|
832
933
|
queryState.records = await this.materializeRecords(queryState);
|
|
833
934
|
const subscribers = this.subscriptions.get(hash);
|
|
@@ -917,6 +1018,88 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
917
1018
|
);
|
|
918
1019
|
}
|
|
919
1020
|
|
|
1021
|
+
// ---- Durable membership (`_00_window`) ---------------------------------
|
|
1022
|
+
//
|
|
1023
|
+
// A query's authoritative membership is the id-set the server put in
|
|
1024
|
+
// `_00_list_ref` — `config.remoteArray`. It is persisted on the `_00_query`
|
|
1025
|
+
// row, but that row's id is salted with `session::id()`, which is new on every
|
|
1026
|
+
// page load (and `''` offline), and `doSwitchBucket` wipes the table outright.
|
|
1027
|
+
// So membership never survived a reload: the first paint fell back to a
|
|
1028
|
+
// predicate scan over whatever bodies were still cached, which re-included
|
|
1029
|
+
// rows the server had already dropped from the window.
|
|
1030
|
+
//
|
|
1031
|
+
// `_00_window` fixes that: same data, keyed by a session-independent hash, in
|
|
1032
|
+
// a table nothing wipes. Mirrors the durable `_00_preload` marker above.
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Read the durable membership row, or `null` if this query has never had
|
|
1036
|
+
* authoritative membership on this device. Any read error is treated as
|
|
1037
|
+
* "unknown" so a broken row degrades to the predicate scan rather than
|
|
1038
|
+
* rendering an empty list.
|
|
1039
|
+
*/
|
|
1040
|
+
async getWindowMembership(key: string): Promise<RecordVersionArray | null> {
|
|
1041
|
+
try {
|
|
1042
|
+
const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
|
|
1043
|
+
if (!row || typeof row !== 'object') return null;
|
|
1044
|
+
const ids = (row as any).ids;
|
|
1045
|
+
return Array.isArray(ids) ? (ids as RecordVersionArray) : null;
|
|
1046
|
+
} catch {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Persist the durable membership row. Best-effort: callers must not fail a
|
|
1052
|
+
* sync round because the mirror write failed. */
|
|
1053
|
+
async writeWindowMembership(key: string, ids: RecordVersionArray): Promise<void> {
|
|
1054
|
+
try {
|
|
1055
|
+
await this.local.upsert(
|
|
1056
|
+
'_00_window',
|
|
1057
|
+
new RecordId('_00_window', key),
|
|
1058
|
+
{ ids, updatedAt: Date.now() },
|
|
1059
|
+
'replace'
|
|
1060
|
+
);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
this.logger.debug(
|
|
1063
|
+
{ err, key, Category: 'sp00ky-client::DataModule::writeWindowMembership' },
|
|
1064
|
+
'Failed to persist window membership; it will be re-derived on next register'
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Record ids with a mutation still in the outbox, split by direction.
|
|
1071
|
+
*
|
|
1072
|
+
* Both halves feed {@link materializeRecords}: `writes` keeps optimistic
|
|
1073
|
+
* creates/updates visible before the server has acknowledged them, and
|
|
1074
|
+
* `deletes` suppresses rows the server still lists because our DELETE hasn't
|
|
1075
|
+
* been processed yet. Reading `_00_pending_mutations` (rather than tracking
|
|
1076
|
+
* ids in memory) is what makes both survive a reload.
|
|
1077
|
+
*
|
|
1078
|
+
* On failure returns empty sets: membership alone then decides, which can
|
|
1079
|
+
* briefly hide an optimistic write but never resurrects a deleted row.
|
|
1080
|
+
*/
|
|
1081
|
+
async getPendingRecordIds(): Promise<{ writes: Set<string>; deletes: Set<string> }> {
|
|
1082
|
+
const writes = new Set<string>();
|
|
1083
|
+
const deletes = new Set<string>();
|
|
1084
|
+
try {
|
|
1085
|
+
const [rows] = await this.local.query<
|
|
1086
|
+
[{ recordId: RecordId<string>; mutationType: string }[]]
|
|
1087
|
+
>('SELECT recordId, mutationType FROM _00_pending_mutations');
|
|
1088
|
+
for (const row of rows ?? []) {
|
|
1089
|
+
if (!row?.recordId) continue;
|
|
1090
|
+
const id = encodeRecordId(row.recordId);
|
|
1091
|
+
if (row.mutationType === 'delete') deletes.add(id);
|
|
1092
|
+
else writes.add(id);
|
|
1093
|
+
}
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
this.logger.warn(
|
|
1096
|
+
{ err, Category: 'sp00ky-client::DataModule::getPendingRecordIds' },
|
|
1097
|
+
'Failed to read pending mutations; optimistic writes may be briefly hidden'
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
return { writes, deletes };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
920
1103
|
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
921
1104
|
hasSubscribers(hash: string): boolean {
|
|
922
1105
|
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
@@ -1017,6 +1200,13 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1017
1200
|
}
|
|
1018
1201
|
const epoch = this.local.epoch;
|
|
1019
1202
|
queryState.config.remoteArray = remoteArray;
|
|
1203
|
+
// The single point where authoritative membership arrives (registration and
|
|
1204
|
+
// the `_00_list_ref` poll both land here), so it is where "we now know the
|
|
1205
|
+
// membership" is latched and where the durable mirror is written.
|
|
1206
|
+
queryState.config.membershipKnown = true;
|
|
1207
|
+
if (queryState.config.membershipKey) {
|
|
1208
|
+
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
1209
|
+
}
|
|
1020
1210
|
try {
|
|
1021
1211
|
await this.local.query(
|
|
1022
1212
|
surql.seal(surql.updateSet('id', ['remoteArray'])),
|
|
@@ -1074,6 +1264,18 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1074
1264
|
config.localArray = [];
|
|
1075
1265
|
config.remoteArray = [];
|
|
1076
1266
|
config.subqueryRemoteArray = undefined;
|
|
1267
|
+
// Membership belongs to the bucket we just left. Re-seed from the NEW
|
|
1268
|
+
// bucket's durable row if it has one (a returning user), otherwise mark it
|
|
1269
|
+
// unknown so the first paint falls back to a scan instead of rendering an
|
|
1270
|
+
// empty list until the server answers.
|
|
1271
|
+
config.membershipKnown = false;
|
|
1272
|
+
if (config.membershipKey) {
|
|
1273
|
+
const durable = await this.getWindowMembership(config.membershipKey);
|
|
1274
|
+
if (durable) {
|
|
1275
|
+
config.remoteArray = durable;
|
|
1276
|
+
config.membershipKnown = true;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1077
1279
|
queryState.hydrated = false;
|
|
1078
1280
|
queryState.syncNotified = false;
|
|
1079
1281
|
queryState.records = [];
|
|
@@ -1593,7 +1795,8 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1593
1795
|
params: Record<string, any>,
|
|
1594
1796
|
ttl: QueryTimeToLive,
|
|
1595
1797
|
tableName: T,
|
|
1596
|
-
plan?: QueryPlan
|
|
1798
|
+
plan?: QueryPlan,
|
|
1799
|
+
membershipKey?: string
|
|
1597
1800
|
): Promise<QueryHash> {
|
|
1598
1801
|
const queryState = await this.createNewQuery<T>({
|
|
1599
1802
|
recordId,
|
|
@@ -1602,6 +1805,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1602
1805
|
ttl,
|
|
1603
1806
|
tableName,
|
|
1604
1807
|
plan,
|
|
1808
|
+
membershipKey,
|
|
1605
1809
|
});
|
|
1606
1810
|
|
|
1607
1811
|
const t0 = performance.now();
|
|
@@ -1685,6 +1889,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1685
1889
|
ttl,
|
|
1686
1890
|
tableName,
|
|
1687
1891
|
plan,
|
|
1892
|
+
membershipKey,
|
|
1688
1893
|
}: {
|
|
1689
1894
|
recordId: RecordId;
|
|
1690
1895
|
surql: string;
|
|
@@ -1692,6 +1897,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1692
1897
|
ttl: QueryTimeToLive;
|
|
1693
1898
|
tableName: T;
|
|
1694
1899
|
plan?: QueryPlan;
|
|
1900
|
+
membershipKey?: string;
|
|
1695
1901
|
}): Promise<QueryState> {
|
|
1696
1902
|
// `_00_*` meta tables (feature flags, app releases) are framework-owned:
|
|
1697
1903
|
// they exist in the client db schema by construction but are never part of
|
|
@@ -1742,15 +1948,44 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1742
1948
|
// engines materialize via `select(plan)` instead of parsing `surql`.
|
|
1743
1949
|
plan,
|
|
1744
1950
|
params: parseParams(tableSchema.columns, configRecord.params),
|
|
1951
|
+
membershipKey,
|
|
1745
1952
|
};
|
|
1746
1953
|
|
|
1954
|
+
// The `_00_query` row we just read is session-salted, so on a reload it is
|
|
1955
|
+
// always a fresh row with empty arrays. Recover the last authoritative
|
|
1956
|
+
// membership from the durable `_00_window` row instead — this is what stops a
|
|
1957
|
+
// removed row reappearing after a reload, and it works with no network.
|
|
1958
|
+
if (membershipKey && !config.remoteArray?.length) {
|
|
1959
|
+
const durable = await this.getWindowMembership(membershipKey);
|
|
1960
|
+
if (durable) {
|
|
1961
|
+
config.remoteArray = durable;
|
|
1962
|
+
config.membershipKnown = true;
|
|
1963
|
+
}
|
|
1964
|
+
} else if (config.remoteArray?.length) {
|
|
1965
|
+
config.membershipKnown = true;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1747
1968
|
let records: Record<string, any>[] = [];
|
|
1748
1969
|
// Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
|
|
1749
1970
|
// `… LIMIT n START m` against the shared local store is O(m) — it sorts and
|
|
1750
1971
|
// skips m rows on every window open — AND returns the wrong rows for sparse
|
|
1751
1972
|
// windows (the reason `buildWindowMaterialization` exists). Those windows are
|
|
1752
1973
|
// seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
|
|
1753
|
-
|
|
1974
|
+
//
|
|
1975
|
+
// With known membership the same reasoning applies to EVERY query, windowed
|
|
1976
|
+
// or not: the predicate scan below would re-admit rows the server has already
|
|
1977
|
+
// dropped from the window (their bodies are still cached and still match the
|
|
1978
|
+
// WHERE). Seed from membership instead.
|
|
1979
|
+
if (config.membershipKnown) {
|
|
1980
|
+
try {
|
|
1981
|
+
records = await this.materializeFromConfig(config);
|
|
1982
|
+
} catch (err) {
|
|
1983
|
+
this.logger.warn(
|
|
1984
|
+
{ err, Category: 'sp00ky-client::DataModule::createNewQuery' },
|
|
1985
|
+
'Failed to seed records from durable membership'
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
} else if (buildWindowMaterialization(surqlString) === null) {
|
|
1754
1989
|
try {
|
|
1755
1990
|
// Prefer the engine-neutral plan (required for non-SurrealQL engines);
|
|
1756
1991
|
// fall back to running the raw surql on the SurrealDB engine.
|
|
@@ -1800,7 +2035,24 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1800
2035
|
// sessionId is part of the hash so the same logical query from two
|
|
1801
2036
|
// sessions (e.g. two browser tabs of the same user) lands on different
|
|
1802
2037
|
// `_00_query` rows and doesn't fight over a shared one.
|
|
1803
|
-
|
|
2038
|
+
return this.sha256(JSON.stringify({ ...data, sessionId: this.sessionId }));
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Session-independent counterpart of {@link calculateHash}: the key for a
|
|
2043
|
+
* query's durable `_00_window` membership row.
|
|
2044
|
+
*
|
|
2045
|
+
* Deliberately the SAME inputs minus the `session::id()` salt, so the two keys
|
|
2046
|
+
* can never drift apart. The salt is right for `_00_query` (two tabs must not
|
|
2047
|
+
* fight over one row) and wrong for membership, which has to be recognizable
|
|
2048
|
+
* after a reload — a reload mints a new session id, and offline the salt is
|
|
2049
|
+
* `''`, so a salted key can never match what the previous session wrote.
|
|
2050
|
+
*/
|
|
2051
|
+
private async calculateMembershipKey(data: any): Promise<string> {
|
|
2052
|
+
return this.sha256(JSON.stringify(data));
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
private async sha256(content: string): Promise<string> {
|
|
1804
2056
|
const msgBuffer = new TextEncoder().encode(content);
|
|
1805
2057
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
1806
2058
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
@@ -38,6 +38,58 @@ export function buildWindowMaterializationPlan(
|
|
|
38
38
|
ids: unknown[]
|
|
39
39
|
): QueryPlan | null {
|
|
40
40
|
if (plan.offset === undefined || plan.offset <= 0) return null;
|
|
41
|
+
return buildIdSetPlan(plan, ids);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Raw-SurrealQL counterpart of {@link buildIdSetPlan}, for queries that arrived
|
|
46
|
+
* without a plan. Same rewrite as {@link buildWindowMaterialization} without the
|
|
47
|
+
* offset guard: keep the projection and top-level `ORDER BY`, replace the source
|
|
48
|
+
* with the bound id-set.
|
|
49
|
+
*/
|
|
50
|
+
export function buildIdSetSurql(
|
|
51
|
+
surql: string,
|
|
52
|
+
idsParam = '__win'
|
|
53
|
+
): { query: string } | null {
|
|
54
|
+
const kw = scanTopLevelClauses(surql);
|
|
55
|
+
if (kw.fromIndex === null) return null;
|
|
56
|
+
return { query: renderIdSetSelect(surql, kw, idsParam) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shared body of the two id-set surql rewrites: keep the projection and the
|
|
60
|
+
* top-level `ORDER BY`, replace the source with the bound id-set. */
|
|
61
|
+
function renderIdSetSelect(surql: string, kw: TopLevelClauses, idsParam: string): string {
|
|
62
|
+
const selectClause = surql.slice(0, kw.fromIndex!).trimEnd(); // "SELECT <projection>"
|
|
63
|
+
|
|
64
|
+
let orderBy = '';
|
|
65
|
+
const orderByIndex = kw.orderByIndex;
|
|
66
|
+
if (orderByIndex !== null) {
|
|
67
|
+
const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
|
|
68
|
+
(n): n is number => n !== null && n > orderByIndex
|
|
69
|
+
);
|
|
70
|
+
orderBy = ' ' + surql.slice(orderByIndex, Math.min(...ends)).trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `${selectClause} FROM $${idsParam}${orderBy}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Restrict any plan's base rows to an explicit id-set, regardless of offset.
|
|
78
|
+
*
|
|
79
|
+
* Same rewrite as {@link buildWindowMaterializationPlan} without the offset
|
|
80
|
+
* guard, for rendering a query from its authoritative membership list rather
|
|
81
|
+
* than by re-running its predicate.
|
|
82
|
+
*
|
|
83
|
+
* `where` is dropped deliberately: the id-set already IS the answer to the
|
|
84
|
+
* predicate as the server evaluated it, and re-applying the `WHERE` locally is
|
|
85
|
+
* exactly the bug — a row that left the window keeps its (stale, never
|
|
86
|
+
* re-fetched) local body, so it keeps matching and keeps rendering.
|
|
87
|
+
* `limit`/`offset` go for the same reason: both are already baked into which ids
|
|
88
|
+
* are in the set, so the set itself is the bound. (Both engines ignore them on
|
|
89
|
+
* the `ids` path regardless — only `select` and `orderBy` are honored there.)
|
|
90
|
+
* `orderBy` is kept so display order stays deterministic.
|
|
91
|
+
*/
|
|
92
|
+
export function buildIdSetPlan(plan: QueryPlan, ids: unknown[]): QueryPlan {
|
|
41
93
|
return {
|
|
42
94
|
...plan,
|
|
43
95
|
ids,
|
|
@@ -54,19 +106,7 @@ export function buildWindowMaterialization(
|
|
|
54
106
|
const kw = scanTopLevelClauses(surql);
|
|
55
107
|
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
56
108
|
if (kw.fromIndex === null) return null;
|
|
57
|
-
|
|
58
|
-
const selectClause = surql.slice(0, kw.fromIndex).trimEnd(); // "SELECT <projection>"
|
|
59
|
-
|
|
60
|
-
let orderBy = '';
|
|
61
|
-
if (kw.orderByIndex !== null) {
|
|
62
|
-
const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
|
|
63
|
-
(n): n is number => n !== null && n > kw.orderByIndex!
|
|
64
|
-
);
|
|
65
|
-
const end = Math.min(...ends);
|
|
66
|
-
orderBy = ' ' + surql.slice(kw.orderByIndex, end).trim();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
|
|
109
|
+
return { query: renderIdSetSelect(surql, kw, idsParam) };
|
|
70
110
|
}
|
|
71
111
|
|
|
72
112
|
interface TopLevelClauses {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kySync } from './sync';
|
|
4
|
+
|
|
5
|
+
// A LIVE `_00_list_ref` DELETE is how one window learns another deleted a record.
|
|
6
|
+
//
|
|
7
|
+
// `remoteArray` — the authoritative membership rows are now rendered FROM — used
|
|
8
|
+
// to be written only by registration and the poll, so a LIVE removal left the
|
|
9
|
+
// departed id in the list (in memory AND persisted) until the next poll tick: up
|
|
10
|
+
// to 5s of showing a deleted row, and on a page that then went offline, forever.
|
|
11
|
+
//
|
|
12
|
+
// A removal-only diff also gets no re-render for free: `runSyncForQuery` leaves
|
|
13
|
+
// `fetching` false, and a removal needs no record fetch to trigger a stream
|
|
14
|
+
// update, so the notify has to be forced.
|
|
15
|
+
|
|
16
|
+
function makeSync(opts: { membershipKnown?: boolean } = {}) {
|
|
17
|
+
const logger: any = {
|
|
18
|
+
child: () => logger,
|
|
19
|
+
debug: () => {},
|
|
20
|
+
info: () => {},
|
|
21
|
+
warn: () => {},
|
|
22
|
+
error: () => {},
|
|
23
|
+
trace: () => {},
|
|
24
|
+
};
|
|
25
|
+
const queryId = new RecordId('_00_query', 'h1');
|
|
26
|
+
const queryState: any = {
|
|
27
|
+
config: {
|
|
28
|
+
id: queryId,
|
|
29
|
+
localArray: [
|
|
30
|
+
['thread:a', 1],
|
|
31
|
+
['thread:b', 1],
|
|
32
|
+
],
|
|
33
|
+
remoteArray: [
|
|
34
|
+
['thread:a', 1],
|
|
35
|
+
['thread:b', 1],
|
|
36
|
+
],
|
|
37
|
+
membershipKnown: opts.membershipKnown ?? true,
|
|
38
|
+
membershipKey: 'stable-key',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const updateQueryRemoteArray = vi.fn(async (_h: string, next: any) => {
|
|
43
|
+
queryState.config.remoteArray = next;
|
|
44
|
+
});
|
|
45
|
+
const notifyQuerySynced = vi.fn().mockResolvedValue(undefined);
|
|
46
|
+
const dataModule: any = {
|
|
47
|
+
getQueryById: vi.fn().mockReturnValue(queryState),
|
|
48
|
+
getQueryByHash: vi.fn().mockReturnValue(queryState),
|
|
49
|
+
updateQueryRemoteArray,
|
|
50
|
+
notifyQuerySynced,
|
|
51
|
+
getActiveQueryHashes: () => ['h1'],
|
|
52
|
+
getPendingRecordIds: async () => ({ writes: new Set(), deletes: new Set() }),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sync = new Sp00kySync(
|
|
56
|
+
{} as any,
|
|
57
|
+
{ query: vi.fn() } as any,
|
|
58
|
+
{} as any,
|
|
59
|
+
dataModule,
|
|
60
|
+
{} as any,
|
|
61
|
+
logger
|
|
62
|
+
);
|
|
63
|
+
// `runSyncForQuery` drives the engine + scheduler; both are out of scope here.
|
|
64
|
+
(sync as any).runSyncForQuery = vi.fn().mockResolvedValue(undefined);
|
|
65
|
+
|
|
66
|
+
const live = (action: 'CREATE' | 'UPDATE' | 'DELETE', id: string, version: number) =>
|
|
67
|
+
(sync as any).handleRemoteListRefChange(
|
|
68
|
+
action,
|
|
69
|
+
queryId,
|
|
70
|
+
new RecordId('thread', id),
|
|
71
|
+
version
|
|
72
|
+
) as Promise<void>;
|
|
73
|
+
|
|
74
|
+
return { sync, queryState, updateQueryRemoteArray, notifyQuerySynced, live };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('LIVE list_ref removal → membership', () => {
|
|
78
|
+
beforeEach(() => vi.clearAllMocks());
|
|
79
|
+
|
|
80
|
+
it('drops the removed id from remoteArray immediately', async () => {
|
|
81
|
+
const { queryState, updateQueryRemoteArray, live } = makeSync();
|
|
82
|
+
|
|
83
|
+
await live('DELETE', 'b', 2);
|
|
84
|
+
|
|
85
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [['thread:a', 1]]);
|
|
86
|
+
expect(queryState.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('forces a re-render for a removal-only diff', async () => {
|
|
90
|
+
const { notifyQuerySynced, live } = makeSync();
|
|
91
|
+
await live('DELETE', 'b', 2);
|
|
92
|
+
expect(notifyQuerySynced).toHaveBeenCalledWith('h1');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not force a re-render when rows were added (the stream update covers it)', async () => {
|
|
96
|
+
const { notifyQuerySynced, live } = makeSync();
|
|
97
|
+
await live('CREATE', 'c', 1);
|
|
98
|
+
expect(notifyQuerySynced).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('adds a newly-arrived id to membership', async () => {
|
|
102
|
+
const { updateQueryRemoteArray, live } = makeSync();
|
|
103
|
+
|
|
104
|
+
await live('CREATE', 'c', 1);
|
|
105
|
+
|
|
106
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
|
|
107
|
+
['thread:a', 1],
|
|
108
|
+
['thread:b', 1],
|
|
109
|
+
['thread:c', 1],
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('leaves membership alone while it is still unknown', async () => {
|
|
114
|
+
// Nothing authoritative has arrived yet, so there is no list to amend —
|
|
115
|
+
// registration will supply the whole thing shortly.
|
|
116
|
+
const { updateQueryRemoteArray, live } = makeSync({ membershipKnown: false });
|
|
117
|
+
await live('DELETE', 'b', 2);
|
|
118
|
+
expect(updateQueryRemoteArray).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('is a no-op for an unknown query', async () => {
|
|
122
|
+
const { sync, updateQueryRemoteArray } = makeSync();
|
|
123
|
+
(sync as any).dataModule.getQueryById = vi.fn().mockReturnValue(undefined);
|
|
124
|
+
|
|
125
|
+
await (sync as any).handleRemoteListRefChange(
|
|
126
|
+
'DELETE',
|
|
127
|
+
new RecordId('_00_query', 'other'),
|
|
128
|
+
new RecordId('thread', 'b'),
|
|
129
|
+
2
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(updateQueryRemoteArray).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
});
|