@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- 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/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- 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 +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- 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/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- 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 +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -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.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +59 -3
- 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 +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { DataModule } from '../data/index';
|
|
3
|
+
import type { Sp00kySync } from '../sync/index';
|
|
4
|
+
import type { AuthService } from '../auth/index';
|
|
5
|
+
import type { Logger } from '../../services/logger/index';
|
|
6
|
+
import type { QueryTimeToLive } from '../../types';
|
|
7
|
+
|
|
8
|
+
// One shared LIVE query over ALL of the signed-in user's assignments — the
|
|
9
|
+
// `_00_user_feature` select permission scopes it to `user = $auth.id`, so no
|
|
10
|
+
// per-key `WHERE key = $key` param is needed. A single registration means every
|
|
11
|
+
// flag the user is (or becomes) assigned is observed at once: new assignments
|
|
12
|
+
// stream in live, and a handle for an unassigned key simply resolves to its
|
|
13
|
+
// fallback. Avoids one-registration-per-flag and the param-filtered live query.
|
|
14
|
+
const FEATURE_QUERY = 'SELECT key, variant, payload FROM _00_user_feature';
|
|
15
|
+
|
|
16
|
+
// Local overrides live on the PAGE origin, so they survive reloads and are
|
|
17
|
+
// shared across tabs of the app. Deliberately not the DevTools panel's own
|
|
18
|
+
// storage — the panel runs on a different origin and would get a separate
|
|
19
|
+
// bucket.
|
|
20
|
+
const OVERRIDE_STORAGE_KEY = 'sp00ky:feature-overrides';
|
|
21
|
+
|
|
22
|
+
interface FeatureRow {
|
|
23
|
+
key?: string;
|
|
24
|
+
variant?: string;
|
|
25
|
+
payload?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FeatureFlagSnapshot {
|
|
29
|
+
variant: string | undefined;
|
|
30
|
+
payload: unknown | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FeatureFlagOptions {
|
|
34
|
+
fallback?: string;
|
|
35
|
+
ttl?: QueryTimeToLive;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A locally forced variant. Applies to THIS browser only and is never sent to
|
|
40
|
+
* the server — the assignment in `_00_user_feature` is untouched, so clearing
|
|
41
|
+
* the override restores whatever the server says.
|
|
42
|
+
*/
|
|
43
|
+
export interface FeatureFlagOverride {
|
|
44
|
+
variant: string;
|
|
45
|
+
payload?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class FeatureFlagHandle {
|
|
49
|
+
private latest: FeatureFlagSnapshot = { variant: undefined, payload: undefined };
|
|
50
|
+
private listeners = new Set<(s: FeatureFlagSnapshot) => void>();
|
|
51
|
+
private unsubscribeFn: (() => void) | null = null;
|
|
52
|
+
private onCloseFn: (() => void) | null = null;
|
|
53
|
+
private closed = false;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
public readonly key: string,
|
|
57
|
+
public readonly fallback: string | undefined,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
attach(unsubscribe: () => void): void {
|
|
61
|
+
this.unsubscribeFn?.();
|
|
62
|
+
this.unsubscribeFn = unsubscribe;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
detach(): void {
|
|
66
|
+
this.unsubscribeFn?.();
|
|
67
|
+
this.unsubscribeFn = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
set(snapshot: FeatureFlagSnapshot): void {
|
|
71
|
+
if (this.closed) return;
|
|
72
|
+
this.latest = snapshot;
|
|
73
|
+
for (const cb of this.listeners) cb(snapshot);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
variant(): string | undefined {
|
|
77
|
+
return this.latest.variant ?? this.fallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
payload<T = unknown>(): T | undefined {
|
|
81
|
+
return this.latest.payload as T | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
enabled(): boolean {
|
|
85
|
+
const v = this.variant();
|
|
86
|
+
return v !== undefined && v !== 'off';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
subscribe(cb: (s: FeatureFlagSnapshot) => void): () => void {
|
|
90
|
+
this.listeners.add(cb);
|
|
91
|
+
cb({ variant: this.variant(), payload: this.latest.payload });
|
|
92
|
+
return () => {
|
|
93
|
+
this.listeners.delete(cb);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onClose(cb: () => void): void {
|
|
98
|
+
this.onCloseFn = cb;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
close(): void {
|
|
102
|
+
if (this.closed) return;
|
|
103
|
+
this.closed = true;
|
|
104
|
+
this.listeners.clear();
|
|
105
|
+
this.detach();
|
|
106
|
+
this.onCloseFn?.();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FeatureFlagModuleDeps<S extends SchemaStructure> {
|
|
111
|
+
dataModule: DataModule<S>;
|
|
112
|
+
sync: Sp00kySync<S>;
|
|
113
|
+
auth: AuthService<S>;
|
|
114
|
+
logger: Logger;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class FeatureFlagModule<S extends SchemaStructure> {
|
|
118
|
+
private logger: Logger;
|
|
119
|
+
private handles = new Set<FeatureFlagHandle>();
|
|
120
|
+
private authUnsubscribe: (() => void) | null = null;
|
|
121
|
+
private lastUserId: string | null = null;
|
|
122
|
+
|
|
123
|
+
// The single shared live query over the user's assignments.
|
|
124
|
+
private querySubscription: (() => void) | null = null;
|
|
125
|
+
private starting = false;
|
|
126
|
+
// Longest TTL any caller asked for (the query is shared across all flags).
|
|
127
|
+
private ttl: QueryTimeToLive = '10m';
|
|
128
|
+
// Latest assignment per key, plus whether the query has resolved at least
|
|
129
|
+
// once (so a handle created before the first result knows to wait vs. fall
|
|
130
|
+
// back). `snapshots` only holds ASSIGNED keys; an absent key → fallback.
|
|
131
|
+
private snapshots = new Map<string, FeatureFlagSnapshot>();
|
|
132
|
+
private loaded = false;
|
|
133
|
+
// Developer-forced variants, this browser only. Take precedence over the
|
|
134
|
+
// server assignment for every read path.
|
|
135
|
+
private overrides = new Map<string, FeatureFlagOverride>();
|
|
136
|
+
|
|
137
|
+
constructor(private deps: FeatureFlagModuleDeps<S>) {
|
|
138
|
+
this.logger = deps.logger.child({ service: 'FeatureFlagModule' });
|
|
139
|
+
// Loaded in the constructor rather than `init()` so an override applies
|
|
140
|
+
// even when `client.feature()` is called before auth resolves.
|
|
141
|
+
this.loadOverrides();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
init(): void {
|
|
145
|
+
if (this.authUnsubscribe) return;
|
|
146
|
+
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
147
|
+
if (userId === this.lastUserId) return;
|
|
148
|
+
this.lastUserId = userId;
|
|
149
|
+
void this.refresh();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
feature(key: string, options: FeatureFlagOptions = {}): FeatureFlagHandle {
|
|
154
|
+
const handle = new FeatureFlagHandle(key, options.fallback);
|
|
155
|
+
this.handles.add(handle);
|
|
156
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
157
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
158
|
+
// If the shared query already resolved, seed this handle immediately so a
|
|
159
|
+
// late `feature()` call doesn't flash the fallback for an assigned key.
|
|
160
|
+
// An override seeds it too, even before the first result: forcing a
|
|
161
|
+
// variant should take effect instantly, not one round trip later.
|
|
162
|
+
if (this.loaded || this.overrides.has(key)) {
|
|
163
|
+
handle.set(this.resolve(key));
|
|
164
|
+
}
|
|
165
|
+
void this.ensureStarted();
|
|
166
|
+
return handle;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async closeAll(): Promise<void> {
|
|
170
|
+
this.authUnsubscribe?.();
|
|
171
|
+
this.authUnsubscribe = null;
|
|
172
|
+
this.teardownQuery();
|
|
173
|
+
for (const handle of [...this.handles]) handle.close();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Auth changed: drop the old user's query/snapshots and re-observe. */
|
|
177
|
+
private async refresh(): Promise<void> {
|
|
178
|
+
this.teardownQuery();
|
|
179
|
+
this.loaded = false;
|
|
180
|
+
this.snapshots.clear();
|
|
181
|
+
// Clear handles immediately so a sign-out hides flag-gated UI without lag.
|
|
182
|
+
// `resolve` keeps any local override in place across the switch — it is a
|
|
183
|
+
// developer setting for this browser, not part of the session.
|
|
184
|
+
for (const handle of this.handles) {
|
|
185
|
+
handle.set(this.resolve(handle.key));
|
|
186
|
+
}
|
|
187
|
+
await this.ensureStarted();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private teardownQuery(): void {
|
|
191
|
+
this.querySubscription?.();
|
|
192
|
+
this.querySubscription = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Start the single shared live query (idempotent; no-op with no handles). */
|
|
196
|
+
private async ensureStarted(): Promise<void> {
|
|
197
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
198
|
+
this.starting = true;
|
|
199
|
+
try {
|
|
200
|
+
const hash = await this.deps.dataModule.query(
|
|
201
|
+
'_00_user_feature' as any,
|
|
202
|
+
FEATURE_QUERY,
|
|
203
|
+
{},
|
|
204
|
+
this.ttl,
|
|
205
|
+
);
|
|
206
|
+
this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
207
|
+
this.querySubscription = this.deps.dataModule.subscribe(
|
|
208
|
+
hash,
|
|
209
|
+
(records) => this.applyRecords(records as FeatureRow[]),
|
|
210
|
+
{ immediate: true },
|
|
211
|
+
);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
this.logger.warn(
|
|
214
|
+
{ err, Category: 'sp00ky-client::FeatureFlagModule::register' },
|
|
215
|
+
'Failed to register feature flag query',
|
|
216
|
+
);
|
|
217
|
+
} finally {
|
|
218
|
+
this.starting = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Live query result → per-key snapshots → push to every active handle. */
|
|
223
|
+
private applyRecords(records: FeatureRow[]): void {
|
|
224
|
+
this.snapshots.clear();
|
|
225
|
+
for (const row of records ?? []) {
|
|
226
|
+
if (row && typeof row.key === 'string') {
|
|
227
|
+
this.snapshots.set(row.key, { variant: row.variant, payload: row.payload });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
this.loaded = true;
|
|
231
|
+
this.pushAll();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ===========================================================
|
|
235
|
+
// Local overrides (this browser only)
|
|
236
|
+
// ===========================================================
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Force `key` to `variant` in THIS browser. Pass `null` to clear.
|
|
240
|
+
*
|
|
241
|
+
* Nothing is written to the server: the `_00_user_feature` assignment is
|
|
242
|
+
* untouched, so clearing restores whatever the server says. Persisted to
|
|
243
|
+
* localStorage on the page origin, so it survives a reload.
|
|
244
|
+
*/
|
|
245
|
+
setLocalOverride(key: string, variant: string | null, payload?: unknown): void {
|
|
246
|
+
if (variant === null) this.overrides.delete(key);
|
|
247
|
+
else this.overrides.set(key, { variant, payload });
|
|
248
|
+
this.persistOverrides();
|
|
249
|
+
this.pushAll();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
clearLocalOverrides(): void {
|
|
253
|
+
this.overrides.clear();
|
|
254
|
+
this.persistOverrides();
|
|
255
|
+
this.pushAll();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
getLocalOverrides(): Record<string, FeatureFlagOverride> {
|
|
259
|
+
return Object.fromEntries(this.overrides);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The assignment for `key`, with any local override taking precedence. */
|
|
263
|
+
private resolve(key: string): FeatureFlagSnapshot {
|
|
264
|
+
const override = this.overrides.get(key);
|
|
265
|
+
if (override) return { variant: override.variant, payload: override.payload };
|
|
266
|
+
return this.snapshots.get(key) ?? { variant: undefined, payload: undefined };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private pushAll(): void {
|
|
270
|
+
for (const handle of this.handles) handle.set(this.resolve(handle.key));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private loadOverrides(): void {
|
|
274
|
+
try {
|
|
275
|
+
const raw = globalThis.localStorage?.getItem(OVERRIDE_STORAGE_KEY);
|
|
276
|
+
if (!raw) return;
|
|
277
|
+
const parsed = JSON.parse(raw) as Record<string, FeatureFlagOverride>;
|
|
278
|
+
for (const [key, value] of Object.entries(parsed ?? {})) {
|
|
279
|
+
if (value && typeof value.variant === 'string') this.overrides.set(key, value);
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// Best-effort: a corrupt or unavailable store must never stop the
|
|
283
|
+
// client from booting. Same posture as the DevTools prefs helper.
|
|
284
|
+
this.logger.warn(
|
|
285
|
+
{ err, Category: 'sp00ky-client::FeatureFlagModule::loadOverrides' },
|
|
286
|
+
'Failed to read local feature flag overrides',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private persistOverrides(): void {
|
|
292
|
+
try {
|
|
293
|
+
if (this.overrides.size === 0) {
|
|
294
|
+
globalThis.localStorage?.removeItem(OVERRIDE_STORAGE_KEY);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
globalThis.localStorage?.setItem(
|
|
298
|
+
OVERRIDE_STORAGE_KEY,
|
|
299
|
+
JSON.stringify(this.getLocalOverrides()),
|
|
300
|
+
);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this.logger.warn(
|
|
303
|
+
{ err, Category: 'sp00ky-client::FeatureFlagModule::persistOverrides' },
|
|
304
|
+
'Failed to persist local feature flag overrides',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|