@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.301
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14939 -2432
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { walkOpfs } from './storage-info';
|
|
3
|
+
|
|
4
|
+
/** Minimal in-memory OPFS: directories are nested objects, files are numbers
|
|
5
|
+
* (their size) or 'locked' (getFile() throws, like a live SAHPool handle). */
|
|
6
|
+
type FakeTree = { [name: string]: FakeTree | number | 'locked' };
|
|
7
|
+
|
|
8
|
+
function makeDirHandle(tree: FakeTree): any {
|
|
9
|
+
return {
|
|
10
|
+
kind: 'directory',
|
|
11
|
+
entries: async function* () {
|
|
12
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
13
|
+
if (typeof node === 'object') {
|
|
14
|
+
yield [name, makeDirHandle(node)];
|
|
15
|
+
} else {
|
|
16
|
+
yield [
|
|
17
|
+
name,
|
|
18
|
+
{
|
|
19
|
+
kind: 'file',
|
|
20
|
+
getFile: async () => {
|
|
21
|
+
if (node === 'locked') throw new DOMException('locked', 'NoModificationAllowedError');
|
|
22
|
+
return { size: node };
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stubOpfs(tree: FakeTree | null) {
|
|
33
|
+
vi.stubGlobal('navigator', tree === null ? {} : {
|
|
34
|
+
storage: { getDirectory: async () => makeDirHandle(tree) },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('walkOpfs', () => {
|
|
43
|
+
it('reports unsupported without OPFS APIs', async () => {
|
|
44
|
+
stubOpfs(null);
|
|
45
|
+
expect(await walkOpfs()).toEqual({
|
|
46
|
+
supported: false,
|
|
47
|
+
entries: [],
|
|
48
|
+
totalBytes: 0,
|
|
49
|
+
truncated: false,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('walks recursively, sums readable sizes, and omits size for locked files', async () => {
|
|
54
|
+
stubOpfs({
|
|
55
|
+
'.sp00ky-anon': { '0000000001': 4096, '0000000002': 'locked' },
|
|
56
|
+
'other.txt': 10,
|
|
57
|
+
});
|
|
58
|
+
const res = await walkOpfs();
|
|
59
|
+
expect(res.supported).toBe(true);
|
|
60
|
+
expect(res.truncated).toBe(false);
|
|
61
|
+
// Locked file present but without a size; total counts only readable bytes.
|
|
62
|
+
expect(res.totalBytes).toBe(4106);
|
|
63
|
+
expect(res.entries).toEqual([
|
|
64
|
+
{ path: '.sp00ky-anon', kind: 'directory' },
|
|
65
|
+
{ path: '.sp00ky-anon/0000000001', kind: 'file', size: 4096 },
|
|
66
|
+
{ path: '.sp00ky-anon/0000000002', kind: 'file' },
|
|
67
|
+
{ path: 'other.txt', kind: 'file', size: 10 },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('caps the listing and flags truncation', async () => {
|
|
72
|
+
const big: FakeTree = {};
|
|
73
|
+
for (let i = 0; i < 10; i++) big[`f${i}`] = 1;
|
|
74
|
+
stubOpfs(big);
|
|
75
|
+
const res = await walkOpfs(5);
|
|
76
|
+
expect(res.truncated).toBe(true);
|
|
77
|
+
expect(res.entries.length).toBe(5);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage diagnostics for the DevTools Storage tab: what engine backs the
|
|
3
|
+
* local cache, whether it actually persists, how much of the device's quota
|
|
4
|
+
* the origin uses, and what is physically sitting in OPFS. Assembled by
|
|
5
|
+
* `DevToolsService.getStorageInfo()`; everything here is JSON-safe.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface OpfsEntry {
|
|
9
|
+
/** Path relative to the OPFS root, e.g. `.sp00ky-anon/0000000000000001`. */
|
|
10
|
+
path: string;
|
|
11
|
+
kind: 'file' | 'directory';
|
|
12
|
+
/** Absent when the file's size can't be read (e.g. an exclusive sync access
|
|
13
|
+
* handle is held on it — exactly the case during SAHPool contention). */
|
|
14
|
+
size?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Engine-side numbers only the engine can produce (worker round-trips). */
|
|
18
|
+
export interface EngineStorageDiagnostics {
|
|
19
|
+
engine: 'sqlite';
|
|
20
|
+
bucketId: string;
|
|
21
|
+
useOpfs: boolean;
|
|
22
|
+
workerSelectConfigured: boolean;
|
|
23
|
+
/** `false` while configured `true` means the runtime downgraded to the
|
|
24
|
+
* legacy multi-hop select (stale cached worker bundle). */
|
|
25
|
+
workerSelectEffective: boolean;
|
|
26
|
+
/** page_count * page_size. */
|
|
27
|
+
dbSizeBytes?: number;
|
|
28
|
+
/** freelist_count * page_size — reclaimable via VACUUM. */
|
|
29
|
+
freelistBytes?: number;
|
|
30
|
+
tableCounts?: { table: string; rows: number }[];
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared-tabs coordination state. Reported ONLY when `sharedTabs: true` was
|
|
36
|
+
* configured (apps that never asked for it get `null`, so the panel shows
|
|
37
|
+
* nothing). `active: false` with a `reason` is itself the useful signal: the
|
|
38
|
+
* app asked to share one store and this tab is not, so it owns or contends for
|
|
39
|
+
* the OPFS pool alone.
|
|
40
|
+
*/
|
|
41
|
+
export interface SharedTabsInfo {
|
|
42
|
+
active: boolean;
|
|
43
|
+
/** Why inactive: a capability gate reason, or 'fell-back' when the broker
|
|
44
|
+
* was reachable but no role landed (election timeout, rejected tab). */
|
|
45
|
+
reason?: string;
|
|
46
|
+
role?: 'solo' | 'leader' | 'follower';
|
|
47
|
+
/** This tab's id (also the suffix of every mutation id it mints). */
|
|
48
|
+
tabId?: string;
|
|
49
|
+
/** Monotonic id of the current leadership term; embedded in the worker lock. */
|
|
50
|
+
leadershipId?: number;
|
|
51
|
+
/** Who owns the store: this tab when leader, else the leader's tab id. */
|
|
52
|
+
leaderTabId?: string | null;
|
|
53
|
+
/** Leader only: attached follower tabs. */
|
|
54
|
+
followers?: number;
|
|
55
|
+
/** Leader only: ingest batches relayed to followers so far. */
|
|
56
|
+
relayedBatches?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StorageInfo {
|
|
60
|
+
at: number;
|
|
61
|
+
engine: { kind: 'surrealdb' | 'sqlite' | 'custom'; store: string; bucketId: string };
|
|
62
|
+
health: { status: 'unknown' | 'persistent' | 'memory'; fallback: boolean; error?: string };
|
|
63
|
+
/** Shared-tabs state, or null when the feature was never requested. */
|
|
64
|
+
tabs?: SharedTabsInfo | null;
|
|
65
|
+
browser: {
|
|
66
|
+
/** `navigator.storage.persisted()` — whether the origin's storage is
|
|
67
|
+
* exempt from eviction (unrelated to the OPFS pool lock). */
|
|
68
|
+
persisted?: boolean;
|
|
69
|
+
usage?: number;
|
|
70
|
+
quota?: number;
|
|
71
|
+
/** Chrome-only per-system breakdown from `estimate()`. */
|
|
72
|
+
usageDetails?: Record<string, number>;
|
|
73
|
+
error?: string;
|
|
74
|
+
};
|
|
75
|
+
opfs: {
|
|
76
|
+
supported: boolean;
|
|
77
|
+
entries: OpfsEntry[];
|
|
78
|
+
totalBytes: number;
|
|
79
|
+
truncated: boolean;
|
|
80
|
+
error?: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Bucket-file cache counters. `evicted*` is cumulative for this tab's
|
|
84
|
+
* session, so a number that climbs while `totalBytes` sits at the budget is
|
|
85
|
+
* the signal that `blobCache.maxBytes` is too small for the working set.
|
|
86
|
+
*/
|
|
87
|
+
blobs?: BlobCacheInfo;
|
|
88
|
+
/** Snapshot of `globalThis.__sqliteStats` (SQLite engine only). */
|
|
89
|
+
sqliteStats?: Record<string, unknown>;
|
|
90
|
+
engineDiagnostics?: EngineStorageDiagnostics;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface BlobCacheInfo {
|
|
94
|
+
entries: number;
|
|
95
|
+
totalBytes: number;
|
|
96
|
+
budgetBytes: number;
|
|
97
|
+
pinnedBytes: number;
|
|
98
|
+
evictedEntries: number;
|
|
99
|
+
evictedBytes: number;
|
|
100
|
+
/** Manifest rows rebuilt from disk at the last reconcile. */
|
|
101
|
+
reconciledEntries: number;
|
|
102
|
+
hits: number;
|
|
103
|
+
misses: number;
|
|
104
|
+
/** False when OPFS is unwritable or the quota was exhausted — the cache is
|
|
105
|
+
* running in per-tab memory and nothing survives a reload. */
|
|
106
|
+
persistent: boolean;
|
|
107
|
+
/** Over budget with only pinned or on-screen entries left: new files are no
|
|
108
|
+
* longer written rather than pinned ones being discarded. */
|
|
109
|
+
persistPaused: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
|
|
114
|
+
* which throws for a file another context holds an exclusive sync access
|
|
115
|
+
* handle on — SAHPool does exactly that for its whole pool, so a locked file
|
|
116
|
+
* (size omitted) is a live "who has the pool" signal, not a failure.
|
|
117
|
+
*/
|
|
118
|
+
export async function walkOpfs(maxEntries = 2000, maxDepth = 8): Promise<StorageInfo['opfs']> {
|
|
119
|
+
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
120
|
+
if (!nav?.storage?.getDirectory) {
|
|
121
|
+
return { supported: false, entries: [], totalBytes: 0, truncated: false };
|
|
122
|
+
}
|
|
123
|
+
const entries: OpfsEntry[] = [];
|
|
124
|
+
let totalBytes = 0;
|
|
125
|
+
let truncated = false;
|
|
126
|
+
try {
|
|
127
|
+
const root = await nav.storage.getDirectory();
|
|
128
|
+
const walk = async (dir: FileSystemDirectoryHandle, prefix: string, depth: number) => {
|
|
129
|
+
if (depth > maxDepth) return;
|
|
130
|
+
// entries() is standard; older lib.dom typings may lack it.
|
|
131
|
+
for await (const [name, handle] of (dir as any).entries() as AsyncIterable<
|
|
132
|
+
[string, FileSystemHandle]
|
|
133
|
+
>) {
|
|
134
|
+
if (entries.length >= maxEntries) {
|
|
135
|
+
truncated = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
139
|
+
if (handle.kind === 'directory') {
|
|
140
|
+
entries.push({ path, kind: 'directory' });
|
|
141
|
+
await walk(handle as FileSystemDirectoryHandle, path, depth + 1);
|
|
142
|
+
} else {
|
|
143
|
+
let size: number | undefined;
|
|
144
|
+
try {
|
|
145
|
+
size = (await (handle as FileSystemFileHandle).getFile()).size;
|
|
146
|
+
totalBytes += size;
|
|
147
|
+
} catch {
|
|
148
|
+
// Locked by an exclusive access handle (e.g. a live SAHPool).
|
|
149
|
+
}
|
|
150
|
+
const entry: OpfsEntry = { path, kind: 'file' };
|
|
151
|
+
if (size !== undefined) entry.size = size;
|
|
152
|
+
entries.push(entry);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await walk(root, '', 0);
|
|
157
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
158
|
+
return { supported: true, entries, totalBytes, truncated };
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return {
|
|
161
|
+
supported: true,
|
|
162
|
+
entries,
|
|
163
|
+
totalBytes,
|
|
164
|
+
truncated,
|
|
165
|
+
error: e instanceof Error ? e.message : String(e),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
emptyBackendInfo,
|
|
4
|
+
emptyBackendVersions,
|
|
5
|
+
parseBackendInfo,
|
|
6
|
+
toEntityArray,
|
|
7
|
+
UNAVAILABLE,
|
|
8
|
+
} from './versions';
|
|
9
|
+
|
|
10
|
+
describe('toEntityArray', () => {
|
|
11
|
+
it('passes through an array, dropping non-objects', () => {
|
|
12
|
+
expect(toEntityArray([{ entity: 'ssp' }, null, 'x'])).toEqual([{ entity: 'ssp' }]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('wraps a single object', () => {
|
|
16
|
+
expect(toEntityArray({ entity: 'ssp' })).toEqual([{ entity: 'ssp' }]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns [] for null/undefined/primitives', () => {
|
|
20
|
+
expect(toEntityArray(null)).toEqual([]);
|
|
21
|
+
expect(toEntityArray(undefined)).toEqual([]);
|
|
22
|
+
expect(toEntityArray(42)).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('parseBackendInfo', () => {
|
|
27
|
+
it('parses the singlenode shape (ssp only) incl. surrealdb_version', () => {
|
|
28
|
+
const { versions, entities } = parseBackendInfo([
|
|
29
|
+
{ entity: 'ssp', version: '0.0.1-canary.69', surrealdb_version: '2.0.3', status: 'ready' },
|
|
30
|
+
]);
|
|
31
|
+
expect(versions.ssp).toBe('0.0.1-canary.69');
|
|
32
|
+
expect(versions.surrealdb).toBe('2.0.3');
|
|
33
|
+
expect(versions.scheduler).toBe(UNAVAILABLE);
|
|
34
|
+
expect(entities).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses the cluster shape (scheduler + ssp + backend)', () => {
|
|
38
|
+
const { versions, entities } = parseBackendInfo([
|
|
39
|
+
{ entity: 'scheduler', version: '0.9.0', surrealdb_version: '2.0.3', status: 'ready' },
|
|
40
|
+
{ entity: 'ssp', version: '0.9.0', status: 'ready' },
|
|
41
|
+
{ entity: 'backend', id: 'surrealdb', status: 'healthy' },
|
|
42
|
+
]);
|
|
43
|
+
expect(versions.scheduler).toBe('0.9.0');
|
|
44
|
+
expect(versions.ssp).toBe('0.9.0');
|
|
45
|
+
expect(versions.surrealdb).toBe('2.0.3');
|
|
46
|
+
expect(entities).toHaveLength(3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('strips a leading surrealdb- prefix', () => {
|
|
50
|
+
const { versions } = parseBackendInfo([
|
|
51
|
+
{ entity: 'ssp', version: '1.0.0', surrealdb_version: 'surrealdb-2.1.0' },
|
|
52
|
+
]);
|
|
53
|
+
expect(versions.surrealdb).toBe('2.1.0');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('takes surrealdb_version from whichever entity reports it', () => {
|
|
57
|
+
const { versions } = parseBackendInfo([
|
|
58
|
+
{ entity: 'scheduler', version: '0.9.0' },
|
|
59
|
+
{ entity: 'ssp', version: '0.9.0', surrealdb_version: '3.1.0' },
|
|
60
|
+
]);
|
|
61
|
+
expect(versions.surrealdb).toBe('3.1.0');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('tolerates a single object instead of an array', () => {
|
|
65
|
+
const { versions } = parseBackendInfo({ entity: 'ssp', version: '1.2.3' });
|
|
66
|
+
expect(versions.ssp).toBe('1.2.3');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns all-unavailable / empty for null or garbage', () => {
|
|
70
|
+
expect(parseBackendInfo(null)).toEqual(emptyBackendInfo());
|
|
71
|
+
expect(parseBackendInfo('nope').versions).toEqual(emptyBackendVersions());
|
|
72
|
+
expect(parseBackendInfo([{ foo: 'bar' }]).versions).toEqual(emptyBackendVersions());
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend versions of the stack components, derived from the entity list the
|
|
3
|
+
* backend `/info` endpoint exposes (read via the `fn::spooky::info()` SurrealQL
|
|
4
|
+
* function). Any component that isn't reported degrades to `'unavailable'`.
|
|
5
|
+
*/
|
|
6
|
+
export interface BackendVersions {
|
|
7
|
+
ssp: string;
|
|
8
|
+
scheduler: string;
|
|
9
|
+
surrealdb: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const UNAVAILABLE = 'unavailable';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A single stack entity as reported by `/info` (one per ssp / scheduler /
|
|
16
|
+
* backend). Carries far more than versions — status, uptime, ip, views — so the
|
|
17
|
+
* DevTools can render the whole stack. Extra fields are preserved verbatim.
|
|
18
|
+
*/
|
|
19
|
+
/** One end-to-end sync-pipeline probe cycle recorded by the scheduler. */
|
|
20
|
+
export interface HeartbeatSample {
|
|
21
|
+
ts: number;
|
|
22
|
+
/** `null` for a failed cycle. */
|
|
23
|
+
ms: number | null;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* E2E heartbeat state, reported on the scheduler entity only. The scheduler
|
|
29
|
+
* times a probe row's full round trip (DB event → ingest → broadcast → SSP
|
|
30
|
+
* circuit step); `samples` is its rolling window of recent cycles, which the
|
|
31
|
+
* DevTools sparkline renders.
|
|
32
|
+
*/
|
|
33
|
+
export interface HeartbeatInfo {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
stale: boolean;
|
|
36
|
+
/** Nothing to measure right now (e.g. no ready SSPs mid-bootstrap). */
|
|
37
|
+
blocked?: boolean;
|
|
38
|
+
blocked_reason?: string | null;
|
|
39
|
+
last_e2e_ms: number | null;
|
|
40
|
+
last_ok_epoch_ms: number | null;
|
|
41
|
+
consecutive_failures: number;
|
|
42
|
+
interval_secs: number;
|
|
43
|
+
samples?: HeartbeatSample[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BackendEntity {
|
|
47
|
+
entity: string;
|
|
48
|
+
id?: string;
|
|
49
|
+
ip?: string | null;
|
|
50
|
+
status?: string;
|
|
51
|
+
version?: string;
|
|
52
|
+
surrealdb_version?: string;
|
|
53
|
+
uptime_seconds?: number;
|
|
54
|
+
views?: number;
|
|
55
|
+
/** Scheduler entity only. */
|
|
56
|
+
heartbeat?: HeartbeatInfo;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BackendInfo {
|
|
61
|
+
versions: BackendVersions;
|
|
62
|
+
entities: BackendEntity[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function emptyBackendVersions(): BackendVersions {
|
|
66
|
+
return { ssp: UNAVAILABLE, scheduler: UNAVAILABLE, surrealdb: UNAVAILABLE };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function emptyBackendInfo(): BackendInfo {
|
|
70
|
+
return { versions: emptyBackendVersions(), entities: [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
|
|
74
|
+
function normalizeServerVersion(v: string): string {
|
|
75
|
+
return String(v).replace(/^surrealdb-/i, '').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
|
|
80
|
+
* array. The SurrealQL function returns the parsed `/info` array; depending on
|
|
81
|
+
* how the result is unwrapped it may arrive as the array itself, a single
|
|
82
|
+
* object, or `null`. Tolerant of all three.
|
|
83
|
+
*/
|
|
84
|
+
export function toEntityArray(raw: unknown): BackendEntity[] {
|
|
85
|
+
if (Array.isArray(raw)) return raw.filter((e): e is BackendEntity => !!e && typeof e === 'object');
|
|
86
|
+
if (raw && typeof raw === 'object') return [raw as BackendEntity];
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive component versions + the full entity list from a `/info` entity array.
|
|
92
|
+
* `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
|
|
93
|
+
* scheduler). Never throws; missing pieces stay `'unavailable'`.
|
|
94
|
+
*/
|
|
95
|
+
export function parseBackendInfo(raw: unknown): BackendInfo {
|
|
96
|
+
const entities = toEntityArray(raw);
|
|
97
|
+
const versions = emptyBackendVersions();
|
|
98
|
+
|
|
99
|
+
for (const entity of entities) {
|
|
100
|
+
const version = entity.version ? String(entity.version) : undefined;
|
|
101
|
+
if (entity.entity === 'ssp' && version) versions.ssp = version;
|
|
102
|
+
else if (entity.entity === 'scheduler' && version) versions.scheduler = version;
|
|
103
|
+
|
|
104
|
+
if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) {
|
|
105
|
+
versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { versions, entities };
|
|
110
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { FeatureFlagModule } from './index';
|
|
3
|
+
|
|
4
|
+
// vitest runs in the `node` environment here, so there is no localStorage.
|
|
5
|
+
// The module guards every access with `globalThis.localStorage?.`, which keeps
|
|
6
|
+
// overrides in-memory-only — this shim lets the persistence path be tested.
|
|
7
|
+
function installLocalStorage() {
|
|
8
|
+
const store = new Map<string, string>();
|
|
9
|
+
const shim = {
|
|
10
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
11
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
12
|
+
removeItem: (k: string) => void store.delete(k),
|
|
13
|
+
};
|
|
14
|
+
(globalThis as any).localStorage = shim;
|
|
15
|
+
return { store, uninstall: () => delete (globalThis as any).localStorage };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Minimal mocks for the three deps the module touches. The DataModule mock
|
|
19
|
+
// captures the single subscribe callback so a test can push live results, and
|
|
20
|
+
// counts query() calls to assert the query is SHARED (one registration for all
|
|
21
|
+
// flags), not per-key.
|
|
22
|
+
function makeDeps() {
|
|
23
|
+
let subCb: ((records: unknown[]) => void) | null = null;
|
|
24
|
+
const calls: Array<{ sql: string; params: unknown }> = [];
|
|
25
|
+
let authCb: ((userId: string | null) => void) | null = null;
|
|
26
|
+
|
|
27
|
+
const dataModule = {
|
|
28
|
+
query: async (_table: string, sql: string, params: unknown) => {
|
|
29
|
+
calls.push({ sql, params });
|
|
30
|
+
return `hash:${calls.length}`;
|
|
31
|
+
},
|
|
32
|
+
subscribe: (_hash: string, cb: (records: unknown[]) => void) => {
|
|
33
|
+
subCb = cb;
|
|
34
|
+
return () => {
|
|
35
|
+
subCb = null;
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const sync = { enqueueDownEvent: () => {} };
|
|
40
|
+
const auth = {
|
|
41
|
+
subscribe: (cb: (userId: string | null) => void) => {
|
|
42
|
+
authCb = cb;
|
|
43
|
+
return () => {
|
|
44
|
+
authCb = null;
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const logger = { child: () => ({ warn: () => {} }) };
|
|
49
|
+
|
|
50
|
+
const deps = { dataModule, sync, auth, logger } as any;
|
|
51
|
+
return {
|
|
52
|
+
deps,
|
|
53
|
+
calls,
|
|
54
|
+
push: (records: unknown[]) => subCb?.(records),
|
|
55
|
+
setUser: (id: string | null) => authCb?.(id),
|
|
56
|
+
hasSub: () => subCb !== null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
61
|
+
|
|
62
|
+
describe('FeatureFlagModule', () => {
|
|
63
|
+
let env: ReturnType<typeof makeDeps>;
|
|
64
|
+
let mod: FeatureFlagModule<any>;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
env = makeDeps();
|
|
68
|
+
mod = new FeatureFlagModule(env.deps);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('registers ONE shared, unfiltered query for many flags', async () => {
|
|
72
|
+
mod.feature('alpha');
|
|
73
|
+
mod.feature('beta');
|
|
74
|
+
mod.feature('gamma');
|
|
75
|
+
await tick();
|
|
76
|
+
|
|
77
|
+
expect(env.calls.length).toBe(1);
|
|
78
|
+
expect(env.calls[0].sql).not.toContain('WHERE');
|
|
79
|
+
expect(env.calls[0].sql).toContain('FROM _00_user_feature');
|
|
80
|
+
expect(env.calls[0].params).toEqual({});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fans the shared result out to each handle by key', async () => {
|
|
84
|
+
const alpha = mod.feature('alpha', { fallback: 'off' });
|
|
85
|
+
const beta = mod.feature('beta', { fallback: 'off' });
|
|
86
|
+
const missing = mod.feature('missing', { fallback: 'off' });
|
|
87
|
+
await tick();
|
|
88
|
+
|
|
89
|
+
env.push([
|
|
90
|
+
{ key: 'alpha', variant: 'on' },
|
|
91
|
+
{ key: 'beta', variant: 'off' },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
expect(alpha.enabled()).toBe(true);
|
|
95
|
+
expect(alpha.variant()).toBe('on');
|
|
96
|
+
expect(beta.enabled()).toBe(false); // assigned 'off'
|
|
97
|
+
expect(missing.enabled()).toBe(false); // no row → fallback 'off'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('observes NEW assignments live without re-registering', async () => {
|
|
101
|
+
const flag = mod.feature('live', { fallback: 'off' });
|
|
102
|
+
await tick();
|
|
103
|
+
|
|
104
|
+
env.push([]); // user starts with no assignment
|
|
105
|
+
expect(flag.enabled()).toBe(false);
|
|
106
|
+
|
|
107
|
+
env.push([{ key: 'live', variant: 'on' }]); // assigned while observing
|
|
108
|
+
expect(flag.enabled()).toBe(true);
|
|
109
|
+
|
|
110
|
+
expect(env.calls.length).toBe(1); // still the same single query
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('seeds a late-created handle from the already-loaded snapshot', async () => {
|
|
114
|
+
mod.feature('alpha', { fallback: 'off' });
|
|
115
|
+
await tick();
|
|
116
|
+
env.push([{ key: 'alpha', variant: 'on' }]);
|
|
117
|
+
|
|
118
|
+
const late = mod.feature('alpha', { fallback: 'off' });
|
|
119
|
+
expect(late.enabled()).toBe(true); // no fallback flash
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('clears flags and re-observes on user change', async () => {
|
|
123
|
+
mod.init();
|
|
124
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
125
|
+
await tick();
|
|
126
|
+
env.push([{ key: 'alpha', variant: 'on' }]);
|
|
127
|
+
expect(flag.enabled()).toBe(true);
|
|
128
|
+
|
|
129
|
+
env.setUser('user:other'); // sign-in as a different user
|
|
130
|
+
await tick();
|
|
131
|
+
expect(flag.enabled()).toBe(false); // cleared until the new query resolves
|
|
132
|
+
expect(env.hasSub()).toBe(true); // re-registered for the new user
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Local overrides force a variant in THIS browser only. They must win over the
|
|
137
|
+
// server assignment on every read path — `variant()`, `payload()`, `enabled()`
|
|
138
|
+
// and `subscribe()` all funnel through the same resolver, so a gap in any one
|
|
139
|
+
// of them is a gap in all of them.
|
|
140
|
+
describe('FeatureFlagModule local overrides', () => {
|
|
141
|
+
let env: ReturnType<typeof makeDeps>;
|
|
142
|
+
let mod: FeatureFlagModule<any>;
|
|
143
|
+
let ls: ReturnType<typeof installLocalStorage>;
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
ls = installLocalStorage();
|
|
147
|
+
env = makeDeps();
|
|
148
|
+
mod = new FeatureFlagModule(env.deps);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
afterEach(() => ls.uninstall());
|
|
152
|
+
|
|
153
|
+
it('wins over the server assignment', async () => {
|
|
154
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
155
|
+
await tick();
|
|
156
|
+
env.push([{ key: 'alpha', variant: 'off' }]);
|
|
157
|
+
expect(flag.enabled()).toBe(false);
|
|
158
|
+
|
|
159
|
+
mod.setLocalOverride('alpha', 'on');
|
|
160
|
+
expect(flag.variant()).toBe('on');
|
|
161
|
+
expect(flag.enabled()).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('survives a later live result for the same key', async () => {
|
|
165
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
166
|
+
await tick();
|
|
167
|
+
mod.setLocalOverride('alpha', 'on');
|
|
168
|
+
|
|
169
|
+
env.push([{ key: 'alpha', variant: 'off' }]); // server disagrees
|
|
170
|
+
expect(flag.variant()).toBe('on');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('applies before the first result, without a fallback flash', () => {
|
|
174
|
+
mod.setLocalOverride('alpha', 'on');
|
|
175
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
176
|
+
expect(flag.variant()).toBe('on'); // seeded even though nothing loaded yet
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('carries its own payload', async () => {
|
|
180
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
181
|
+
await tick();
|
|
182
|
+
env.push([{ key: 'alpha', variant: 'off', payload: { copy: 'server' } }]);
|
|
183
|
+
|
|
184
|
+
mod.setLocalOverride('alpha', 'on', { copy: 'local' });
|
|
185
|
+
expect(flag.payload()).toEqual({ copy: 'local' });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('notifies subscribers when set and cleared', async () => {
|
|
189
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
190
|
+
await tick();
|
|
191
|
+
env.push([{ key: 'alpha', variant: 'off' }]);
|
|
192
|
+
|
|
193
|
+
const seen: (string | undefined)[] = [];
|
|
194
|
+
flag.subscribe((s) => seen.push(s.variant));
|
|
195
|
+
expect(seen).toEqual(['off']); // immediate call
|
|
196
|
+
|
|
197
|
+
mod.setLocalOverride('alpha', 'on');
|
|
198
|
+
mod.setLocalOverride('alpha', null);
|
|
199
|
+
expect(seen).toEqual(['off', 'on', 'off']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('restores the server assignment when cleared', async () => {
|
|
203
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
204
|
+
await tick();
|
|
205
|
+
env.push([{ key: 'alpha', variant: 'treatment', payload: { copy: 'server' } }]);
|
|
206
|
+
|
|
207
|
+
mod.setLocalOverride('alpha', 'off');
|
|
208
|
+
expect(flag.variant()).toBe('off');
|
|
209
|
+
|
|
210
|
+
mod.clearLocalOverrides();
|
|
211
|
+
expect(flag.variant()).toBe('treatment');
|
|
212
|
+
expect(flag.payload()).toEqual({ copy: 'server' });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('survives a user change — it is a browser setting, not a session one', async () => {
|
|
216
|
+
mod.init();
|
|
217
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
218
|
+
await tick();
|
|
219
|
+
mod.setLocalOverride('alpha', 'on');
|
|
220
|
+
|
|
221
|
+
env.setUser('user:other');
|
|
222
|
+
await tick();
|
|
223
|
+
expect(flag.variant()).toBe('on');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('persists to localStorage and reloads into a fresh module', () => {
|
|
227
|
+
mod.setLocalOverride('alpha', 'on', { copy: 'local' });
|
|
228
|
+
expect(mod.getLocalOverrides()).toEqual({ alpha: { variant: 'on', payload: { copy: 'local' } } });
|
|
229
|
+
|
|
230
|
+
// A new module reads the same page-origin store, as after a reload.
|
|
231
|
+
const reloaded = new FeatureFlagModule(makeDeps().deps);
|
|
232
|
+
expect(reloaded.getLocalOverrides()).toEqual({
|
|
233
|
+
alpha: { variant: 'on', payload: { copy: 'local' } },
|
|
234
|
+
});
|
|
235
|
+
expect(reloaded.feature('alpha', { fallback: 'off' }).variant()).toBe('on');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('drops the storage key entirely once the last override is cleared', () => {
|
|
239
|
+
mod.setLocalOverride('alpha', 'on');
|
|
240
|
+
expect(ls.store.size).toBe(1);
|
|
241
|
+
|
|
242
|
+
mod.setLocalOverride('alpha', null);
|
|
243
|
+
expect(ls.store.size).toBe(0);
|
|
244
|
+
expect(new FeatureFlagModule(makeDeps().deps).getLocalOverrides()).toEqual({});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('ignores a corrupt store rather than failing to construct', () => {
|
|
248
|
+
ls.store.set('sp00ky:feature-overrides', '{not json');
|
|
249
|
+
expect(() => new FeatureFlagModule(makeDeps().deps)).not.toThrow();
|
|
250
|
+
});
|
|
251
|
+
});
|