@spooky-sync/core 0.0.1-canary.171 → 0.0.1-canary.172
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 +2 -2
- package/dist/index.js +35 -10
- package/dist/types.d.ts +23 -0
- package/package.json +3 -3
- package/src/modules/data/data.membership.test.ts +67 -0
- package/src/modules/data/index.ts +57 -2
- package/src/modules/devtools/flags.ts +2 -2
- package/src/modules/devtools/index.ts +3 -3
- package/src/sp00ky.ts +3 -3
- package/src/types.ts +23 -0
package/dist/index.d.ts
CHANGED
|
@@ -2157,10 +2157,10 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2157
2157
|
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
2158
2158
|
* untouched, so clearing restores whatever the server says. Persisted to
|
|
2159
2159
|
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
2160
|
-
* DevTools
|
|
2160
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
2161
2161
|
*
|
|
2162
2162
|
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
2163
|
-
* and the DevTools
|
|
2163
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
2164
2164
|
*/
|
|
2165
2165
|
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void;
|
|
2166
2166
|
/** Drop every local feature flag override set via `setFeatureOverride`. */
|
package/dist/index.js
CHANGED
|
@@ -3069,6 +3069,13 @@ function mutationOwnerTabId(mutationId) {
|
|
|
3069
3069
|
|
|
3070
3070
|
//#endregion
|
|
3071
3071
|
//#region src/modules/data/index.ts
|
|
3072
|
+
/**
|
|
3073
|
+
* How many consecutive empty `_00_list_ref` reads it takes to believe a query
|
|
3074
|
+
* really is empty, before any non-empty set has been seen this session. One
|
|
3075
|
+
* read is the registration race (the SSP flushes a view's initial edges
|
|
3076
|
+
* asynchronously); the second comes from the poll a cycle later.
|
|
3077
|
+
*/
|
|
3078
|
+
const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
|
|
3072
3079
|
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
3073
3080
|
function pushSample(samples, ms) {
|
|
3074
3081
|
samples.push(ms);
|
|
@@ -3899,9 +3906,25 @@ var DataModule = class {
|
|
|
3899
3906
|
}, "Query to update remote array not found");
|
|
3900
3907
|
return;
|
|
3901
3908
|
}
|
|
3909
|
+
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
3910
|
+
const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
|
|
3911
|
+
queryState.config.emptyReads = emptyReads;
|
|
3912
|
+
if (emptyReads < EMPTY_MEMBERSHIP_CONFIRMATIONS) {
|
|
3913
|
+
this.logger.debug({
|
|
3914
|
+
hash,
|
|
3915
|
+
emptyReads,
|
|
3916
|
+
Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
|
|
3917
|
+
}, "Ignoring unconfirmed empty membership (server may not have flushed list_ref yet)");
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3902
3921
|
const epoch = this.local.epoch;
|
|
3903
3922
|
queryState.config.remoteArray = remoteArray;
|
|
3904
3923
|
queryState.config.membershipKnown = true;
|
|
3924
|
+
if (remoteArray.length > 0) {
|
|
3925
|
+
queryState.config.remoteSeen = true;
|
|
3926
|
+
queryState.config.emptyReads = 0;
|
|
3927
|
+
}
|
|
3905
3928
|
if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
3906
3929
|
try {
|
|
3907
3930
|
await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
|
|
@@ -3951,9 +3974,11 @@ var DataModule = class {
|
|
|
3951
3974
|
config.remoteArray = [];
|
|
3952
3975
|
config.subqueryRemoteArray = void 0;
|
|
3953
3976
|
config.membershipKnown = false;
|
|
3977
|
+
config.remoteSeen = false;
|
|
3978
|
+
config.emptyReads = 0;
|
|
3954
3979
|
if (config.membershipKey) {
|
|
3955
3980
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
3956
|
-
if (durable) {
|
|
3981
|
+
if (durable?.length) {
|
|
3957
3982
|
config.remoteArray = durable;
|
|
3958
3983
|
config.membershipKnown = true;
|
|
3959
3984
|
}
|
|
@@ -4381,7 +4406,7 @@ var DataModule = class {
|
|
|
4381
4406
|
};
|
|
4382
4407
|
if (membershipKey && !config.remoteArray?.length) {
|
|
4383
4408
|
const durable = await this.getWindowMembership(membershipKey);
|
|
4384
|
-
if (durable) {
|
|
4409
|
+
if (durable?.length) {
|
|
4385
4410
|
config.remoteArray = durable;
|
|
4386
4411
|
config.membershipKnown = true;
|
|
4387
4412
|
}
|
|
@@ -6577,7 +6602,7 @@ var FlagsAdminService = class {
|
|
|
6577
6602
|
this.deps = deps;
|
|
6578
6603
|
}
|
|
6579
6604
|
/**
|
|
6580
|
-
* Everything the
|
|
6605
|
+
* Everything the Access tab renders, in one round trip per source.
|
|
6581
6606
|
*
|
|
6582
6607
|
* Each section fails independently: a remote read that throws downgrades to
|
|
6583
6608
|
* `isAdmin: false` plus an `error`, while local assignments and overrides
|
|
@@ -6609,7 +6634,7 @@ var FlagsAdminService = class {
|
|
|
6609
6634
|
try {
|
|
6610
6635
|
snapshot.isAdmin = statementRows(await this.deps.remote.query("SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1")).length > 0;
|
|
6611
6636
|
} catch (err) {
|
|
6612
|
-
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the
|
|
6637
|
+
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Access tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
|
|
6613
6638
|
return snapshot;
|
|
6614
6639
|
}
|
|
6615
6640
|
if (!snapshot.isAdmin) return snapshot;
|
|
@@ -6729,8 +6754,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
6729
6754
|
|
|
6730
6755
|
//#endregion
|
|
6731
6756
|
//#region src/modules/devtools/index.ts
|
|
6732
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
6733
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
6757
|
+
const CORE_VERSION = "0.0.1-canary.172";
|
|
6758
|
+
const WASM_VERSION = "0.0.1-canary.172";
|
|
6734
6759
|
const SURREAL_VERSION = "3.0.3";
|
|
6735
6760
|
var DevToolsService = class DevToolsService {
|
|
6736
6761
|
eventsHistory = [];
|
|
@@ -7088,7 +7113,7 @@ var DevToolsService = class DevToolsService {
|
|
|
7088
7113
|
return data;
|
|
7089
7114
|
}
|
|
7090
7115
|
/**
|
|
7091
|
-
* Hand the FeatureFlagModule to the
|
|
7116
|
+
* Hand the FeatureFlagModule to the Access tab so it can read and write local
|
|
7092
7117
|
* overrides. Called from `Sp00kyClient` once both are constructed; until then
|
|
7093
7118
|
* the override methods are no-ops that report an empty map.
|
|
7094
7119
|
*/
|
|
@@ -11160,7 +11185,7 @@ var Sp00kyClient = class {
|
|
|
11160
11185
|
return new TabsCoordinator({
|
|
11161
11186
|
tabId,
|
|
11162
11187
|
fingerprint: computeTabsFingerprint({
|
|
11163
|
-
coreVersion: "0.0.1-canary.
|
|
11188
|
+
coreVersion: "0.0.1-canary.172",
|
|
11164
11189
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11165
11190
|
endpoint: this.config.database.endpoint ?? "",
|
|
11166
11191
|
namespace: this.config.database.namespace,
|
|
@@ -11506,10 +11531,10 @@ var Sp00kyClient = class {
|
|
|
11506
11531
|
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
11507
11532
|
* untouched, so clearing restores whatever the server says. Persisted to
|
|
11508
11533
|
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
11509
|
-
* DevTools
|
|
11534
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
11510
11535
|
*
|
|
11511
11536
|
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
11512
|
-
* and the DevTools
|
|
11537
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
11513
11538
|
*/
|
|
11514
11539
|
setFeatureOverride(key, variant, payload) {
|
|
11515
11540
|
this.featureFlags.setLocalOverride(key, variant, payload);
|
package/dist/types.d.ts
CHANGED
|
@@ -815,6 +815,29 @@ interface QueryConfig {
|
|
|
815
815
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
816
816
|
*/
|
|
817
817
|
membershipKnown?: boolean;
|
|
818
|
+
/**
|
|
819
|
+
* Whether a NON-EMPTY id-set has arrived from the server for this query in
|
|
820
|
+
* this session. Gates whether an empty read may be believed.
|
|
821
|
+
*
|
|
822
|
+
* The server publishes `_00_list_ref` asynchronously — the SSP queues a
|
|
823
|
+
* view's initial edges to a coalescing flusher and returns from
|
|
824
|
+
* `fn::query::register` before they land — so an empty read right after
|
|
825
|
+
* registration says nothing about the query being empty. Believing it (and
|
|
826
|
+
* mirroring it to the durable `_00_window` row) blanked lists and kept them
|
|
827
|
+
* blank across reloads. Once a real set has been seen, a later empty one is a
|
|
828
|
+
* genuine transition and must be honoured, or removed rows resurrect.
|
|
829
|
+
*
|
|
830
|
+
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
831
|
+
*/
|
|
832
|
+
remoteSeen?: boolean;
|
|
833
|
+
/**
|
|
834
|
+
* Consecutive empty id-sets read from the server while `remoteSeen` is still
|
|
835
|
+
* false. Bounds how long an unconfirmed empty may be ignored, so a window
|
|
836
|
+
* that genuinely emptied while this device was away is believed on the second
|
|
837
|
+
* read instead of rendering stale rows forever. Reset by any non-empty set.
|
|
838
|
+
* In-memory only.
|
|
839
|
+
*/
|
|
840
|
+
emptyReads?: number;
|
|
818
841
|
/**
|
|
819
842
|
* Key of this query's durable `_00_window` membership row: a hash of
|
|
820
843
|
* `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
|
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.172",
|
|
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.172",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.172",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"fast-json-patch": "^3.1.1",
|
|
@@ -280,6 +280,73 @@ describe('membership-authoritative rendering', () => {
|
|
|
280
280
|
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
it('ignores an empty id-set until a real one has been seen', async () => {
|
|
284
|
+
// The registration race: the SSP queues a view's initial edges to a
|
|
285
|
+
// coalescing flusher and returns from `fn::query::register` before they
|
|
286
|
+
// land, so this read says nothing about the query being empty. Believing
|
|
287
|
+
// it rendered a blank list AND persisted the blankness.
|
|
288
|
+
const { dm, state, local, hash } = setup({
|
|
289
|
+
membershipKey: 'stable-key',
|
|
290
|
+
remoteArray: [['thread:a', 1]],
|
|
291
|
+
membershipKnown: true,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
295
|
+
|
|
296
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
297
|
+
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('believes an empty id-set once a non-empty one has arrived', async () => {
|
|
301
|
+
// The genuine transition — the last row left the window. Honouring it is
|
|
302
|
+
// what stops a removed row resurrecting from the local body cache.
|
|
303
|
+
const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
304
|
+
|
|
305
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
306
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
307
|
+
|
|
308
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
309
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
310
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ ids: [] });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('believes a repeated empty id-set even with nothing seen this session', async () => {
|
|
314
|
+
// A window that really did empty while this device was away: the durable
|
|
315
|
+
// seed must not render forever, so the second read is taken at face value.
|
|
316
|
+
const { dm, state, hash } = setup({
|
|
317
|
+
membershipKey: 'stable-key',
|
|
318
|
+
remoteArray: [['thread:a', 1]],
|
|
319
|
+
membershipKnown: true,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
323
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
324
|
+
|
|
325
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
326
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('does not seed membership from an empty durable row', async () => {
|
|
330
|
+
// Self-heals devices poisoned before the guard existed: an empty durable
|
|
331
|
+
// row is indistinguishable from "never had membership", so it must fall
|
|
332
|
+
// back to the scan rather than paint an empty list.
|
|
333
|
+
const { dm, local } = setup({ membershipKey: 'stable-key' });
|
|
334
|
+
local.windowRows.set('stable-key', { ids: [] });
|
|
335
|
+
|
|
336
|
+
const fresh = await (dm as any).createNewQuery({
|
|
337
|
+
recordId: new RecordId('_00_query', 'h-poisoned'),
|
|
338
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
339
|
+
params: {},
|
|
340
|
+
ttl: '10m',
|
|
341
|
+
tableName: 'thread',
|
|
342
|
+
plan,
|
|
343
|
+
membershipKey: 'stable-key',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(fresh.config.membershipKnown).toBeFalsy();
|
|
347
|
+
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
348
|
+
});
|
|
349
|
+
|
|
283
350
|
it('derives the membership key without the session salt', async () => {
|
|
284
351
|
const { dm } = setup();
|
|
285
352
|
const key = () => (dm as any).calculateMembershipKey({ surql: 'X', params: {} });
|
|
@@ -52,6 +52,14 @@ import {
|
|
|
52
52
|
} from './window-query';
|
|
53
53
|
import { mintMutationId } from './mutation-id';
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* How many consecutive empty `_00_list_ref` reads it takes to believe a query
|
|
57
|
+
* really is empty, before any non-empty set has been seen this session. One
|
|
58
|
+
* read is the registration race (the SSP flushes a view's initial edges
|
|
59
|
+
* asynchronously); the second comes from the poll a cycle later.
|
|
60
|
+
*/
|
|
61
|
+
const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
|
|
62
|
+
|
|
55
63
|
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
56
64
|
function pushSample(samples: number[], ms: number): void {
|
|
57
65
|
samples.push(ms);
|
|
@@ -1198,12 +1206,49 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1198
1206
|
);
|
|
1199
1207
|
return;
|
|
1200
1208
|
}
|
|
1209
|
+
// An empty id-set is only believable once a real one has arrived in this
|
|
1210
|
+
// session. `_00_list_ref` is published asynchronously — the SSP queues a
|
|
1211
|
+
// view's initial edges to a coalescing flusher and returns from
|
|
1212
|
+
// `fn::query::register` before they land — so an empty read right after
|
|
1213
|
+
// registration is routinely just "not flushed yet", for a query that has
|
|
1214
|
+
// rows. Taking it at face value latched `membershipKnown` on an empty set,
|
|
1215
|
+
// which renders nothing (no scan fallback), and mirrored `[]` into the
|
|
1216
|
+
// durable `_00_window` row, which kept the list blank across reloads.
|
|
1217
|
+
//
|
|
1218
|
+
// Ignoring it entirely (rather than storing `[]` with the latch withheld)
|
|
1219
|
+
// is deliberate: on a cold start `remoteArray` is seeded from the durable
|
|
1220
|
+
// row, and overwriting that with `[]` would blank the very rows the seed
|
|
1221
|
+
// exists to paint. The `_00_list_ref` poll re-reads within ~500ms and
|
|
1222
|
+
// delivers the real set.
|
|
1223
|
+
// Bounded, though: a query seeded from the durable row on a cold start has
|
|
1224
|
+
// `remoteSeen === false`, and if its window really did empty while this
|
|
1225
|
+
// device was away, the server will keep answering `[]`. Believe it on the
|
|
1226
|
+
// second such read — one poll cycle (~500ms) past the flush window — so
|
|
1227
|
+
// stale rows can't render forever.
|
|
1228
|
+
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
1229
|
+
const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
|
|
1230
|
+
queryState.config.emptyReads = emptyReads;
|
|
1231
|
+
if (emptyReads < EMPTY_MEMBERSHIP_CONFIRMATIONS) {
|
|
1232
|
+
this.logger.debug(
|
|
1233
|
+
{ hash, emptyReads, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
|
|
1234
|
+
'Ignoring unconfirmed empty membership (server may not have flushed list_ref yet)'
|
|
1235
|
+
);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1201
1240
|
const epoch = this.local.epoch;
|
|
1202
1241
|
queryState.config.remoteArray = remoteArray;
|
|
1203
1242
|
// The single point where authoritative membership arrives (registration and
|
|
1204
1243
|
// the `_00_list_ref` poll both land here), so it is where "we now know the
|
|
1205
1244
|
// membership" is latched and where the durable mirror is written.
|
|
1206
1245
|
queryState.config.membershipKnown = true;
|
|
1246
|
+
if (remoteArray.length > 0) {
|
|
1247
|
+
// Earns the right to believe a later empty set — that transition is a
|
|
1248
|
+
// genuine removal and must be honoured, or removed rows resurrect.
|
|
1249
|
+
queryState.config.remoteSeen = true;
|
|
1250
|
+
queryState.config.emptyReads = 0;
|
|
1251
|
+
}
|
|
1207
1252
|
if (queryState.config.membershipKey) {
|
|
1208
1253
|
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
1209
1254
|
}
|
|
@@ -1269,9 +1314,15 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1269
1314
|
// unknown so the first paint falls back to a scan instead of rendering an
|
|
1270
1315
|
// empty list until the server answers.
|
|
1271
1316
|
config.membershipKnown = false;
|
|
1317
|
+
// The new bucket's server sets have not been seen yet — an empty read
|
|
1318
|
+
// must be re-confirmed there too.
|
|
1319
|
+
config.remoteSeen = false;
|
|
1320
|
+
config.emptyReads = 0;
|
|
1272
1321
|
if (config.membershipKey) {
|
|
1273
1322
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
1274
|
-
|
|
1323
|
+
// Length-checked for the same reason as the cold-start read above: an
|
|
1324
|
+
// empty durable row is indistinguishable from "never had membership".
|
|
1325
|
+
if (durable?.length) {
|
|
1275
1326
|
config.remoteArray = durable;
|
|
1276
1327
|
config.membershipKnown = true;
|
|
1277
1328
|
}
|
|
@@ -1957,7 +2008,11 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1957
2008
|
// removed row reappearing after a reload, and it works with no network.
|
|
1958
2009
|
if (membershipKey && !config.remoteArray?.length) {
|
|
1959
2010
|
const durable = await this.getWindowMembership(membershipKey);
|
|
1960
|
-
|
|
2011
|
+
// `durable.length` on purpose: an empty durable row cannot be told apart
|
|
2012
|
+
// from one written before this device ever saw a real id-set, and treating
|
|
2013
|
+
// it as known means the first paint is empty with no scan fallback. It
|
|
2014
|
+
// also self-heals devices poisoned by the pre-fix `writeWindowMembership`.
|
|
2015
|
+
if (durable?.length) {
|
|
1961
2016
|
config.remoteArray = durable;
|
|
1962
2017
|
config.membershipKnown = true;
|
|
1963
2018
|
}
|
|
@@ -150,7 +150,7 @@ export class FlagsAdminService {
|
|
|
150
150
|
constructor(private deps: FlagsAdminDeps) {}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* Everything the
|
|
153
|
+
* Everything the Access tab renders, in one round trip per source.
|
|
154
154
|
*
|
|
155
155
|
* Each section fails independently: a remote read that throws downgrades to
|
|
156
156
|
* `isAdmin: false` plus an `error`, while local assignments and overrides
|
|
@@ -196,7 +196,7 @@ export class FlagsAdminService {
|
|
|
196
196
|
} catch (err) {
|
|
197
197
|
// A missing `_00_admin` table means the deployment hasn't applied the
|
|
198
198
|
// internal schema yet. Say so — otherwise it reads as "not an admin".
|
|
199
|
-
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the
|
|
199
|
+
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Access tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
|
|
200
200
|
return snapshot;
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -86,7 +86,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
86
86
|
private localTablesFetching = false;
|
|
87
87
|
private localTablesAt = 0;
|
|
88
88
|
|
|
89
|
-
// Feature flag admin, backing the panel's
|
|
89
|
+
// Feature flag admin, backing the panel's Access tab. The local-override
|
|
90
90
|
// store is injected later (`setFeatureFlagOverrides`) because the
|
|
91
91
|
// FeatureFlagModule is built after this service.
|
|
92
92
|
private featureOverrides: LocalOverrideStore | null = null;
|
|
@@ -545,7 +545,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
545
545
|
}
|
|
546
546
|
|
|
547
547
|
/**
|
|
548
|
-
* Hand the FeatureFlagModule to the
|
|
548
|
+
* Hand the FeatureFlagModule to the Access tab so it can read and write local
|
|
549
549
|
* overrides. Called from `Sp00kyClient` once both are constructed; until then
|
|
550
550
|
* the override methods are no-ops that report an empty map.
|
|
551
551
|
*/
|
|
@@ -558,7 +558,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
558
558
|
(window as any).__00__ = {
|
|
559
559
|
version: this.version,
|
|
560
560
|
getState: () => this.getState(),
|
|
561
|
-
// ---- Feature flags (
|
|
561
|
+
// ---- Feature flags (Access tab) --------------------------------
|
|
562
562
|
// Remote reads/writes are admin-gated by SurrealDB, not here: a
|
|
563
563
|
// non-admin gets an empty flag list, and the `fn::feature::*` calls
|
|
564
564
|
// are denied outright. The override methods are purely local and
|
package/src/sp00ky.ts
CHANGED
|
@@ -465,7 +465,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
465
465
|
this.dataModule
|
|
466
466
|
);
|
|
467
467
|
|
|
468
|
-
// Let the DevTools
|
|
468
|
+
// Let the DevTools Access tab read and write local flag overrides. Done
|
|
469
469
|
// here rather than via the constructor because FeatureFlagModule is built
|
|
470
470
|
// above and DevToolsService takes its deps positionally.
|
|
471
471
|
this.devTools.setFeatureFlagOverrides(this.featureFlags);
|
|
@@ -1065,10 +1065,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1065
1065
|
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
1066
1066
|
* untouched, so clearing restores whatever the server says. Persisted to
|
|
1067
1067
|
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
1068
|
-
* DevTools
|
|
1068
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
1069
1069
|
*
|
|
1070
1070
|
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
1071
|
-
* and the DevTools
|
|
1071
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
1072
1072
|
*/
|
|
1073
1073
|
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
|
|
1074
1074
|
this.featureFlags.setLocalOverride(key, variant, payload);
|
package/src/types.ts
CHANGED
|
@@ -488,6 +488,29 @@ export interface QueryConfig {
|
|
|
488
488
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
489
489
|
*/
|
|
490
490
|
membershipKnown?: boolean;
|
|
491
|
+
/**
|
|
492
|
+
* Whether a NON-EMPTY id-set has arrived from the server for this query in
|
|
493
|
+
* this session. Gates whether an empty read may be believed.
|
|
494
|
+
*
|
|
495
|
+
* The server publishes `_00_list_ref` asynchronously — the SSP queues a
|
|
496
|
+
* view's initial edges to a coalescing flusher and returns from
|
|
497
|
+
* `fn::query::register` before they land — so an empty read right after
|
|
498
|
+
* registration says nothing about the query being empty. Believing it (and
|
|
499
|
+
* mirroring it to the durable `_00_window` row) blanked lists and kept them
|
|
500
|
+
* blank across reloads. Once a real set has been seen, a later empty one is a
|
|
501
|
+
* genuine transition and must be honoured, or removed rows resurrect.
|
|
502
|
+
*
|
|
503
|
+
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
504
|
+
*/
|
|
505
|
+
remoteSeen?: boolean;
|
|
506
|
+
/**
|
|
507
|
+
* Consecutive empty id-sets read from the server while `remoteSeen` is still
|
|
508
|
+
* false. Bounds how long an unconfirmed empty may be ignored, so a window
|
|
509
|
+
* that genuinely emptied while this device was away is believed on the second
|
|
510
|
+
* read instead of rendering stale rows forever. Reset by any non-empty set.
|
|
511
|
+
* In-memory only.
|
|
512
|
+
*/
|
|
513
|
+
emptyReads?: number;
|
|
491
514
|
/**
|
|
492
515
|
* Key of this query's durable `_00_window` membership row: a hash of
|
|
493
516
|
* `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
|