@spooky-sync/core 0.0.1-canary.237 → 0.0.1-canary.239
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/AGENTS.md +1 -1
- package/dist/index.d.ts +134 -5
- package/dist/index.js +498 -83
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +40 -27
- package/package.json +3 -3
- package/src/modules/data/data.activity.test.ts +109 -0
- package/src/modules/data/data.authority.test.ts +122 -0
- package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
- package/src/modules/data/data.membership.test.ts +168 -68
- package/src/modules/data/index.ts +248 -83
- package/src/modules/devtools/index.ts +3 -2
- package/src/modules/sync/engine.ts +12 -7
- package/src/modules/sync/sync.health.test.ts +13 -3
- package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
- package/src/modules/sync/sync.live-removal.test.ts +137 -23
- package/src/modules/sync/sync.ts +326 -60
- package/src/modules/sync/utils.test.ts +1 -1
- package/src/modules/sync/utils.ts +10 -2
- package/src/sp00ky.ts +64 -1
- package/src/types.ts +41 -26
- package/src/utils/error-classification.test.ts +10 -0
- package/src/utils/error-classification.ts +18 -0
package/src/modules/sync/sync.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
RecordVersionDiff,
|
|
10
10
|
SyncHealth,
|
|
11
11
|
SyncHealthStatus,
|
|
12
|
+
ServerViewMeta,
|
|
13
|
+
MembershipOutcome,
|
|
12
14
|
} from '../../types';
|
|
13
15
|
import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
|
|
14
16
|
import type { Logger } from '../../services/logger/index';
|
|
@@ -38,6 +40,60 @@ interface ListRefSnapshot {
|
|
|
38
40
|
subquery: RecordVersionArray;
|
|
39
41
|
/** `_00_query.rowCount`; `null` when the row was not readable. */
|
|
40
42
|
rowCount: number | null;
|
|
43
|
+
/** The `_00_query` row's presence, `rowCount` and publish `state`. */
|
|
44
|
+
meta: ServerViewMeta;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `{ rowCount, state }` object the row-count selects return per row. */
|
|
48
|
+
interface QueryMetaRow {
|
|
49
|
+
id?: RecordId<string>;
|
|
50
|
+
rowCount?: number | null;
|
|
51
|
+
state?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fold a row-count select result into {@link ServerViewMeta}. */
|
|
55
|
+
function metaFromRow(row: QueryMetaRow | null | undefined): ServerViewMeta {
|
|
56
|
+
if (!row || typeof row !== 'object') return { present: false, rowCount: null, state: null };
|
|
57
|
+
const state = row.state === 'materializing' || row.state === 'ready' ? row.state : null;
|
|
58
|
+
return {
|
|
59
|
+
present: true,
|
|
60
|
+
rowCount: typeof row.rowCount === 'number' ? row.rowCount : null,
|
|
61
|
+
state,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Re-read ladder for a registration that answered while the SSP was still
|
|
67
|
+
* publishing (`state = 'materializing'`): the flusher window is ~100ms, so the
|
|
68
|
+
* first rung usually lands it; the rest cover a slow write without waiting
|
|
69
|
+
* for a backed-off poll tick.
|
|
70
|
+
*/
|
|
71
|
+
const MATERIALIZING_REFETCH_LADDER_MS: readonly number[] = [150, 400, 1000];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How long LIVE `_00_list_ref` DELETEs for one query are held before they are
|
|
75
|
+
* verified upstream as a batch. A view teardown (sweep, reset, republish)
|
|
76
|
+
* arrives as one DELETE per edge within a few ms; a single peer delete costs
|
|
77
|
+
* this plus one round trip.
|
|
78
|
+
*/
|
|
79
|
+
const LIVE_REMOVAL_COALESCE_MS = 100;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Collapse duplicate `(id, version)` pairs to one per id, keeping the highest
|
|
83
|
+
* version. `_00_list_ref` has no unique index on `(in, out)`, and a full
|
|
84
|
+
* republish over edges that were never cleaned up (a scheduler restart
|
|
85
|
+
* orphans them) used to leave two rows per record; the client must render
|
|
86
|
+
* each record once regardless.
|
|
87
|
+
*/
|
|
88
|
+
function dedupeRecordVersions(pairs: RecordVersionArray): RecordVersionArray {
|
|
89
|
+
if (pairs.length < 2) return pairs;
|
|
90
|
+
const best = new Map<string, number>();
|
|
91
|
+
for (const [id, version] of pairs) {
|
|
92
|
+
const prev = best.get(id);
|
|
93
|
+
if (prev === undefined || version > prev) best.set(id, version);
|
|
94
|
+
}
|
|
95
|
+
if (best.size === pairs.length) return pairs;
|
|
96
|
+
return Array.from(best.entries());
|
|
41
97
|
}
|
|
42
98
|
|
|
43
99
|
interface ListRefEdgeRow {
|
|
@@ -788,6 +844,8 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
788
844
|
public async prepareBucketSwitch(): Promise<void> {
|
|
789
845
|
this.stopSelfHeal();
|
|
790
846
|
this.stopListRefPoll();
|
|
847
|
+
this.clearListRefRefetches();
|
|
848
|
+
this.clearLiveRemovals();
|
|
791
849
|
if (this.listRefPollInFlight) await this.listRefPollInFlight;
|
|
792
850
|
await this.killRefLiveQuery();
|
|
793
851
|
this.upQueue.clearDebounceTimers();
|
|
@@ -1011,7 +1069,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1011
1069
|
if (!snapshot) continue;
|
|
1012
1070
|
this.listRefPolledAt.set(hash, now);
|
|
1013
1071
|
try {
|
|
1014
|
-
if (await this.applyListRefSnapshot(hash, snapshot)) anyChanged = true;
|
|
1072
|
+
if ((await this.applyListRefSnapshot(hash, snapshot)).changed) anyChanged = true;
|
|
1015
1073
|
} catch (err) {
|
|
1016
1074
|
this.logger.debug(
|
|
1017
1075
|
{
|
|
@@ -1069,11 +1127,16 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1069
1127
|
return out;
|
|
1070
1128
|
}
|
|
1071
1129
|
const [edges, counts] = await this.remote.query<
|
|
1072
|
-
[ListRefEdgeRow[] | null, (
|
|
1130
|
+
[ListRefEdgeRow[] | null, (QueryMetaRow | null)[] | null]
|
|
1073
1131
|
>(`${buildListRefBatchSelect(listRefTbl)};\n${buildQueryRowCountBatchSelect()}`, { ins: ids });
|
|
1074
1132
|
if (!Array.isArray(edges)) return out;
|
|
1075
1133
|
for (const hash of hashById.values()) {
|
|
1076
|
-
out.set(hash, {
|
|
1134
|
+
out.set(hash, {
|
|
1135
|
+
primary: [],
|
|
1136
|
+
subquery: [],
|
|
1137
|
+
rowCount: null,
|
|
1138
|
+
meta: { present: false, rowCount: null, state: null },
|
|
1139
|
+
});
|
|
1077
1140
|
}
|
|
1078
1141
|
for (const row of edges) {
|
|
1079
1142
|
const hash = hashById.get(encodeRecordId(row.in));
|
|
@@ -1083,22 +1146,35 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1083
1146
|
if (row.parent == null) snapshot.primary.push(pair);
|
|
1084
1147
|
else snapshot.subquery.push(pair);
|
|
1085
1148
|
}
|
|
1149
|
+
for (const snapshot of out.values()) {
|
|
1150
|
+
snapshot.primary = dedupeRecordVersions(snapshot.primary);
|
|
1151
|
+
snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
|
|
1152
|
+
}
|
|
1086
1153
|
for (const count of Array.isArray(counts) ? counts : []) {
|
|
1087
1154
|
if (!count || !count.id) continue;
|
|
1088
1155
|
const hash = hashById.get(encodeRecordId(count.id));
|
|
1089
1156
|
if (!hash) continue;
|
|
1090
|
-
out.get(hash)
|
|
1157
|
+
const snapshot = out.get(hash)!;
|
|
1158
|
+
snapshot.meta = metaFromRow(count);
|
|
1159
|
+
snapshot.rowCount = snapshot.meta.rowCount;
|
|
1091
1160
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1161
|
+
// Two shapes of a batch that must not be believed at face value: no edge
|
|
1162
|
+
// at all while the server still reports rows for a query we hold (a
|
|
1163
|
+
// silently empty batch), and a query we hold whose `_00_query` row did
|
|
1164
|
+
// not come back (a lost view, or a batch that missed it). Both are
|
|
1165
|
+
// re-read one query at a time before anything is applied, because an
|
|
1166
|
+
// empty set taken at face value is the removal of every row, and a
|
|
1167
|
+
// missing row taken at face value would re-register a healthy view.
|
|
1168
|
+
const suspect = Array.from(out.entries()).some(([hash, snapshot]) => {
|
|
1169
|
+
const held = this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0;
|
|
1170
|
+
if (held === 0) return false;
|
|
1171
|
+
if (!snapshot.meta.present) return true;
|
|
1172
|
+
return edges.length === 0 && (snapshot.rowCount ?? 0) > 0;
|
|
1173
|
+
});
|
|
1098
1174
|
if (suspect) {
|
|
1099
1175
|
this.logger.warn(
|
|
1100
1176
|
{ hashes, Category: 'sp00ky-client::Sp00kySync::fetchListRefSnapshots' },
|
|
1101
|
-
'Batched list_ref select
|
|
1177
|
+
'Batched list_ref select is not trusted for queries that hold rows (no edges, or a missing _00_query row); re-reading one query at a time'
|
|
1102
1178
|
);
|
|
1103
1179
|
for (const hash of Array.from(out.keys())) {
|
|
1104
1180
|
const queryState = this.dataModule.getQueryByHash(hash);
|
|
@@ -1121,10 +1197,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1121
1197
|
*/
|
|
1122
1198
|
private async fetchListRefSnapshot(id: RecordId<string>): Promise<ListRefSnapshot | null> {
|
|
1123
1199
|
const listRefTbl = this.listRefTable();
|
|
1124
|
-
const [items,
|
|
1200
|
+
const [items, metaRow, children] = await this.remote.query<
|
|
1125
1201
|
[
|
|
1126
1202
|
{ out: RecordId<string>; version: number }[] | null,
|
|
1127
|
-
|
|
1203
|
+
QueryMetaRow | null,
|
|
1128
1204
|
{ out: RecordId<string>; version: number }[] | null,
|
|
1129
1205
|
]
|
|
1130
1206
|
>(
|
|
@@ -1133,11 +1209,15 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1133
1209
|
);
|
|
1134
1210
|
if (!Array.isArray(items)) return null;
|
|
1135
1211
|
const toPairs = (rows: { out: RecordId<string>; version: number }[] | null): RecordVersionArray =>
|
|
1136
|
-
|
|
1212
|
+
dedupeRecordVersions(
|
|
1213
|
+
Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : []
|
|
1214
|
+
);
|
|
1215
|
+
const meta = metaFromRow(metaRow);
|
|
1137
1216
|
return {
|
|
1138
1217
|
primary: toPairs(items),
|
|
1139
1218
|
subquery: toPairs(children),
|
|
1140
|
-
rowCount:
|
|
1219
|
+
rowCount: meta.rowCount,
|
|
1220
|
+
meta,
|
|
1141
1221
|
};
|
|
1142
1222
|
}
|
|
1143
1223
|
|
|
@@ -1153,26 +1233,24 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1153
1233
|
private async applyListRefSnapshot(
|
|
1154
1234
|
queryHash: string,
|
|
1155
1235
|
snapshot: ListRefSnapshot
|
|
1156
|
-
): Promise<boolean> {
|
|
1236
|
+
): Promise<{ changed: boolean; outcome: MembershipOutcome | 'unchanged' }> {
|
|
1157
1237
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
1158
|
-
if (!queryState) return false;
|
|
1238
|
+
if (!queryState) return { changed: false, outcome: 'unchanged' };
|
|
1159
1239
|
const fresh = snapshot.primary;
|
|
1160
|
-
const serverRowCount = snapshot.rowCount;
|
|
1161
1240
|
// Capture which ids LEFT the query's window (present in the cached
|
|
1162
1241
|
// remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
|
|
1163
1242
|
// are cross-window deletes (or rows that scrolled out). They drive the
|
|
1164
|
-
// forced re-render below.
|
|
1243
|
+
// forced re-render below. Only meaningful once the set is actually applied.
|
|
1165
1244
|
const prevRemote = queryState.config.remoteArray ?? [];
|
|
1166
|
-
const freshIds = new Set(fresh.map(([id]) => id));
|
|
1167
|
-
const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
|
|
1168
1245
|
// Idempotent poll: only persist the remoteArray when it actually changed.
|
|
1169
1246
|
// The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
|
|
1170
1247
|
// equals the cached array every tick — re-writing it (an `UPDATE _00_query`
|
|
1171
1248
|
// each cycle, per active query) was pure churn and the bulk of the idle
|
|
1172
1249
|
// traffic. `recordVersionArraysEqual` is order-insensitive because the
|
|
1173
1250
|
// list_ref SELECT has no `ORDER BY`.
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1251
|
+
const differs = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
|
|
1252
|
+
let outcome: MembershipOutcome | 'unchanged' = 'unchanged';
|
|
1253
|
+
if (differs) {
|
|
1176
1254
|
// Update the cached remoteArray so the next diff/sync sees the new state.
|
|
1177
1255
|
// `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
|
|
1178
1256
|
// the local DB row and ingests it into the in-browser SSP — the SSP's
|
|
@@ -1180,14 +1258,27 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1180
1258
|
// and notifies subscribers. We skip an explicit `notifyQuerySynced`
|
|
1181
1259
|
// because that path races the stream-update path (can notify with stale
|
|
1182
1260
|
// records).
|
|
1183
|
-
|
|
1261
|
+
//
|
|
1262
|
+
// The DataModule arbitrates an EMPTY set against the `_00_query` row it
|
|
1263
|
+
// was read with (`snapshot.meta`): a missing row is a lost view (kept +
|
|
1264
|
+
// re-registered), a `materializing` row is a publish in flight, and only
|
|
1265
|
+
// a `ready` row with `rowCount 0` empties the query.
|
|
1266
|
+
outcome = await this.dataModule.updateQueryRemoteArray(queryHash, fresh, {
|
|
1267
|
+
meta: snapshot.meta,
|
|
1268
|
+
});
|
|
1184
1269
|
}
|
|
1270
|
+
const applied = differs && outcome === 'applied';
|
|
1271
|
+
const freshIds = new Set(fresh.map(([id]) => id));
|
|
1272
|
+
const removedIds = applied
|
|
1273
|
+
? prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id)
|
|
1274
|
+
: [];
|
|
1185
1275
|
// Run `syncQuery` every tick regardless: it's a no-op when localArray has
|
|
1186
1276
|
// caught up to remoteArray (`if (!diff) return`, issues no query), but it
|
|
1187
1277
|
// covers the rare case where remoteArray is stable yet localArray is behind
|
|
1188
1278
|
// (a prior record fetch failed) — so a missed row still gets retried.
|
|
1189
1279
|
// For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
|
|
1190
|
-
// confirmed-gone records from the local DB.
|
|
1280
|
+
// confirmed-gone records from the local DB. A rejected snapshot left
|
|
1281
|
+
// `remoteArray` alone, so this cannot act on it.
|
|
1191
1282
|
try {
|
|
1192
1283
|
await this.syncQuery(queryHash);
|
|
1193
1284
|
} catch (err) {
|
|
@@ -1204,20 +1295,23 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1204
1295
|
// it, so the engine already holds it at the published version): nothing
|
|
1205
1296
|
// then re-materializes the query. Ask for one; it is a no-op behind a
|
|
1206
1297
|
// stream update that a fetch above already queued.
|
|
1207
|
-
if (
|
|
1298
|
+
if (applied) this.dataModule.scheduleRematerialize(queryHash);
|
|
1208
1299
|
// Cross-session fallback for `.related()` child rows: the LIVE-permission
|
|
1209
1300
|
// gap can drop child-edge notifications, so converge their bodies on the
|
|
1210
|
-
// poll too (idempotent — no-op when nothing changed).
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1301
|
+
// poll too (idempotent — no-op when nothing changed). Not for a snapshot
|
|
1302
|
+
// that was rejected: its child set is as untrustworthy as its primary set.
|
|
1303
|
+
if (outcome === 'applied' || outcome === 'unchanged') {
|
|
1304
|
+
await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
|
|
1305
|
+
this.logger.info(
|
|
1306
|
+
{
|
|
1307
|
+
err: (err as Error)?.message ?? err,
|
|
1308
|
+
queryHash,
|
|
1309
|
+
Category: 'sp00ky-client::Sp00kySync::applyListRefSnapshot',
|
|
1310
|
+
},
|
|
1311
|
+
'Subquery child sync failed during poll'
|
|
1312
|
+
);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1221
1315
|
// A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
|
|
1222
1316
|
// get a re-render from the SSP stream on this code path reliably (and the
|
|
1223
1317
|
// non-windowed window-0 query re-queries the local DB rather than the id-set).
|
|
@@ -1237,7 +1331,76 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1237
1331
|
);
|
|
1238
1332
|
}
|
|
1239
1333
|
}
|
|
1240
|
-
return changed;
|
|
1334
|
+
return { changed: applied, outcome };
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Re-read ONE query's server state now (edges + `_00_query` row) and land
|
|
1339
|
+
* it through {@link applyListRefSnapshot}. The targeted form of the poll,
|
|
1340
|
+
* for the moments that cannot wait for the next tick: a registration that
|
|
1341
|
+
* answered `materializing`, and a LIVE removal that has to be checked
|
|
1342
|
+
* against the row before it may empty a painted list.
|
|
1343
|
+
*/
|
|
1344
|
+
private async refetchListRefForQuery(
|
|
1345
|
+
queryHash: string
|
|
1346
|
+
): Promise<{ changed: boolean; outcome: MembershipOutcome | 'unchanged' } | null> {
|
|
1347
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
1348
|
+
if (!queryState) return null;
|
|
1349
|
+
const snapshot = await this.fetchListRefSnapshot(queryState.config.id);
|
|
1350
|
+
if (!snapshot) return null;
|
|
1351
|
+
return this.applyListRefSnapshot(queryHash, snapshot);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Per-query pending targeted re-reads: one timer and the ladder still to walk. */
|
|
1355
|
+
private listRefRefetches = new Map<string, { timer: ReturnType<typeof setTimeout>; ladder: number[] }>();
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Schedule a targeted re-read of `queryHash` after `delays[0]` ms. If the
|
|
1359
|
+
* read still says the server is publishing (`materializing`), the next rung
|
|
1360
|
+
* of the ladder is armed; any other outcome ends it. Re-scheduling a hash
|
|
1361
|
+
* that already has a timer replaces it (latest wins), so bursts coalesce.
|
|
1362
|
+
*/
|
|
1363
|
+
private scheduleListRefRefetch(queryHash: string, delays: number | readonly number[]): void {
|
|
1364
|
+
const ladder = typeof delays === 'number' ? [delays] : [...delays];
|
|
1365
|
+
if (ladder.length === 0) return;
|
|
1366
|
+
this.clearListRefRefetch(queryHash);
|
|
1367
|
+
const [first, ...rest] = ladder;
|
|
1368
|
+
const timer = setTimeout(() => {
|
|
1369
|
+
this.listRefRefetches.delete(queryHash);
|
|
1370
|
+
void this.refetchListRefForQuery(queryHash)
|
|
1371
|
+
.then((result) => {
|
|
1372
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
1373
|
+
const stillMaterializing =
|
|
1374
|
+
result?.outcome === 'ignored' && queryState?.serverState === 'materializing';
|
|
1375
|
+
if (stillMaterializing && rest.length > 0) {
|
|
1376
|
+
this.scheduleListRefRefetch(queryHash, rest);
|
|
1377
|
+
}
|
|
1378
|
+
})
|
|
1379
|
+
.catch((err) => {
|
|
1380
|
+
this.logger.info(
|
|
1381
|
+
{
|
|
1382
|
+
err: (err as Error)?.message ?? err,
|
|
1383
|
+
queryHash,
|
|
1384
|
+
Category: 'sp00ky-client::Sp00kySync::scheduleListRefRefetch',
|
|
1385
|
+
},
|
|
1386
|
+
'Targeted list_ref re-read failed; the poll will retry'
|
|
1387
|
+
);
|
|
1388
|
+
});
|
|
1389
|
+
}, first);
|
|
1390
|
+
this.listRefRefetches.set(queryHash, { timer, ladder: rest });
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
private clearListRefRefetch(queryHash: string): void {
|
|
1394
|
+
const pending = this.listRefRefetches.get(queryHash);
|
|
1395
|
+
if (pending) {
|
|
1396
|
+
clearTimeout(pending.timer);
|
|
1397
|
+
this.listRefRefetches.delete(queryHash);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
private clearListRefRefetches(): void {
|
|
1402
|
+
for (const { timer } of this.listRefRefetches.values()) clearTimeout(timer);
|
|
1403
|
+
this.listRefRefetches.clear();
|
|
1241
1404
|
}
|
|
1242
1405
|
|
|
1243
1406
|
/**
|
|
@@ -1580,11 +1743,25 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1580
1743
|
// relayed from another tab). The fetch is rightly skipped then, but the
|
|
1581
1744
|
// membership still has to be recorded, or the row lives on the
|
|
1582
1745
|
// settled-write grace alone until the poll catches it.
|
|
1746
|
+
// A DELETE is not applied on arrival. Every teardown of a view - the TTL
|
|
1747
|
+
// sweep, an SSP reset, a scheduler wipe, a full republish - reaches the
|
|
1748
|
+
// client as one DELETE per edge, indistinguishable from a peer deleting
|
|
1749
|
+
// rows, and obeying them blanked painted lists n-1 rows at a time. Removals
|
|
1750
|
+
// are coalesced briefly and VERIFIED upstream before membership moves: an
|
|
1751
|
+
// id that no longer exists is dropped (and its body deleted); anything
|
|
1752
|
+
// else is settled by re-reading the server's whole set for the query, where
|
|
1753
|
+
// a missing `_00_query` row means "view lost, keep everything".
|
|
1754
|
+
if (action === 'DELETE') {
|
|
1755
|
+
this.queueLiveRemoval(hash, recordId);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1583
1759
|
if (existing.config.membershipKnown) {
|
|
1584
|
-
const membershipDiff: RecordVersionDiff =
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1760
|
+
const membershipDiff: RecordVersionDiff = {
|
|
1761
|
+
added: [{ id: recordId, version }],
|
|
1762
|
+
updated: [],
|
|
1763
|
+
removed: [],
|
|
1764
|
+
};
|
|
1588
1765
|
const next = applyRecordVersionDiff(existing.config.remoteArray ?? [], membershipDiff);
|
|
1589
1766
|
if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) {
|
|
1590
1767
|
await this.dataModule.updateQueryRemoteArray(hash, next);
|
|
@@ -1593,13 +1770,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1593
1770
|
|
|
1594
1771
|
await this.runSyncForQuery(hash, diff);
|
|
1595
1772
|
|
|
1596
|
-
|
|
1597
|
-
// no `flushPendingStreamUpdate`/`endFetching` re-render — and a removal needs
|
|
1598
|
-
// no record fetch to trigger one either. Force it, mirroring what the poll
|
|
1599
|
-
// path already does for its own removals (`refetchListRefForQuery`).
|
|
1600
|
-
if (diff.removed.length > 0 && diff.added.length === 0 && diff.updated.length === 0) {
|
|
1601
|
-
await this.dataModule.notifyQuerySynced(hash);
|
|
1602
|
-
} else if (diff.added.length === 0 && diff.updated.length === 0) {
|
|
1773
|
+
if (diff.added.length === 0 && diff.updated.length === 0) {
|
|
1603
1774
|
// Empty diff: the circuit already holds the row at this version (this
|
|
1604
1775
|
// tab wrote it), so no fetch and no stream update - but the membership
|
|
1605
1776
|
// recorded above is new, and the subscribers have to hear about it.
|
|
@@ -1607,6 +1778,79 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1607
1778
|
}
|
|
1608
1779
|
}
|
|
1609
1780
|
|
|
1781
|
+
/** LIVE removals awaiting verification, per query, with their coalescing timer. */
|
|
1782
|
+
private pendingLiveRemovals = new Map<string, { ids: Map<string, RecordId>; timer: ReturnType<typeof setTimeout> }>();
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Hold a LIVE `_00_list_ref` DELETE for {@link LIVE_REMOVAL_COALESCE_MS},
|
|
1786
|
+
* then verify the batch (see {@link flushLiveRemovals}). A sweep or a
|
|
1787
|
+
* republish delivers a view's whole set as one burst, so one verification
|
|
1788
|
+
* round covers it.
|
|
1789
|
+
*/
|
|
1790
|
+
private queueLiveRemoval(hash: string, recordId: RecordId): void {
|
|
1791
|
+
const key = encodeRecordId(recordId);
|
|
1792
|
+
const pending = this.pendingLiveRemovals.get(hash);
|
|
1793
|
+
if (pending) {
|
|
1794
|
+
pending.ids.set(key, recordId);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
const ids = new Map<string, RecordId>([[key, recordId]]);
|
|
1798
|
+
const timer = setTimeout(() => {
|
|
1799
|
+
this.pendingLiveRemovals.delete(hash);
|
|
1800
|
+
void this.flushLiveRemovals(hash, Array.from(ids.values())).catch((err) => {
|
|
1801
|
+
this.logger.info(
|
|
1802
|
+
{
|
|
1803
|
+
err: (err as Error)?.message ?? err,
|
|
1804
|
+
hash,
|
|
1805
|
+
Category: 'sp00ky-client::Sp00kySync::flushLiveRemovals',
|
|
1806
|
+
},
|
|
1807
|
+
'LIVE removal verification failed; the poll will converge'
|
|
1808
|
+
);
|
|
1809
|
+
});
|
|
1810
|
+
}, LIVE_REMOVAL_COALESCE_MS);
|
|
1811
|
+
this.pendingLiveRemovals.set(hash, { ids, timer });
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Verify a coalesced batch of LIVE removals against the upstream records.
|
|
1816
|
+
* Ids that no longer exist upstream are dropped from membership (the body
|
|
1817
|
+
* delete already happened inside `syncRecords`) and the query re-renders.
|
|
1818
|
+
* Anything still present upstream - or a verification that could not run -
|
|
1819
|
+
* is settled by a targeted re-read of the server's set for this query,
|
|
1820
|
+
* where the `_00_query` row decides: gone = view lost (keep rows,
|
|
1821
|
+
* re-register), `materializing` = wait, `ready` = adopt the smaller set.
|
|
1822
|
+
*/
|
|
1823
|
+
private async flushLiveRemovals(hash: string, ids: RecordId[]): Promise<void> {
|
|
1824
|
+
const existing = this.dataModule.getQueryByHash(hash);
|
|
1825
|
+
if (!existing || ids.length === 0) return;
|
|
1826
|
+
const { confirmedRemovedIds, stillRemoteIds } = await this.runSyncForQuery(hash, {
|
|
1827
|
+
added: [],
|
|
1828
|
+
updated: [],
|
|
1829
|
+
removed: ids,
|
|
1830
|
+
});
|
|
1831
|
+
if (confirmedRemovedIds.length > 0 && existing.config.membershipKnown) {
|
|
1832
|
+
const gone = new Set(confirmedRemovedIds);
|
|
1833
|
+
const held = existing.config.remoteArray ?? [];
|
|
1834
|
+
const next = held.filter(([id]) => !gone.has(id));
|
|
1835
|
+
if (next.length !== held.length) {
|
|
1836
|
+
await this.dataModule.updateQueryRemoteArray(hash, next, { verifiedRemoval: true });
|
|
1837
|
+
}
|
|
1838
|
+
// A removal-only diff sets `fetching` false in `runSyncForQuery`, so it
|
|
1839
|
+
// gets no `flushPendingStreamUpdate`/`endFetching` re-render - and a
|
|
1840
|
+
// removal needs no record fetch to trigger one either. Force it.
|
|
1841
|
+
await this.dataModule.notifyQuerySynced(hash);
|
|
1842
|
+
}
|
|
1843
|
+
const unverified = confirmedRemovedIds.length + stillRemoteIds.length < ids.length;
|
|
1844
|
+
if (stillRemoteIds.length > 0 || unverified) {
|
|
1845
|
+
this.scheduleListRefRefetch(hash, 0);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
private clearLiveRemovals(): void {
|
|
1850
|
+
for (const { timer } of this.pendingLiveRemovals.values()) clearTimeout(timer);
|
|
1851
|
+
this.pendingLiveRemovals.clear();
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1610
1854
|
/**
|
|
1611
1855
|
* Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
|
|
1612
1856
|
* `parent` set) for a `.related()` query. Unlike primary rows, child rows
|
|
@@ -1882,7 +2126,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1882
2126
|
if (!diff) {
|
|
1883
2127
|
return;
|
|
1884
2128
|
}
|
|
1885
|
-
|
|
2129
|
+
await this.runSyncForQuery(hash, diff);
|
|
1886
2130
|
}
|
|
1887
2131
|
|
|
1888
2132
|
/**
|
|
@@ -1892,7 +2136,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1892
2136
|
* never leaves a query stuck `fetching`. Part A's notification coalescing
|
|
1893
2137
|
* means the single resulting UI update lands after this completes.
|
|
1894
2138
|
*/
|
|
1895
|
-
private async runSyncForQuery(
|
|
2139
|
+
private async runSyncForQuery(
|
|
2140
|
+
hash: string,
|
|
2141
|
+
diff: RecordVersionDiff
|
|
2142
|
+
): Promise<{ confirmedRemovedIds: string[]; stillRemoteIds: string[] }> {
|
|
2143
|
+
const nothing = { confirmedRemovedIds: [] as string[], stillRemoteIds: [] as string[] };
|
|
1896
2144
|
// The diff was computed against whatever the circuit held at call time; if
|
|
1897
2145
|
// the boot prime is still filling it, wait and recompute from the primed
|
|
1898
2146
|
// `localArray` so the delta is real rather than "everything".
|
|
@@ -1901,9 +2149,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1901
2149
|
await prime;
|
|
1902
2150
|
this.settledPrime = prime;
|
|
1903
2151
|
const fresh = this.dataModule.getQueryByHash(hash);
|
|
1904
|
-
if (!fresh) return;
|
|
2152
|
+
if (!fresh) return nothing;
|
|
1905
2153
|
const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
|
|
1906
|
-
if (!recomputed) return;
|
|
2154
|
+
if (!recomputed) return nothing;
|
|
1907
2155
|
diff = recomputed;
|
|
1908
2156
|
}
|
|
1909
2157
|
// Don't let sync re-add a record the user just deleted locally. The remote
|
|
@@ -1929,8 +2177,11 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1929
2177
|
if (fetching) {
|
|
1930
2178
|
this.dataModule.beginFetching(hash);
|
|
1931
2179
|
}
|
|
2180
|
+
let summary = nothing;
|
|
1932
2181
|
try {
|
|
1933
|
-
const { remoteFetchMs, stillRemoteIds } =
|
|
2182
|
+
const { remoteFetchMs, stillRemoteIds, confirmedRemovedIds } =
|
|
2183
|
+
await this.syncEngine.syncRecords(diff);
|
|
2184
|
+
summary = { confirmedRemovedIds, stillRemoteIds };
|
|
1934
2185
|
if (fetching) {
|
|
1935
2186
|
this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
|
|
1936
2187
|
}
|
|
@@ -1996,6 +2247,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1996
2247
|
this.dataModule.endFetching(hash);
|
|
1997
2248
|
}
|
|
1998
2249
|
}
|
|
2250
|
+
return summary;
|
|
1999
2251
|
}
|
|
2000
2252
|
|
|
2001
2253
|
/**
|
|
@@ -2144,11 +2396,12 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
2144
2396
|
// `rowCount` rides along: it is written by the SSP in the same statement
|
|
2145
2397
|
// that registers the view, BEFORE the edges are flushed, so it is the only
|
|
2146
2398
|
// way to tell "this query is empty" from "its edges have not landed yet".
|
|
2147
|
-
const [items,
|
|
2148
|
-
[{ out: RecordId<string>; version: number }[],
|
|
2399
|
+
const [items, metaRow] = await this.remote.query<
|
|
2400
|
+
[{ out: RecordId<string>; version: number }[], QueryMetaRow | null]
|
|
2149
2401
|
>(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
|
|
2150
2402
|
in: queryState.config.id,
|
|
2151
2403
|
});
|
|
2404
|
+
const meta = metaFromRow(metaRow);
|
|
2152
2405
|
|
|
2153
2406
|
this.logger.trace(
|
|
2154
2407
|
{
|
|
@@ -2159,7 +2412,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
2159
2412
|
'Got query record version array from remote'
|
|
2160
2413
|
);
|
|
2161
2414
|
|
|
2162
|
-
const array: RecordVersionArray =
|
|
2415
|
+
const array: RecordVersionArray = dedupeRecordVersions(
|
|
2416
|
+
items.map((item) => [encodeRecordId(item.out), item.version])
|
|
2417
|
+
);
|
|
2163
2418
|
|
|
2164
2419
|
this.logger.debug(
|
|
2165
2420
|
{
|
|
@@ -2170,9 +2425,14 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
2170
2425
|
'createdRemoteQuery'
|
|
2171
2426
|
);
|
|
2172
2427
|
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2428
|
+
const outcome = await this.dataModule.updateQueryRemoteArray(queryHash, array, { meta });
|
|
2429
|
+
// A registration that answered before its edges landed: the SSP wrote
|
|
2430
|
+
// `materializing` and handed the publish to its flusher (100ms window).
|
|
2431
|
+
// Do not leave the fresh query waiting for a backed-off poll tick; re-read
|
|
2432
|
+
// it a few times on a short ladder. LIVE CREATEs are ignored for a query
|
|
2433
|
+
// whose membership is still unknown, so this is the only fast path.
|
|
2434
|
+
if (outcome === 'ignored' && meta.present && meta.state === 'materializing') {
|
|
2435
|
+
this.scheduleListRefRefetch(queryHash, MATERIALIZING_REFETCH_LADDER_MS);
|
|
2176
2436
|
}
|
|
2177
2437
|
|
|
2178
2438
|
// Pull the bodies of any `.related()` subquery children into the local
|
|
@@ -2355,6 +2615,12 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
2355
2615
|
}
|
|
2356
2616
|
|
|
2357
2617
|
// No subscribers throughout → safe to free the local view + state.
|
|
2618
|
+
this.clearListRefRefetch(queryHash);
|
|
2619
|
+
const pendingRemoval = this.pendingLiveRemovals.get(queryHash);
|
|
2620
|
+
if (pendingRemoval) {
|
|
2621
|
+
clearTimeout(pendingRemoval.timer);
|
|
2622
|
+
this.pendingLiveRemovals.delete(queryHash);
|
|
2623
|
+
}
|
|
2358
2624
|
this.dataModule.finalizeDeregister(queryHash);
|
|
2359
2625
|
}
|
|
2360
2626
|
}
|
|
@@ -586,7 +586,7 @@ describe('list_ref poll batching', () => {
|
|
|
586
586
|
'SELECT in, out, version, parent FROM _00_list_ref_user_abc WHERE in IN $ins'
|
|
587
587
|
);
|
|
588
588
|
expect(buildQueryRowCountBatchSelect()).toBe(
|
|
589
|
-
'SELECT VALUE { id: id, rowCount: rowCount } FROM $ins'
|
|
589
|
+
'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins'
|
|
590
590
|
);
|
|
591
591
|
});
|
|
592
592
|
|
|
@@ -200,11 +200,19 @@ export function buildListRefSelect(table: string): string {
|
|
|
200
200
|
* no matter how long it waits, which is why this is read alongside them rather
|
|
201
201
|
* than a retry counter.
|
|
202
202
|
*
|
|
203
|
+
* `state` rides along: the SSP writes `materializing` when it hands a full
|
|
204
|
+
* publish to its edge flusher and flips it to `ready` INSIDE the transaction
|
|
205
|
+
* that writes the edges. So `ready` + no edges + `rowCount 0` is a real empty
|
|
206
|
+
* result, `materializing` + no edges is "still publishing", and a missing row
|
|
207
|
+
* (the whole object is `NONE`) is "the server no longer has this view". A
|
|
208
|
+
* server that predates the marker returns `state: NONE` and callers fall back
|
|
209
|
+
* to `rowCount` alone.
|
|
210
|
+
*
|
|
203
211
|
* Returns `NONE` (→ null) when the row is not readable or does not exist yet;
|
|
204
212
|
* callers must treat that as "unknown", not as zero.
|
|
205
213
|
*/
|
|
206
214
|
export function buildQueryRowCountSelect(): string {
|
|
207
|
-
return 'SELECT VALUE rowCount FROM ONLY $in';
|
|
215
|
+
return 'SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in';
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/**
|
|
@@ -240,7 +248,7 @@ export function buildListRefBatchSelect(table: string): string {
|
|
|
240
248
|
* without an id and must be treated as "unknown".
|
|
241
249
|
*/
|
|
242
250
|
export function buildQueryRowCountBatchSelect(): string {
|
|
243
|
-
return 'SELECT VALUE { id: id, rowCount: rowCount } FROM $ins';
|
|
251
|
+
return 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins';
|
|
244
252
|
}
|
|
245
253
|
|
|
246
254
|
/** Known edges one poll round trip may carry before the cycle is split. */
|