@spooky-sync/core 0.0.1-canary.94 → 0.0.1-canary.96
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 +13 -3
- package/dist/index.js +56 -35
- package/package.json +3 -3
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +76 -33
package/dist/index.d.ts
CHANGED
|
@@ -1032,15 +1032,25 @@ interface FeatureFlagModuleDeps<S extends SchemaStructure> {
|
|
|
1032
1032
|
declare class FeatureFlagModule<S extends SchemaStructure> {
|
|
1033
1033
|
private deps;
|
|
1034
1034
|
private logger;
|
|
1035
|
-
private
|
|
1035
|
+
private handles;
|
|
1036
1036
|
private authUnsubscribe;
|
|
1037
1037
|
private lastUserId;
|
|
1038
|
+
private querySubscription;
|
|
1039
|
+
private starting;
|
|
1040
|
+
private ttl;
|
|
1041
|
+
private snapshots;
|
|
1042
|
+
private loaded;
|
|
1038
1043
|
constructor(deps: FeatureFlagModuleDeps<S>);
|
|
1039
1044
|
init(): void;
|
|
1040
1045
|
feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
|
|
1041
1046
|
closeAll(): Promise<void>;
|
|
1042
|
-
|
|
1043
|
-
private
|
|
1047
|
+
/** Auth changed: drop the old user's query/snapshots and re-observe. */
|
|
1048
|
+
private refresh;
|
|
1049
|
+
private teardownQuery;
|
|
1050
|
+
/** Start the single shared live query (idempotent; no-op with no handles). */
|
|
1051
|
+
private ensureStarted;
|
|
1052
|
+
/** Live query result → per-key snapshots → push to every active handle. */
|
|
1053
|
+
private applyRecords;
|
|
1044
1054
|
}
|
|
1045
1055
|
//#endregion
|
|
1046
1056
|
//#region src/sp00ky.d.ts
|
package/dist/index.js
CHANGED
|
@@ -3485,8 +3485,8 @@ function parseBackendInfo(raw) {
|
|
|
3485
3485
|
|
|
3486
3486
|
//#endregion
|
|
3487
3487
|
//#region src/modules/devtools/index.ts
|
|
3488
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
3489
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
3488
|
+
const CORE_VERSION = "0.0.1-canary.96";
|
|
3489
|
+
const WASM_VERSION = "0.0.1-canary.96";
|
|
3490
3490
|
const SURREAL_VERSION = "3.0.3";
|
|
3491
3491
|
var DevToolsService = class {
|
|
3492
3492
|
eventsHistory = [];
|
|
@@ -5018,7 +5018,7 @@ var CrdtManager = class {
|
|
|
5018
5018
|
|
|
5019
5019
|
//#endregion
|
|
5020
5020
|
//#region src/modules/feature-flag/index.ts
|
|
5021
|
-
const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature
|
|
5021
|
+
const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature";
|
|
5022
5022
|
var FeatureFlagHandle = class {
|
|
5023
5023
|
latest = {
|
|
5024
5024
|
variant: void 0,
|
|
@@ -5078,9 +5078,14 @@ var FeatureFlagHandle = class {
|
|
|
5078
5078
|
};
|
|
5079
5079
|
var FeatureFlagModule = class {
|
|
5080
5080
|
logger;
|
|
5081
|
-
|
|
5081
|
+
handles = /* @__PURE__ */ new Set();
|
|
5082
5082
|
authUnsubscribe = null;
|
|
5083
5083
|
lastUserId = null;
|
|
5084
|
+
querySubscription = null;
|
|
5085
|
+
starting = false;
|
|
5086
|
+
ttl = "10m";
|
|
5087
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
5088
|
+
loaded = false;
|
|
5084
5089
|
constructor(deps) {
|
|
5085
5090
|
this.deps = deps;
|
|
5086
5091
|
this.logger = deps.logger.child({ service: "FeatureFlagModule" });
|
|
@@ -5090,59 +5095,75 @@ var FeatureFlagModule = class {
|
|
|
5090
5095
|
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
5091
5096
|
if (userId === this.lastUserId) return;
|
|
5092
5097
|
this.lastUserId = userId;
|
|
5093
|
-
this.
|
|
5098
|
+
this.refresh();
|
|
5094
5099
|
});
|
|
5095
5100
|
}
|
|
5096
5101
|
feature(key, options = {}) {
|
|
5097
5102
|
const handle = new FeatureFlagHandle(key, options.fallback);
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5103
|
+
this.handles.add(handle);
|
|
5104
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
5105
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
5106
|
+
if (this.loaded) handle.set(this.snapshots.get(key) ?? {
|
|
5107
|
+
variant: void 0,
|
|
5108
|
+
payload: void 0
|
|
5109
|
+
});
|
|
5110
|
+
this.ensureStarted();
|
|
5105
5111
|
return handle;
|
|
5106
5112
|
}
|
|
5107
5113
|
async closeAll() {
|
|
5108
5114
|
this.authUnsubscribe?.();
|
|
5109
5115
|
this.authUnsubscribe = null;
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5116
|
+
this.teardownQuery();
|
|
5117
|
+
for (const handle of [...this.handles]) handle.close();
|
|
5118
|
+
}
|
|
5119
|
+
/** Auth changed: drop the old user's query/snapshots and re-observe. */
|
|
5120
|
+
async refresh() {
|
|
5121
|
+
this.teardownQuery();
|
|
5122
|
+
this.loaded = false;
|
|
5123
|
+
this.snapshots.clear();
|
|
5124
|
+
for (const handle of this.handles) handle.set({
|
|
5125
|
+
variant: void 0,
|
|
5126
|
+
payload: void 0
|
|
5127
|
+
});
|
|
5128
|
+
await this.ensureStarted();
|
|
5129
|
+
}
|
|
5130
|
+
teardownQuery() {
|
|
5131
|
+
this.querySubscription?.();
|
|
5132
|
+
this.querySubscription = null;
|
|
5121
5133
|
}
|
|
5122
|
-
|
|
5123
|
-
|
|
5134
|
+
/** Start the single shared live query (idempotent; no-op with no handles). */
|
|
5135
|
+
async ensureStarted() {
|
|
5136
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
5137
|
+
this.starting = true;
|
|
5124
5138
|
try {
|
|
5125
|
-
const hash = await this.deps.dataModule.query("_00_user_feature", FEATURE_QUERY, {
|
|
5139
|
+
const hash = await this.deps.dataModule.query("_00_user_feature", FEATURE_QUERY, {}, this.ttl);
|
|
5126
5140
|
this.deps.sync.enqueueDownEvent({
|
|
5127
5141
|
type: "register",
|
|
5128
5142
|
payload: { hash }
|
|
5129
5143
|
});
|
|
5130
|
-
|
|
5131
|
-
const row = records[0];
|
|
5132
|
-
handle.set({
|
|
5133
|
-
variant: row?.variant,
|
|
5134
|
-
payload: row?.payload
|
|
5135
|
-
});
|
|
5136
|
-
}, { immediate: true });
|
|
5137
|
-
handle.attach(unsub);
|
|
5144
|
+
this.querySubscription = this.deps.dataModule.subscribe(hash, (records) => this.applyRecords(records), { immediate: true });
|
|
5138
5145
|
} catch (err) {
|
|
5139
5146
|
this.logger.warn({
|
|
5140
5147
|
err,
|
|
5141
|
-
key: handle.key,
|
|
5142
5148
|
Category: "sp00ky-client::FeatureFlagModule::register"
|
|
5143
5149
|
}, "Failed to register feature flag query");
|
|
5150
|
+
} finally {
|
|
5151
|
+
this.starting = false;
|
|
5144
5152
|
}
|
|
5145
5153
|
}
|
|
5154
|
+
/** Live query result → per-key snapshots → push to every active handle. */
|
|
5155
|
+
applyRecords(records) {
|
|
5156
|
+
this.snapshots.clear();
|
|
5157
|
+
for (const row of records ?? []) if (row && typeof row.key === "string") this.snapshots.set(row.key, {
|
|
5158
|
+
variant: row.variant,
|
|
5159
|
+
payload: row.payload
|
|
5160
|
+
});
|
|
5161
|
+
this.loaded = true;
|
|
5162
|
+
for (const handle of this.handles) handle.set(this.snapshots.get(handle.key) ?? {
|
|
5163
|
+
variant: void 0,
|
|
5164
|
+
payload: void 0
|
|
5165
|
+
});
|
|
5166
|
+
}
|
|
5146
5167
|
};
|
|
5147
5168
|
|
|
5148
5169
|
//#endregion
|
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.96",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
62
|
+
"@spooky-sync/query-builder": "0.0.1-canary.96",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.96",
|
|
64
64
|
"@surrealdb/wasm": "^3.0.3",
|
|
65
65
|
"fast-json-patch": "^3.1.1",
|
|
66
66
|
"loro-crdt": "^1.5.6",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { FeatureFlagModule } from './index';
|
|
3
|
+
|
|
4
|
+
// Minimal mocks for the three deps the module touches. The DataModule mock
|
|
5
|
+
// captures the single subscribe callback so a test can push live results, and
|
|
6
|
+
// counts query() calls to assert the query is SHARED (one registration for all
|
|
7
|
+
// flags), not per-key.
|
|
8
|
+
function makeDeps() {
|
|
9
|
+
let subCb: ((records: unknown[]) => void) | null = null;
|
|
10
|
+
const calls: Array<{ sql: string; params: unknown }> = [];
|
|
11
|
+
let authCb: ((userId: string | null) => void) | null = null;
|
|
12
|
+
|
|
13
|
+
const dataModule = {
|
|
14
|
+
query: async (_table: string, sql: string, params: unknown) => {
|
|
15
|
+
calls.push({ sql, params });
|
|
16
|
+
return `hash:${calls.length}`;
|
|
17
|
+
},
|
|
18
|
+
subscribe: (_hash: string, cb: (records: unknown[]) => void) => {
|
|
19
|
+
subCb = cb;
|
|
20
|
+
return () => {
|
|
21
|
+
subCb = null;
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const sync = { enqueueDownEvent: () => {} };
|
|
26
|
+
const auth = {
|
|
27
|
+
subscribe: (cb: (userId: string | null) => void) => {
|
|
28
|
+
authCb = cb;
|
|
29
|
+
return () => {
|
|
30
|
+
authCb = null;
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const logger = { child: () => ({ warn: () => {} }) };
|
|
35
|
+
|
|
36
|
+
const deps = { dataModule, sync, auth, logger } as any;
|
|
37
|
+
return {
|
|
38
|
+
deps,
|
|
39
|
+
calls,
|
|
40
|
+
push: (records: unknown[]) => subCb?.(records),
|
|
41
|
+
setUser: (id: string | null) => authCb?.(id),
|
|
42
|
+
hasSub: () => subCb !== null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
47
|
+
|
|
48
|
+
describe('FeatureFlagModule', () => {
|
|
49
|
+
let env: ReturnType<typeof makeDeps>;
|
|
50
|
+
let mod: FeatureFlagModule<any>;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
env = makeDeps();
|
|
54
|
+
mod = new FeatureFlagModule(env.deps);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('registers ONE shared, unfiltered query for many flags', async () => {
|
|
58
|
+
mod.feature('alpha');
|
|
59
|
+
mod.feature('beta');
|
|
60
|
+
mod.feature('gamma');
|
|
61
|
+
await tick();
|
|
62
|
+
|
|
63
|
+
expect(env.calls.length).toBe(1);
|
|
64
|
+
expect(env.calls[0].sql).not.toContain('WHERE');
|
|
65
|
+
expect(env.calls[0].sql).toContain('FROM _00_user_feature');
|
|
66
|
+
expect(env.calls[0].params).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('fans the shared result out to each handle by key', async () => {
|
|
70
|
+
const alpha = mod.feature('alpha', { fallback: 'off' });
|
|
71
|
+
const beta = mod.feature('beta', { fallback: 'off' });
|
|
72
|
+
const missing = mod.feature('missing', { fallback: 'off' });
|
|
73
|
+
await tick();
|
|
74
|
+
|
|
75
|
+
env.push([
|
|
76
|
+
{ key: 'alpha', variant: 'on' },
|
|
77
|
+
{ key: 'beta', variant: 'off' },
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
expect(alpha.enabled()).toBe(true);
|
|
81
|
+
expect(alpha.variant()).toBe('on');
|
|
82
|
+
expect(beta.enabled()).toBe(false); // assigned 'off'
|
|
83
|
+
expect(missing.enabled()).toBe(false); // no row → fallback 'off'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('observes NEW assignments live without re-registering', async () => {
|
|
87
|
+
const flag = mod.feature('live', { fallback: 'off' });
|
|
88
|
+
await tick();
|
|
89
|
+
|
|
90
|
+
env.push([]); // user starts with no assignment
|
|
91
|
+
expect(flag.enabled()).toBe(false);
|
|
92
|
+
|
|
93
|
+
env.push([{ key: 'live', variant: 'on' }]); // assigned while observing
|
|
94
|
+
expect(flag.enabled()).toBe(true);
|
|
95
|
+
|
|
96
|
+
expect(env.calls.length).toBe(1); // still the same single query
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('seeds a late-created handle from the already-loaded snapshot', async () => {
|
|
100
|
+
mod.feature('alpha', { fallback: 'off' });
|
|
101
|
+
await tick();
|
|
102
|
+
env.push([{ key: 'alpha', variant: 'on' }]);
|
|
103
|
+
|
|
104
|
+
const late = mod.feature('alpha', { fallback: 'off' });
|
|
105
|
+
expect(late.enabled()).toBe(true); // no fallback flash
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('clears flags and re-observes on user change', async () => {
|
|
109
|
+
mod.init();
|
|
110
|
+
const flag = mod.feature('alpha', { fallback: 'off' });
|
|
111
|
+
await tick();
|
|
112
|
+
env.push([{ key: 'alpha', variant: 'on' }]);
|
|
113
|
+
expect(flag.enabled()).toBe(true);
|
|
114
|
+
|
|
115
|
+
env.setUser('user:other'); // sign-in as a different user
|
|
116
|
+
await tick();
|
|
117
|
+
expect(flag.enabled()).toBe(false); // cleared until the new query resolves
|
|
118
|
+
expect(env.hasSub()).toBe(true); // re-registered for the new user
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -5,8 +5,19 @@ import type { AuthService } from '../auth/index';
|
|
|
5
5
|
import type { Logger } from '../../services/logger/index';
|
|
6
6
|
import type { QueryTimeToLive } from '../../types';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
interface FeatureRow {
|
|
17
|
+
key?: string;
|
|
18
|
+
variant?: string;
|
|
19
|
+
payload?: unknown;
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
export interface FeatureFlagSnapshot {
|
|
12
23
|
variant: string | undefined;
|
|
@@ -87,17 +98,23 @@ export interface FeatureFlagModuleDeps<S extends SchemaStructure> {
|
|
|
87
98
|
logger: Logger;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
interface ActiveHandle {
|
|
91
|
-
handle: FeatureFlagHandle;
|
|
92
|
-
ttl: QueryTimeToLive;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
101
|
export class FeatureFlagModule<S extends SchemaStructure> {
|
|
96
102
|
private logger: Logger;
|
|
97
|
-
private
|
|
103
|
+
private handles = new Set<FeatureFlagHandle>();
|
|
98
104
|
private authUnsubscribe: (() => void) | null = null;
|
|
99
105
|
private lastUserId: string | null = null;
|
|
100
106
|
|
|
107
|
+
// The single shared live query over the user's assignments.
|
|
108
|
+
private querySubscription: (() => void) | null = null;
|
|
109
|
+
private starting = false;
|
|
110
|
+
// Longest TTL any caller asked for (the query is shared across all flags).
|
|
111
|
+
private ttl: QueryTimeToLive = '10m';
|
|
112
|
+
// Latest assignment per key, plus whether the query has resolved at least
|
|
113
|
+
// once (so a handle created before the first result knows to wait vs. fall
|
|
114
|
+
// back). `snapshots` only holds ASSIGNED keys; an absent key → fallback.
|
|
115
|
+
private snapshots = new Map<string, FeatureFlagSnapshot>();
|
|
116
|
+
private loaded = false;
|
|
117
|
+
|
|
101
118
|
constructor(private deps: FeatureFlagModuleDeps<S>) {
|
|
102
119
|
this.logger = deps.logger.child({ service: 'FeatureFlagModule' });
|
|
103
120
|
}
|
|
@@ -107,60 +124,86 @@ export class FeatureFlagModule<S extends SchemaStructure> {
|
|
|
107
124
|
this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
|
|
108
125
|
if (userId === this.lastUserId) return;
|
|
109
126
|
this.lastUserId = userId;
|
|
110
|
-
void this.
|
|
127
|
+
void this.refresh();
|
|
111
128
|
});
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
feature(key: string, options: FeatureFlagOptions = {}): FeatureFlagHandle {
|
|
115
132
|
const handle = new FeatureFlagHandle(key, options.fallback);
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
this.handles.add(handle);
|
|
134
|
+
handle.onClose(() => this.handles.delete(handle));
|
|
135
|
+
if (options.ttl) this.ttl = options.ttl;
|
|
136
|
+
// If the shared query already resolved, seed this handle immediately so a
|
|
137
|
+
// late `feature()` call doesn't flash the fallback for an assigned key.
|
|
138
|
+
if (this.loaded) {
|
|
139
|
+
handle.set(this.snapshots.get(key) ?? { variant: undefined, payload: undefined });
|
|
140
|
+
}
|
|
141
|
+
void this.ensureStarted();
|
|
120
142
|
return handle;
|
|
121
143
|
}
|
|
122
144
|
|
|
123
145
|
async closeAll(): Promise<void> {
|
|
124
146
|
this.authUnsubscribe?.();
|
|
125
147
|
this.authUnsubscribe = null;
|
|
126
|
-
|
|
148
|
+
this.teardownQuery();
|
|
149
|
+
for (const handle of [...this.handles]) handle.close();
|
|
127
150
|
}
|
|
128
151
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
/** Auth changed: drop the old user's query/snapshots and re-observe. */
|
|
153
|
+
private async refresh(): Promise<void> {
|
|
154
|
+
this.teardownQuery();
|
|
155
|
+
this.loaded = false;
|
|
156
|
+
this.snapshots.clear();
|
|
157
|
+
// Clear handles immediately so a sign-out hides flag-gated UI without lag.
|
|
158
|
+
for (const handle of this.handles) {
|
|
159
|
+
handle.set({ variant: undefined, payload: undefined });
|
|
134
160
|
}
|
|
161
|
+
await this.ensureStarted();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private teardownQuery(): void {
|
|
165
|
+
this.querySubscription?.();
|
|
166
|
+
this.querySubscription = null;
|
|
135
167
|
}
|
|
136
168
|
|
|
137
|
-
|
|
138
|
-
|
|
169
|
+
/** Start the single shared live query (idempotent; no-op with no handles). */
|
|
170
|
+
private async ensureStarted(): Promise<void> {
|
|
171
|
+
if (this.querySubscription || this.starting || this.handles.size === 0) return;
|
|
172
|
+
this.starting = true;
|
|
139
173
|
try {
|
|
140
174
|
const hash = await this.deps.dataModule.query(
|
|
141
175
|
'_00_user_feature' as any,
|
|
142
176
|
FEATURE_QUERY,
|
|
143
|
-
{
|
|
144
|
-
ttl,
|
|
177
|
+
{},
|
|
178
|
+
this.ttl,
|
|
145
179
|
);
|
|
146
|
-
|
|
147
180
|
this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
148
|
-
|
|
149
|
-
const unsub = this.deps.dataModule.subscribe(
|
|
181
|
+
this.querySubscription = this.deps.dataModule.subscribe(
|
|
150
182
|
hash,
|
|
151
|
-
(records) =>
|
|
152
|
-
const row = records[0] as { variant?: string; payload?: unknown } | undefined;
|
|
153
|
-
handle.set({ variant: row?.variant, payload: row?.payload });
|
|
154
|
-
},
|
|
183
|
+
(records) => this.applyRecords(records as FeatureRow[]),
|
|
155
184
|
{ immediate: true },
|
|
156
185
|
);
|
|
157
|
-
|
|
158
|
-
handle.attach(unsub);
|
|
159
186
|
} catch (err) {
|
|
160
187
|
this.logger.warn(
|
|
161
|
-
{ err,
|
|
188
|
+
{ err, Category: 'sp00ky-client::FeatureFlagModule::register' },
|
|
162
189
|
'Failed to register feature flag query',
|
|
163
190
|
);
|
|
191
|
+
} finally {
|
|
192
|
+
this.starting = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Live query result → per-key snapshots → push to every active handle. */
|
|
197
|
+
private applyRecords(records: FeatureRow[]): void {
|
|
198
|
+
this.snapshots.clear();
|
|
199
|
+
for (const row of records ?? []) {
|
|
200
|
+
if (row && typeof row.key === 'string') {
|
|
201
|
+
this.snapshots.set(row.key, { variant: row.variant, payload: row.payload });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
this.loaded = true;
|
|
205
|
+
for (const handle of this.handles) {
|
|
206
|
+
handle.set(this.snapshots.get(handle.key) ?? { variant: undefined, payload: undefined });
|
|
164
207
|
}
|
|
165
208
|
}
|
|
166
209
|
}
|