@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 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 active;
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
- private refreshAll;
1043
- private register;
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.94";
3489
- const WASM_VERSION = "0.0.1-canary.94";
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 WHERE key = $key";
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
- active = /* @__PURE__ */ new Set();
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.refreshAll();
5098
+ this.refresh();
5094
5099
  });
5095
5100
  }
5096
5101
  feature(key, options = {}) {
5097
5102
  const handle = new FeatureFlagHandle(key, options.fallback);
5098
- const entry = {
5099
- handle,
5100
- ttl: options.ttl ?? "10m"
5101
- };
5102
- this.active.add(entry);
5103
- handle.onClose(() => this.active.delete(entry));
5104
- this.register(entry);
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
- for (const entry of [...this.active]) entry.handle.close();
5111
- }
5112
- async refreshAll() {
5113
- for (const entry of this.active) {
5114
- entry.handle.detach();
5115
- entry.handle.set({
5116
- variant: void 0,
5117
- payload: void 0
5118
- });
5119
- await this.register(entry);
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
- async register(entry) {
5123
- const { handle, ttl } = entry;
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, { key: handle.key }, ttl);
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
- const unsub = this.deps.dataModule.subscribe(hash, (records) => {
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.94",
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.94",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.94",
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
- const FEATURE_QUERY =
9
- 'SELECT key, variant, payload FROM _00_user_feature WHERE key = $key';
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 active = new Set<ActiveHandle>();
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.refreshAll();
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
- const entry: ActiveHandle = { handle, ttl: options.ttl ?? '10m' };
117
- this.active.add(entry);
118
- handle.onClose(() => this.active.delete(entry));
119
- void this.register(entry);
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
- for (const entry of [...this.active]) entry.handle.close();
148
+ this.teardownQuery();
149
+ for (const handle of [...this.handles]) handle.close();
127
150
  }
128
151
 
129
- private async refreshAll(): Promise<void> {
130
- for (const entry of this.active) {
131
- entry.handle.detach();
132
- entry.handle.set({ variant: undefined, payload: undefined });
133
- await this.register(entry);
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
- private async register(entry: ActiveHandle): Promise<void> {
138
- const { handle, ttl } = entry;
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
- { key: handle.key },
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, key: handle.key, Category: 'sp00ky-client::FeatureFlagModule::register' },
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
  }