@spooky-sync/core 0.0.1-canary.205 → 0.0.1-canary.207
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 +78 -6
- package/dist/index.js +151 -44
- package/dist/types.d.ts +8 -0
- package/package.json +3 -3
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +26 -4
- package/src/modules/data/data.membership.test.ts +62 -1
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.rebind.test.ts +23 -0
- package/src/modules/data/index.ts +82 -31
- package/src/modules/devtools/index.ts +6 -0
- package/src/modules/sync/sync.live-removal.test.ts +40 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +71 -6
- package/src/services/tabs/coordinator.test.ts +118 -2
- package/src/services/tabs/coordinator.ts +25 -0
- package/src/services/tabs/protocol.ts +17 -1
- package/src/sp00ky.ts +9 -11
- package/src/types.ts +8 -0
|
@@ -33,8 +33,11 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
33
33
|
private versionLookups: Record<string, number> = {};
|
|
34
34
|
/** Shared-tabs leader: fan every committed ingest out to follower circuits.
|
|
35
35
|
* Fired AFTER the local tx (the rows are already in the shared store, so a
|
|
36
|
-
* follower only needs the circuit feed).
|
|
36
|
+
* follower only needs the circuit feed). A follower relays its own
|
|
37
|
+
* mutations to the leader the same way, see {@link setIngestRelay}. */
|
|
37
38
|
private ingestRelay: ((tuples: CacheIngestTuple[]) => void) | null = null;
|
|
39
|
+
/** See {@link setIngestRelay}. */
|
|
40
|
+
private relayLocalWritesOnly = false;
|
|
38
41
|
|
|
39
42
|
constructor(
|
|
40
43
|
private local: LocalStore,
|
|
@@ -64,8 +67,21 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
64
67
|
this.streamUpdateCallback(update);
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Fan every committed ingest out to the other tabs. The leader relays
|
|
72
|
+
* everything (its sync fetches are the only copy the followers get). A
|
|
73
|
+
* follower relays with `localWritesOnly`: just the mutation path, which is
|
|
74
|
+
* the only thing it knows that the leader does not. Its sync-fetched
|
|
75
|
+
* batches are the leader's data coming back and must not be re-broadcast,
|
|
76
|
+
* or every follower registration would fan its whole working set to
|
|
77
|
+
* every tab.
|
|
78
|
+
*/
|
|
79
|
+
setIngestRelay(
|
|
80
|
+
cb: ((tuples: CacheIngestTuple[]) => void) | null,
|
|
81
|
+
opts: { localWritesOnly?: boolean } = {}
|
|
82
|
+
): void {
|
|
68
83
|
this.ingestRelay = cb;
|
|
84
|
+
this.relayLocalWritesOnly = opts.localWritesOnly === true;
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
/**
|
|
@@ -197,7 +213,11 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
197
213
|
// failed mid-batch is neither "known" here nor fanned out to followers.
|
|
198
214
|
const ingested = this.streamProcessor.ingestMany(bulk);
|
|
199
215
|
for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
|
|
200
|
-
|
|
216
|
+
// `skipDbInsert` is exactly the mutation path (the tx already wrote the
|
|
217
|
+
// row); sync-fetched batches pass false.
|
|
218
|
+
if (ingested.length > 0 && (!this.relayLocalWritesOnly || skipDbInsert)) {
|
|
219
|
+
this.ingestRelay?.(ingested as CacheIngestTuple[]);
|
|
220
|
+
}
|
|
201
221
|
|
|
202
222
|
this.logger.debug(
|
|
203
223
|
{ count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
|
|
@@ -239,7 +259,9 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
239
259
|
// 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
|
|
240
260
|
delete this.versionLookups[id];
|
|
241
261
|
this.streamProcessor.ingestMany([{ table, op: 'DELETE', id, record: recordData }]);
|
|
242
|
-
this.
|
|
262
|
+
if (!this.relayLocalWritesOnly || skipDbDelete) {
|
|
263
|
+
this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
|
|
264
|
+
}
|
|
243
265
|
|
|
244
266
|
this.logger.debug(
|
|
245
267
|
{ table, id, Category: 'sp00ky-client::CacheModule::delete' },
|
|
@@ -77,7 +77,7 @@ function makeLocal(pendingRows: Array<{ recordId: RecordId; mutationType: string
|
|
|
77
77
|
const bodies = new Map(
|
|
78
78
|
['a', 'b', 'c'].map((k) => [`thread:${k}`, { id: new RecordId('thread', k), title: k }])
|
|
79
79
|
);
|
|
80
|
-
const windowRows = new Map<string, { ids: RecordVersionArray }>();
|
|
80
|
+
const windowRows = new Map<string, { ids: RecordVersionArray; confirmed?: boolean }>();
|
|
81
81
|
const local: any = {
|
|
82
82
|
epoch: 1,
|
|
83
83
|
bodies,
|
|
@@ -424,6 +424,67 @@ describe('membership-authoritative rendering', () => {
|
|
|
424
424
|
expect(state.config.remoteArray).toEqual([]);
|
|
425
425
|
});
|
|
426
426
|
|
|
427
|
+
it('marks a non-empty set and a server-confirmed empty set as confirmed', async () => {
|
|
428
|
+
const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
429
|
+
|
|
430
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
431
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ confirmed: true });
|
|
432
|
+
|
|
433
|
+
// The server reports zero rows: a real answer, durable.
|
|
434
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
|
|
435
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
436
|
+
expect.objectContaining({ ids: [], confirmed: true })
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('marks an empty set that follows a seen set as confirmed', async () => {
|
|
441
|
+
// Everything the query matched was deleted while this session watched: the
|
|
442
|
+
// transition is genuine, so the next boot must not resurrect the rows.
|
|
443
|
+
const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
444
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
445
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
446
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
447
|
+
expect.objectContaining({ ids: [], confirmed: true })
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('leaves the retry-budget empty unconfirmed', async () => {
|
|
452
|
+
// Two unreadable-row-count empties are believed for this session, but they
|
|
453
|
+
// are a guess: the durable row must not turn that guess into a permanent
|
|
454
|
+
// empty list on the next boot.
|
|
455
|
+
const { dm, local, hash } = setup({
|
|
456
|
+
membershipKey: 'stable-key',
|
|
457
|
+
remoteArray: [['thread:a', 1]],
|
|
458
|
+
membershipKnown: true,
|
|
459
|
+
});
|
|
460
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
461
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
462
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
463
|
+
expect.objectContaining({ ids: [], confirmed: false })
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('seeds a known-empty membership from a confirmed empty durable row', async () => {
|
|
468
|
+
// The reload after a server-confirmed empty: the list must stay empty
|
|
469
|
+
// instead of re-admitting every cached body until the next poll blanks it.
|
|
470
|
+
const { dm, local } = setup({ membershipKey: 'stable-key' });
|
|
471
|
+
local.windowRows.set('stable-key', { ids: [], confirmed: true });
|
|
472
|
+
|
|
473
|
+
const fresh = await (dm as any).createNewQuery({
|
|
474
|
+
recordId: new RecordId('_00_query', 'h-confirmed-empty'),
|
|
475
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
476
|
+
params: {},
|
|
477
|
+
ttl: '10m',
|
|
478
|
+
tableName: 'thread',
|
|
479
|
+
plan,
|
|
480
|
+
membershipKey: 'stable-key',
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(fresh.config.membershipKnown).toBe(true);
|
|
484
|
+
expect(fresh.config.remoteArray).toEqual([]);
|
|
485
|
+
expect(fresh.records).toEqual([]);
|
|
486
|
+
});
|
|
487
|
+
|
|
427
488
|
it('does not seed membership from an empty durable row', async () => {
|
|
428
489
|
// Self-heals devices poisoned before the guard existed: an empty durable
|
|
429
490
|
// row is indistinguishable from "never had membership", so it must fall
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
|
|
5
|
+
// A DELETE that landed in the local store (this tab's own, or one relayed
|
|
6
|
+
// from another tab) needs a forced re-materialize of the table's queries: the
|
|
7
|
+
// SSP may not emit a view update for it.
|
|
8
|
+
|
|
9
|
+
function makeLogger(): any {
|
|
10
|
+
const logger: any = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {} };
|
|
11
|
+
logger.child = () => logger;
|
|
12
|
+
return logger;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const schema = { tables: [{ name: 'comment', columns: {} }, { name: 'thread', columns: {} }] } as any;
|
|
16
|
+
|
|
17
|
+
function state(hash: string, tableName: string): any {
|
|
18
|
+
return {
|
|
19
|
+
config: { id: new RecordId('_00_query', hash), tableName, localArray: [], remoteArray: [] },
|
|
20
|
+
records: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('DataModule.notifyTableQueries', () => {
|
|
25
|
+
it('re-materializes only the queries on that table, isolating failures', async () => {
|
|
26
|
+
const local: any = { epoch: 1, query: vi.fn(async () => [[]]) };
|
|
27
|
+
const dm = new DataModule({ saveBatch: async () => {} } as any, local, schema, makeLogger(), 100);
|
|
28
|
+
(dm as any).activeQueries.set('c1', state('c1', 'comment'));
|
|
29
|
+
(dm as any).activeQueries.set('c2', state('c2', 'comment'));
|
|
30
|
+
(dm as any).activeQueries.set('t1', state('t1', 'thread'));
|
|
31
|
+
const notified: string[] = [];
|
|
32
|
+
dm.notifyQuerySynced = vi.fn(async (hash: string) => {
|
|
33
|
+
notified.push(hash);
|
|
34
|
+
if (hash === 'c1') throw new Error('boom');
|
|
35
|
+
}) as any;
|
|
36
|
+
|
|
37
|
+
await dm.notifyTableQueries('comment');
|
|
38
|
+
|
|
39
|
+
expect(notified.sort()).toEqual(['c1', 'c2']);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -122,6 +122,29 @@ describe('DataModule.rebindAfterBucketSwitch', () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
describe('DataModule.rebindAfterBucketSwitch durable seed', () => {
|
|
126
|
+
it('seeds a confirmed-empty membership from the new bucket, and ignores an unconfirmed one', async () => {
|
|
127
|
+
const harness = makeHarness();
|
|
128
|
+
const { dm, local } = harness as any;
|
|
129
|
+
const state = makeQueryState('h1', [{ id: 'user:a', name: 'Previous User Row' }]);
|
|
130
|
+
state.config.membershipKey = 'stable-key';
|
|
131
|
+
(dm as any).activeQueries.set('h1', state);
|
|
132
|
+
local.getById = vi.fn(async () => ({ ids: [], confirmed: true }));
|
|
133
|
+
|
|
134
|
+
await dm.rebindAfterBucketSwitch();
|
|
135
|
+
let qs = (dm as any).activeQueries.get('h1') as QueryState;
|
|
136
|
+
expect(qs.config.membershipKnown).toBe(true);
|
|
137
|
+
expect(qs.config.remoteArray).toEqual([]);
|
|
138
|
+
if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
|
|
139
|
+
|
|
140
|
+
local.getById = vi.fn(async () => ({ ids: [] }));
|
|
141
|
+
await dm.rebindAfterBucketSwitch();
|
|
142
|
+
qs = (dm as any).activeQueries.get('h1') as QueryState;
|
|
143
|
+
expect(qs.config.membershipKnown).toBe(false);
|
|
144
|
+
if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
125
148
|
describe('stale-epoch stream updates', () => {
|
|
126
149
|
it('drops an update whose chain started before a bucket switch', async () => {
|
|
127
150
|
const { dm, local } = makeHarness();
|
|
@@ -82,6 +82,13 @@ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
|
|
|
82
82
|
* Merges the functionality of QueryManager and MutationManager.
|
|
83
83
|
* Uses CacheModule for all storage operations.
|
|
84
84
|
*/
|
|
85
|
+
/** A `_00_window` row as read back: the id-set and whether the server vouched
|
|
86
|
+
* for it (which is what allows an empty set to count as known membership). */
|
|
87
|
+
export interface DurableMembership {
|
|
88
|
+
ids: RecordVersionArray;
|
|
89
|
+
confirmed: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
export class DataModule<S extends SchemaStructure> {
|
|
86
93
|
/** Tab identity baked into mutation ids (shared-tabs rollback routing);
|
|
87
94
|
* undefined in solo mode, where mutation-id falls back to a session id. */
|
|
@@ -1161,32 +1168,54 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1161
1168
|
//
|
|
1162
1169
|
// `_00_window` fixes that: same data, keyed by a session-independent hash, in
|
|
1163
1170
|
// a table nothing wipes. Mirrors the durable `_00_preload` marker above.
|
|
1171
|
+
//
|
|
1172
|
+
// Row shape: `{ ids, confirmed, updatedAt }`. `confirmed` is the one bit that
|
|
1173
|
+
// lets an EMPTY row be trusted on the next boot (see `getWindowMembership`).
|
|
1164
1174
|
|
|
1165
1175
|
/**
|
|
1166
1176
|
* Read the durable membership row, or `null` if this query has never had
|
|
1167
1177
|
* authoritative membership on this device. Any read error is treated as
|
|
1168
1178
|
* "unknown" so a broken row degrades to the predicate scan rather than
|
|
1169
1179
|
* rendering an empty list.
|
|
1180
|
+
*
|
|
1181
|
+
* `confirmed` is true only for rows written after the server itself vouched
|
|
1182
|
+
* for the set (a non-empty id-set, or an empty one it reported a row count of
|
|
1183
|
+
* zero for, or an empty one that followed a non-empty one in the same
|
|
1184
|
+
* session). Rows written before the marker existed, including the `[]` rows a
|
|
1185
|
+
* pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
|
|
1170
1186
|
*/
|
|
1171
|
-
async getWindowMembership(key: string): Promise<
|
|
1187
|
+
async getWindowMembership(key: string): Promise<DurableMembership | null> {
|
|
1172
1188
|
try {
|
|
1173
1189
|
const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
|
|
1174
1190
|
if (!row || typeof row !== 'object') return null;
|
|
1175
1191
|
const ids = (row as any).ids;
|
|
1176
|
-
|
|
1192
|
+
if (!Array.isArray(ids)) return null;
|
|
1193
|
+
return { ids: ids as RecordVersionArray, confirmed: (row as any).confirmed === true };
|
|
1177
1194
|
} catch {
|
|
1178
1195
|
return null;
|
|
1179
1196
|
}
|
|
1180
1197
|
}
|
|
1181
1198
|
|
|
1182
|
-
/**
|
|
1183
|
-
*
|
|
1184
|
-
|
|
1199
|
+
/**
|
|
1200
|
+
* Persist the durable membership row. Best-effort: callers must not fail a
|
|
1201
|
+
* sync round because the mirror write failed.
|
|
1202
|
+
*
|
|
1203
|
+
* `confirmed` says whether a cold start may trust this row even when it is
|
|
1204
|
+
* empty. A confirmed empty is a real answer ("the server says this query has
|
|
1205
|
+
* no rows") and stays empty across a reload; an unconfirmed empty is the
|
|
1206
|
+
* retry budget's guess and falls back to the predicate scan on the next boot,
|
|
1207
|
+
* exactly as every empty row did before the marker existed.
|
|
1208
|
+
*/
|
|
1209
|
+
async writeWindowMembership(
|
|
1210
|
+
key: string,
|
|
1211
|
+
ids: RecordVersionArray,
|
|
1212
|
+
confirmed: boolean
|
|
1213
|
+
): Promise<void> {
|
|
1185
1214
|
try {
|
|
1186
1215
|
await this.local.upsert(
|
|
1187
1216
|
'_00_window',
|
|
1188
1217
|
new RecordId('_00_window', key),
|
|
1189
|
-
{ ids, updatedAt: Date.now() },
|
|
1218
|
+
{ ids, confirmed, updatedAt: Date.now() },
|
|
1190
1219
|
'replace'
|
|
1191
1220
|
);
|
|
1192
1221
|
} catch (err) {
|
|
@@ -1389,9 +1418,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1389
1418
|
// registration, well inside the flush window for a real collection, so
|
|
1390
1419
|
// "believe it the second time" blanked exactly the lists this guard exists
|
|
1391
1420
|
// to protect — reported as rows vanishing ~2s after a page load.
|
|
1421
|
+
// Whether this write may be trusted by the NEXT session. Non-empty sets
|
|
1422
|
+
// always; an empty set only when the server stood behind it (a zero row
|
|
1423
|
+
// count, or a real set was seen this session and it is now gone). The
|
|
1424
|
+
// retry-budget path below accepts an empty without that backing and must
|
|
1425
|
+
// stay non-durable, or the poisoned-device self-heal is lost.
|
|
1426
|
+
let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
|
|
1392
1427
|
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
1393
1428
|
const serverRowCount = opts?.serverRowCount;
|
|
1394
1429
|
const knownEmpty = serverRowCount === 0;
|
|
1430
|
+
confirmed = knownEmpty;
|
|
1395
1431
|
if (!knownEmpty) {
|
|
1396
1432
|
// Unknown row count still gets a bounded escape hatch, so a server that
|
|
1397
1433
|
// cannot report one never strands a device on a durable seed forever.
|
|
@@ -1429,7 +1465,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1429
1465
|
queryState.config.emptyReads = 0;
|
|
1430
1466
|
}
|
|
1431
1467
|
if (queryState.config.membershipKey) {
|
|
1432
|
-
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
1468
|
+
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
|
|
1433
1469
|
}
|
|
1434
1470
|
try {
|
|
1435
1471
|
await this.local.query(
|
|
@@ -1499,10 +1535,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1499
1535
|
config.emptyReads = 0;
|
|
1500
1536
|
if (config.membershipKey) {
|
|
1501
1537
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
if (durable
|
|
1505
|
-
config.remoteArray = durable;
|
|
1538
|
+
// Same rule as the cold-start read: a non-empty row, or an empty one
|
|
1539
|
+
// the server confirmed, is membership; an unmarked empty row is not.
|
|
1540
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
1541
|
+
config.remoteArray = durable.ids;
|
|
1506
1542
|
config.membershipKnown = true;
|
|
1507
1543
|
}
|
|
1508
1544
|
}
|
|
@@ -1893,20 +1929,8 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1893
1929
|
}
|
|
1894
1930
|
|
|
1895
1931
|
// DBSP may not emit view updates for DELETE ops — manually notify all queries
|
|
1896
|
-
// that reference this table.
|
|
1897
|
-
|
|
1898
|
-
for (const [queryHash, queryState] of this.activeQueries) {
|
|
1899
|
-
if (queryState.config.tableName === tableName) {
|
|
1900
|
-
try {
|
|
1901
|
-
await this.notifyQuerySynced(queryHash);
|
|
1902
|
-
} catch (err) {
|
|
1903
|
-
this.logger.error(
|
|
1904
|
-
{ err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
|
|
1905
|
-
'notifyQuerySynced failed after delete'
|
|
1906
|
-
);
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1932
|
+
// that reference this table.
|
|
1933
|
+
await this.notifyTableQueries(tableName);
|
|
1910
1934
|
|
|
1911
1935
|
// Emit mutation event
|
|
1912
1936
|
const mutationEvent: DeleteEvent = {
|
|
@@ -1998,6 +2022,29 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1998
2022
|
}
|
|
1999
2023
|
}
|
|
2000
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* Force a re-materialize + notify of every active query on `tableName`.
|
|
2027
|
+
* Used after a DELETE landed in the local store (this tab's own, or one
|
|
2028
|
+
* relayed from another tab): the SSP may not emit a view update for a
|
|
2029
|
+
* DELETE ingest, and the re-materialize reads the store, which already
|
|
2030
|
+
* excludes the row. Each query is isolated so one failing re-materialize
|
|
2031
|
+
* can't stop the others.
|
|
2032
|
+
*/
|
|
2033
|
+
async notifyTableQueries(tableName: string): Promise<void> {
|
|
2034
|
+
for (const [queryHash, queryState] of this.activeQueries) {
|
|
2035
|
+
if (queryState.config.tableName === tableName) {
|
|
2036
|
+
try {
|
|
2037
|
+
await this.notifyQuerySynced(queryHash);
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
this.logger.error(
|
|
2040
|
+
{ err, queryHash, tableName, Category: 'sp00ky-client::DataModule::notifyTableQueries' },
|
|
2041
|
+
'notifyQuerySynced failed after delete'
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2001
2048
|
/**
|
|
2002
2049
|
* Remove a record from all active query states and notify subscribers
|
|
2003
2050
|
*/
|
|
@@ -2193,12 +2240,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
2193
2240
|
// removed row reappearing after a reload, and it works with no network.
|
|
2194
2241
|
if (membershipKey && !config.remoteArray?.length) {
|
|
2195
2242
|
const durable = await this.getWindowMembership(membershipKey);
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
2200
|
-
|
|
2201
|
-
|
|
2243
|
+
// An empty durable row is trusted only when it carries the `confirmed`
|
|
2244
|
+
// marker, i.e. the server itself reported the query empty. Without it an
|
|
2245
|
+
// empty row cannot be told apart from one written before this device ever
|
|
2246
|
+
// saw a real id-set (or by the pre-`ea56f50e` client that mirrored
|
|
2247
|
+
// unflushed reads), and treating it as known would paint an empty list
|
|
2248
|
+
// with no scan fallback. A confirmed empty is the opposite case: the
|
|
2249
|
+
// server said "no rows", so a reload must stay empty rather than re-admit
|
|
2250
|
+
// every cached body until the next poll blanks it again.
|
|
2251
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
2252
|
+
config.remoteArray = durable.ids;
|
|
2202
2253
|
config.membershipKnown = true;
|
|
2203
2254
|
}
|
|
2204
2255
|
} else if (config.remoteArray?.length) {
|
|
@@ -208,6 +208,12 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
208
208
|
data: q.records,
|
|
209
209
|
localArray: q.config.localArray,
|
|
210
210
|
remoteArray: q.config.remoteArray,
|
|
211
|
+
// Membership state, so "why is this list empty" is answerable from
|
|
212
|
+
// the panel: is the server's set known, has a non-empty one been seen
|
|
213
|
+
// this session, how many empty reads were ignored.
|
|
214
|
+
membershipKnown: q.config.membershipKnown === true,
|
|
215
|
+
remoteSeen: q.config.remoteSeen === true,
|
|
216
|
+
emptyReads: q.config.emptyReads ?? 0,
|
|
211
217
|
// Detailed per-phase processing-time breakdown (SSP sub-phases, local/
|
|
212
218
|
// remote record fetch, frontend reconcile, registration). Flows to both
|
|
213
219
|
// the DevTools panel and the MCP (which returns activeQueries verbatim).
|
|
@@ -110,6 +110,46 @@ describe('LIVE list_ref removal → membership', () => {
|
|
|
110
110
|
]);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
// Every tab that ingested a write optimistically (its own, or one relayed
|
|
114
|
+
// from another tab) already holds the row at the server's version, so the
|
|
115
|
+
// fetch diff is empty. Membership still has to be recorded, or the row lives
|
|
116
|
+
// on the settled-write grace alone until the poll catches it.
|
|
117
|
+
it('adds membership for a row the circuit already holds at the server version', async () => {
|
|
118
|
+
const { queryState, updateQueryRemoteArray, sync, live } = makeSync();
|
|
119
|
+
queryState.config.localArray = [
|
|
120
|
+
['thread:a', 1],
|
|
121
|
+
['thread:b', 1],
|
|
122
|
+
['thread:c', 1],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
await live('CREATE', 'c', 1);
|
|
126
|
+
|
|
127
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
|
|
128
|
+
['thread:a', 1],
|
|
129
|
+
['thread:b', 1],
|
|
130
|
+
['thread:c', 1],
|
|
131
|
+
]);
|
|
132
|
+
// …without a refetch: the diff handed to the sync is empty.
|
|
133
|
+
expect((sync as any).runSyncForQuery).toHaveBeenCalledWith('h1', {
|
|
134
|
+
added: [],
|
|
135
|
+
updated: [],
|
|
136
|
+
removed: [],
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('records a bumped version on UPDATE without rewriting an unchanged list', async () => {
|
|
141
|
+
const { updateQueryRemoteArray, live } = makeSync();
|
|
142
|
+
|
|
143
|
+
await live('UPDATE', 'b', 1);
|
|
144
|
+
expect(updateQueryRemoteArray).not.toHaveBeenCalled();
|
|
145
|
+
|
|
146
|
+
await live('UPDATE', 'b', 2);
|
|
147
|
+
expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
|
|
148
|
+
['thread:a', 1],
|
|
149
|
+
['thread:b', 2],
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
113
153
|
it('leaves membership alone while it is still unknown', async () => {
|
|
114
154
|
// Nothing authoritative has arrived yet, so there is no list to amend —
|
|
115
155
|
// registration will supply the whole thing shortly.
|