@spooky-sync/core 0.0.1-canary.205 → 0.0.1-canary.206
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 +24 -4
- package/dist/index.js +38 -19
- package/dist/types.d.ts +8 -0
- package/package.json +3 -3
- package/src/modules/data/data.membership.test.ts +62 -1
- package/src/modules/data/data.rebind.test.ts +23 -0
- package/src/modules/data/index.ts +57 -17
- package/src/modules/devtools/index.ts +6 -0
- package/src/types.ts +8 -0
package/dist/index.d.ts
CHANGED
|
@@ -606,6 +606,12 @@ declare class CacheModule implements StreamUpdateReceiver {
|
|
|
606
606
|
* Merges the functionality of QueryManager and MutationManager.
|
|
607
607
|
* Uses CacheModule for all storage operations.
|
|
608
608
|
*/
|
|
609
|
+
/** A `_00_window` row as read back: the id-set and whether the server vouched
|
|
610
|
+
* for it (which is what allows an empty set to count as known membership). */
|
|
611
|
+
interface DurableMembership {
|
|
612
|
+
ids: RecordVersionArray;
|
|
613
|
+
confirmed: boolean;
|
|
614
|
+
}
|
|
609
615
|
declare class DataModule<S extends SchemaStructure> {
|
|
610
616
|
private cache;
|
|
611
617
|
private local;
|
|
@@ -901,11 +907,25 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
901
907
|
* authoritative membership on this device. Any read error is treated as
|
|
902
908
|
* "unknown" so a broken row degrades to the predicate scan rather than
|
|
903
909
|
* rendering an empty list.
|
|
910
|
+
*
|
|
911
|
+
* `confirmed` is true only for rows written after the server itself vouched
|
|
912
|
+
* for the set (a non-empty id-set, or an empty one it reported a row count of
|
|
913
|
+
* zero for, or an empty one that followed a non-empty one in the same
|
|
914
|
+
* session). Rows written before the marker existed, including the `[]` rows a
|
|
915
|
+
* pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
|
|
916
|
+
*/
|
|
917
|
+
getWindowMembership(key: string): Promise<DurableMembership | null>;
|
|
918
|
+
/**
|
|
919
|
+
* Persist the durable membership row. Best-effort: callers must not fail a
|
|
920
|
+
* sync round because the mirror write failed.
|
|
921
|
+
*
|
|
922
|
+
* `confirmed` says whether a cold start may trust this row even when it is
|
|
923
|
+
* empty. A confirmed empty is a real answer ("the server says this query has
|
|
924
|
+
* no rows") and stays empty across a reload; an unconfirmed empty is the
|
|
925
|
+
* retry budget's guess and falls back to the predicate scan on the next boot,
|
|
926
|
+
* exactly as every empty row did before the marker existed.
|
|
904
927
|
*/
|
|
905
|
-
|
|
906
|
-
/** Persist the durable membership row. Best-effort: callers must not fail a
|
|
907
|
-
* sync round because the mirror write failed. */
|
|
908
|
-
writeWindowMembership(key: string, ids: RecordVersionArray): Promise<void>;
|
|
928
|
+
writeWindowMembership(key: string, ids: RecordVersionArray, confirmed: boolean): Promise<void>;
|
|
909
929
|
/**
|
|
910
930
|
* Record ids with a mutation still in the outbox, split by direction.
|
|
911
931
|
*
|
package/dist/index.js
CHANGED
|
@@ -3321,12 +3321,6 @@ function phaseStatOf(samples, lastMs) {
|
|
|
3321
3321
|
count: samples.length
|
|
3322
3322
|
};
|
|
3323
3323
|
}
|
|
3324
|
-
/**
|
|
3325
|
-
* DataModule - Unified query and mutation management
|
|
3326
|
-
*
|
|
3327
|
-
* Merges the functionality of QueryManager and MutationManager.
|
|
3328
|
-
* Uses CacheModule for all storage operations.
|
|
3329
|
-
*/
|
|
3330
3324
|
var DataModule = class DataModule {
|
|
3331
3325
|
/** Tab identity baked into mutation ids (shared-tabs rollback routing);
|
|
3332
3326
|
* undefined in solo mode, where mutation-id falls back to a session id. */
|
|
@@ -4030,23 +4024,42 @@ var DataModule = class DataModule {
|
|
|
4030
4024
|
* authoritative membership on this device. Any read error is treated as
|
|
4031
4025
|
* "unknown" so a broken row degrades to the predicate scan rather than
|
|
4032
4026
|
* rendering an empty list.
|
|
4027
|
+
*
|
|
4028
|
+
* `confirmed` is true only for rows written after the server itself vouched
|
|
4029
|
+
* for the set (a non-empty id-set, or an empty one it reported a row count of
|
|
4030
|
+
* zero for, or an empty one that followed a non-empty one in the same
|
|
4031
|
+
* session). Rows written before the marker existed, including the `[]` rows a
|
|
4032
|
+
* pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
|
|
4033
4033
|
*/
|
|
4034
4034
|
async getWindowMembership(key) {
|
|
4035
4035
|
try {
|
|
4036
4036
|
const row = await this.local.getById("_00_window", new RecordId("_00_window", key));
|
|
4037
4037
|
if (!row || typeof row !== "object") return null;
|
|
4038
4038
|
const ids = row.ids;
|
|
4039
|
-
|
|
4039
|
+
if (!Array.isArray(ids)) return null;
|
|
4040
|
+
return {
|
|
4041
|
+
ids,
|
|
4042
|
+
confirmed: row.confirmed === true
|
|
4043
|
+
};
|
|
4040
4044
|
} catch {
|
|
4041
4045
|
return null;
|
|
4042
4046
|
}
|
|
4043
4047
|
}
|
|
4044
|
-
/**
|
|
4045
|
-
*
|
|
4046
|
-
|
|
4048
|
+
/**
|
|
4049
|
+
* Persist the durable membership row. Best-effort: callers must not fail a
|
|
4050
|
+
* sync round because the mirror write failed.
|
|
4051
|
+
*
|
|
4052
|
+
* `confirmed` says whether a cold start may trust this row even when it is
|
|
4053
|
+
* empty. A confirmed empty is a real answer ("the server says this query has
|
|
4054
|
+
* no rows") and stays empty across a reload; an unconfirmed empty is the
|
|
4055
|
+
* retry budget's guess and falls back to the predicate scan on the next boot,
|
|
4056
|
+
* exactly as every empty row did before the marker existed.
|
|
4057
|
+
*/
|
|
4058
|
+
async writeWindowMembership(key, ids, confirmed) {
|
|
4047
4059
|
try {
|
|
4048
4060
|
await this.local.upsert("_00_window", new RecordId("_00_window", key), {
|
|
4049
4061
|
ids,
|
|
4062
|
+
confirmed,
|
|
4050
4063
|
updatedAt: Date.now()
|
|
4051
4064
|
}, "replace");
|
|
4052
4065
|
} catch (err) {
|
|
@@ -4205,9 +4218,12 @@ var DataModule = class DataModule {
|
|
|
4205
4218
|
}, "Query to update remote array not found");
|
|
4206
4219
|
return;
|
|
4207
4220
|
}
|
|
4221
|
+
let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
|
|
4208
4222
|
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
4209
4223
|
const serverRowCount = opts?.serverRowCount;
|
|
4210
|
-
|
|
4224
|
+
const knownEmpty = serverRowCount === 0;
|
|
4225
|
+
confirmed = knownEmpty;
|
|
4226
|
+
if (!knownEmpty) {
|
|
4211
4227
|
const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
|
|
4212
4228
|
queryState.config.emptyReads = emptyReads;
|
|
4213
4229
|
if (!(serverRowCount === null || serverRowCount === void 0 ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS : false)) {
|
|
@@ -4228,7 +4244,7 @@ var DataModule = class DataModule {
|
|
|
4228
4244
|
queryState.config.remoteSeen = true;
|
|
4229
4245
|
queryState.config.emptyReads = 0;
|
|
4230
4246
|
}
|
|
4231
|
-
if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
4247
|
+
if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
|
|
4232
4248
|
try {
|
|
4233
4249
|
await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
|
|
4234
4250
|
id: queryState.config.id,
|
|
@@ -4281,8 +4297,8 @@ var DataModule = class DataModule {
|
|
|
4281
4297
|
config.emptyReads = 0;
|
|
4282
4298
|
if (config.membershipKey) {
|
|
4283
4299
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
4284
|
-
if (durable
|
|
4285
|
-
config.remoteArray = durable;
|
|
4300
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
4301
|
+
config.remoteArray = durable.ids;
|
|
4286
4302
|
config.membershipKnown = true;
|
|
4287
4303
|
}
|
|
4288
4304
|
}
|
|
@@ -4712,8 +4728,8 @@ var DataModule = class DataModule {
|
|
|
4712
4728
|
};
|
|
4713
4729
|
if (membershipKey && !config.remoteArray?.length) {
|
|
4714
4730
|
const durable = await this.getWindowMembership(membershipKey);
|
|
4715
|
-
if (durable
|
|
4716
|
-
config.remoteArray = durable;
|
|
4731
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
4732
|
+
config.remoteArray = durable.ids;
|
|
4717
4733
|
config.membershipKnown = true;
|
|
4718
4734
|
}
|
|
4719
4735
|
} else if (config.remoteArray?.length) config.membershipKnown = true;
|
|
@@ -7337,8 +7353,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7337
7353
|
|
|
7338
7354
|
//#endregion
|
|
7339
7355
|
//#region src/modules/devtools/index.ts
|
|
7340
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7341
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7356
|
+
const CORE_VERSION = "0.0.1-canary.206";
|
|
7357
|
+
const WASM_VERSION = "0.0.1-canary.206";
|
|
7342
7358
|
const SURREAL_VERSION = "3.0.3";
|
|
7343
7359
|
var DevToolsService = class DevToolsService {
|
|
7344
7360
|
eventsHistory = [];
|
|
@@ -7439,6 +7455,9 @@ var DevToolsService = class DevToolsService {
|
|
|
7439
7455
|
data: q.records,
|
|
7440
7456
|
localArray: q.config.localArray,
|
|
7441
7457
|
remoteArray: q.config.remoteArray,
|
|
7458
|
+
membershipKnown: q.config.membershipKnown === true,
|
|
7459
|
+
remoteSeen: q.config.remoteSeen === true,
|
|
7460
|
+
emptyReads: q.config.emptyReads ?? 0,
|
|
7442
7461
|
timings: this.dataManager.phaseTimings(q)
|
|
7443
7462
|
});
|
|
7444
7463
|
});
|
|
@@ -12284,7 +12303,7 @@ var Sp00kyClient = class {
|
|
|
12284
12303
|
return new TabsCoordinator({
|
|
12285
12304
|
tabId,
|
|
12286
12305
|
fingerprint: computeTabsFingerprint({
|
|
12287
|
-
coreVersion: "0.0.1-canary.
|
|
12306
|
+
coreVersion: "0.0.1-canary.206",
|
|
12288
12307
|
schemaHash: hash53(this.config.schemaSurql),
|
|
12289
12308
|
endpoint: this.config.database.endpoint ?? "",
|
|
12290
12309
|
namespace: this.config.database.namespace,
|
package/dist/types.d.ts
CHANGED
|
@@ -874,6 +874,11 @@ interface QueryConfig {
|
|
|
874
874
|
* "never established" has to fall back to a predicate scan of the local store
|
|
875
875
|
* so a query first run on this device still paints offline. A
|
|
876
876
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
877
|
+
*
|
|
878
|
+
* On a cold start it is seeded from the durable `_00_window` row when that
|
|
879
|
+
* row is non-empty, or empty but `confirmed` (the server reported zero rows
|
|
880
|
+
* for the query). An unconfirmed empty row is ignored, so a device poisoned
|
|
881
|
+
* by an old client that mirrored unflushed reads still self-heals.
|
|
877
882
|
*/
|
|
878
883
|
membershipKnown?: boolean;
|
|
879
884
|
/**
|
|
@@ -889,6 +894,9 @@ interface QueryConfig {
|
|
|
889
894
|
* genuine transition and must be honoured, or removed rows resurrect.
|
|
890
895
|
*
|
|
891
896
|
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
897
|
+
* What does persist is the `confirmed` marker on the `_00_window` row, which
|
|
898
|
+
* an empty set earns when it arrives with a server row count of zero or after
|
|
899
|
+
* a non-empty set in the same session.
|
|
892
900
|
*/
|
|
893
901
|
remoteSeen?: boolean;
|
|
894
902
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.206",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.206",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.206",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"blurhash": "^2.0.5",
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -2193,12 +2229,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
2193
2229
|
// removed row reappearing after a reload, and it works with no network.
|
|
2194
2230
|
if (membershipKey && !config.remoteArray?.length) {
|
|
2195
2231
|
const durable = await this.getWindowMembership(membershipKey);
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
2200
|
-
|
|
2201
|
-
|
|
2232
|
+
// An empty durable row is trusted only when it carries the `confirmed`
|
|
2233
|
+
// marker, i.e. the server itself reported the query empty. Without it an
|
|
2234
|
+
// empty row cannot be told apart from one written before this device ever
|
|
2235
|
+
// saw a real id-set (or by the pre-`ea56f50e` client that mirrored
|
|
2236
|
+
// unflushed reads), and treating it as known would paint an empty list
|
|
2237
|
+
// with no scan fallback. A confirmed empty is the opposite case: the
|
|
2238
|
+
// server said "no rows", so a reload must stay empty rather than re-admit
|
|
2239
|
+
// every cached body until the next poll blanks it again.
|
|
2240
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
2241
|
+
config.remoteArray = durable.ids;
|
|
2202
2242
|
config.membershipKnown = true;
|
|
2203
2243
|
}
|
|
2204
2244
|
} 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).
|
package/src/types.ts
CHANGED
|
@@ -513,6 +513,11 @@ export interface QueryConfig {
|
|
|
513
513
|
* "never established" has to fall back to a predicate scan of the local store
|
|
514
514
|
* so a query first run on this device still paints offline. A
|
|
515
515
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
516
|
+
*
|
|
517
|
+
* On a cold start it is seeded from the durable `_00_window` row when that
|
|
518
|
+
* row is non-empty, or empty but `confirmed` (the server reported zero rows
|
|
519
|
+
* for the query). An unconfirmed empty row is ignored, so a device poisoned
|
|
520
|
+
* by an old client that mirrored unflushed reads still self-heals.
|
|
516
521
|
*/
|
|
517
522
|
membershipKnown?: boolean;
|
|
518
523
|
/**
|
|
@@ -528,6 +533,9 @@ export interface QueryConfig {
|
|
|
528
533
|
* genuine transition and must be honoured, or removed rows resurrect.
|
|
529
534
|
*
|
|
530
535
|
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
536
|
+
* What does persist is the `confirmed` marker on the `_00_window` row, which
|
|
537
|
+
* an empty set earns when it arrives with a server row count of zero or after
|
|
538
|
+
* a non-empty set in the same session.
|
|
531
539
|
*/
|
|
532
540
|
remoteSeen?: boolean;
|
|
533
541
|
/**
|