@spooky-sync/core 0.0.1-canary.167 → 0.0.1-canary.169

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
@@ -1636,6 +1636,15 @@ interface FeatureFlagOptions {
1636
1636
  fallback?: string;
1637
1637
  ttl?: QueryTimeToLive;
1638
1638
  }
1639
+ /**
1640
+ * A locally forced variant. Applies to THIS browser only and is never sent to
1641
+ * the server — the assignment in `_00_user_feature` is untouched, so clearing
1642
+ * the override restores whatever the server says.
1643
+ */
1644
+ interface FeatureFlagOverride {
1645
+ variant: string;
1646
+ payload?: unknown;
1647
+ }
1639
1648
  declare class FeatureFlagHandle {
1640
1649
  readonly key: string;
1641
1650
  readonly fallback: string | undefined;
@@ -1672,6 +1681,7 @@ declare class FeatureFlagModule<S extends SchemaStructure> {
1672
1681
  private ttl;
1673
1682
  private snapshots;
1674
1683
  private loaded;
1684
+ private overrides;
1675
1685
  constructor(deps: FeatureFlagModuleDeps<S>);
1676
1686
  init(): void;
1677
1687
  feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
@@ -1683,6 +1693,21 @@ declare class FeatureFlagModule<S extends SchemaStructure> {
1683
1693
  private ensureStarted;
1684
1694
  /** Live query result → per-key snapshots → push to every active handle. */
1685
1695
  private applyRecords;
1696
+ /**
1697
+ * Force `key` to `variant` in THIS browser. Pass `null` to clear.
1698
+ *
1699
+ * Nothing is written to the server: the `_00_user_feature` assignment is
1700
+ * untouched, so clearing restores whatever the server says. Persisted to
1701
+ * localStorage on the page origin, so it survives a reload.
1702
+ */
1703
+ setLocalOverride(key: string, variant: string | null, payload?: unknown): void;
1704
+ clearLocalOverrides(): void;
1705
+ getLocalOverrides(): Record<string, FeatureFlagOverride>;
1706
+ /** The assignment for `key`, with any local override taking precedence. */
1707
+ private resolve;
1708
+ private pushAll;
1709
+ private loadOverrides;
1710
+ private persistOverrides;
1686
1711
  }
1687
1712
  //#endregion
1688
1713
  //#region src/modules/app-release/index.d.ts
@@ -2126,6 +2151,22 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2126
2151
  * its own row, and cannot create or modify assignments.
2127
2152
  */
2128
2153
  feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle;
2154
+ /**
2155
+ * Force a feature flag to `variant` in THIS browser only; `null` clears it.
2156
+ *
2157
+ * Nothing is sent to the server — the `_00_user_feature` assignment is
2158
+ * untouched, so clearing restores whatever the server says. Persisted to
2159
+ * localStorage, survives reloads, and applies while signed out. Backs the
2160
+ * DevTools Flags tab, and is a convenient hook for tests.
2161
+ *
2162
+ * To change a flag for OTHER users you need admin rights (`spky admin add`)
2163
+ * and the DevTools Flags tab, or `spky flag`.
2164
+ */
2165
+ setFeatureOverride(key: string, variant: string | null, payload?: unknown): void;
2166
+ /** Drop every local feature flag override set via `setFeatureOverride`. */
2167
+ clearFeatureOverrides(): void;
2168
+ /** The local feature flag overrides currently in effect, keyed by flag. */
2169
+ getFeatureOverrides(): Record<string, FeatureFlagOverride>;
2129
2170
  /**
2130
2171
  * Observe the announced release of an app (`_00_app_release:<app>`, written
2131
2172
  * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
@@ -2248,4 +2289,4 @@ declare function textToHtml(text: string): string;
2248
2289
  */
2249
2290
 
2250
2291
  //#endregion
2251
- export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, BucketHandle, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
2292
+ export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, BucketHandle, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
package/dist/index.js CHANGED
@@ -6541,10 +6541,196 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
6541
6541
  }
6542
6542
  }
6543
6543
 
6544
+ //#endregion
6545
+ //#region src/modules/devtools/flags.ts
6546
+ /**
6547
+ * SurrealDB's SDK returns one entry per statement; each entry is the rows for
6548
+ * that statement, but older/local shapes wrap them as `{ status, result }`.
6549
+ * `getTableData` in `index.ts` unwraps the same three shapes inline — this is
6550
+ * that logic, reusable.
6551
+ */
6552
+ function statementRows(result, index = 0) {
6553
+ if (!Array.isArray(result)) return [];
6554
+ const entry = result[index];
6555
+ if (Array.isArray(entry)) return entry;
6556
+ if (entry && typeof entry === "object" && "result" in entry) {
6557
+ const inner = entry.result;
6558
+ return Array.isArray(inner) ? inner : inner === void 0 || inner === null ? [] : [inner];
6559
+ }
6560
+ return entry === void 0 || entry === null ? [] : [entry];
6561
+ }
6562
+ function message(err) {
6563
+ return err instanceof Error ? err.message : String(err);
6564
+ }
6565
+ /** Row shape guards. `key` is the only field the panel truly can't do without. */
6566
+ function hasStringKey(row) {
6567
+ return !!row && typeof row === "object" && typeof row.key === "string";
6568
+ }
6569
+ function isAssignment(row) {
6570
+ return hasStringKey(row);
6571
+ }
6572
+ function isFlagRow(row) {
6573
+ return hasStringKey(row);
6574
+ }
6575
+ var FlagsAdminService = class {
6576
+ constructor(deps) {
6577
+ this.deps = deps;
6578
+ }
6579
+ /**
6580
+ * Everything the Flags tab renders, in one round trip per source.
6581
+ *
6582
+ * Each section fails independently: a remote read that throws downgrades to
6583
+ * `isAdmin: false` plus an `error`, while local assignments and overrides
6584
+ * still render. Signing out must not blank the whole tab.
6585
+ */
6586
+ async getFlags() {
6587
+ const userId = this.deps.currentUserId();
6588
+ const snapshot = {
6589
+ at: Date.now(),
6590
+ userId,
6591
+ isAdmin: false,
6592
+ flags: [],
6593
+ assignments: [],
6594
+ overrides: this.deps.overrides()?.getLocalOverrides() ?? {}
6595
+ };
6596
+ try {
6597
+ snapshot.assignments = statementRows(await this.deps.local.query("SELECT key, variant, payload FROM _00_user_feature")).filter(isAssignment).map((r) => ({
6598
+ key: r.key,
6599
+ variant: r.variant,
6600
+ payload: r.payload
6601
+ }));
6602
+ } catch (err) {
6603
+ this.deps.logger.debug({
6604
+ err,
6605
+ Category: "sp00ky-client::FlagsAdminService::getFlags"
6606
+ }, "Local feature assignments unavailable");
6607
+ }
6608
+ if (!userId) return snapshot;
6609
+ try {
6610
+ snapshot.isAdmin = statementRows(await this.deps.remote.query("SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1")).length > 0;
6611
+ } catch (err) {
6612
+ snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Flags tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
6613
+ return snapshot;
6614
+ }
6615
+ if (!snapshot.isAdmin) return snapshot;
6616
+ try {
6617
+ snapshot.flags = statementRows(await this.deps.remote.query("SELECT key, description, variants, default_variant, enabled, payloads, rules, updated_at FROM _00_feature_flag ORDER BY key ASC")).filter(isFlagRow).map((flag) => ({
6618
+ ...flag,
6619
+ rules: Array.isArray(flag.rules) ? flag.rules : [],
6620
+ variants: Array.isArray(flag.variants) ? flag.variants : [],
6621
+ selfAllowlistedVariant: selfAllowlistedVariant(flag, userId)
6622
+ }));
6623
+ } catch (err) {
6624
+ snapshot.error = `Could not read feature flags: ${message(err)}`;
6625
+ }
6626
+ return snapshot;
6627
+ }
6628
+ /**
6629
+ * Flip a flag's global `enabled` bit for EVERY user, then re-materialize.
6630
+ *
6631
+ * Both statements are one request so they share a transaction: if the
6632
+ * materialize fails, the `enabled` change rolls back rather than leaving the
6633
+ * definition and the assignments disagreeing.
6634
+ */
6635
+ async setFlagEnabled(key, enabled) {
6636
+ return this.mutate("UPDATE _00_feature_flag SET enabled = $enabled WHERE key = $key; RETURN fn::feature::materialize($key);", {
6637
+ key,
6638
+ enabled
6639
+ }, 1);
6640
+ }
6641
+ /**
6642
+ * Add or remove a user from `$key`'s allowlist for `$variant`, then
6643
+ * re-materialize. Defaults to the signed-in user, so the common case
6644
+ * ("turn this on for me, for real") needs no user picker.
6645
+ */
6646
+ async setFlagUserVariant(key, variant, remove, userId) {
6647
+ const target = userId ?? this.deps.currentUserId();
6648
+ if (!target) return {
6649
+ success: false,
6650
+ error: "Not signed in"
6651
+ };
6652
+ let user;
6653
+ try {
6654
+ user = parseRecordIdString(target);
6655
+ } catch (err) {
6656
+ return {
6657
+ success: false,
6658
+ error: `Invalid user id '${target}': ${message(err)}`
6659
+ };
6660
+ }
6661
+ return remove ? this.mutate("RETURN fn::feature::disallow($key, $user);", {
6662
+ key,
6663
+ user
6664
+ }, 0) : this.mutate("RETURN fn::feature::allow($key, $variant, $user);", {
6665
+ key,
6666
+ variant,
6667
+ user
6668
+ }, 0);
6669
+ }
6670
+ setLocalFlagOverride(key, variant, payload) {
6671
+ const store = this.deps.overrides();
6672
+ store?.setLocalOverride(key, variant, payload);
6673
+ return { overrides: store?.getLocalOverrides() ?? {} };
6674
+ }
6675
+ clearLocalFlagOverrides() {
6676
+ const store = this.deps.overrides();
6677
+ store?.clearLocalOverrides();
6678
+ return { overrides: store?.getLocalOverrides() ?? {} };
6679
+ }
6680
+ /**
6681
+ * Run a remote mutation, reporting the materialize count from `$index`.
6682
+ *
6683
+ * Retries on a transaction conflict. `fn::feature::allow` / `disallow` are
6684
+ * read-modify-write over `_00_feature_flag.rules`, so two admins acting on
6685
+ * the same flag at once collide. SurrealDB detects this and fails the loser
6686
+ * with "Transaction conflict ... can be retried" rather than losing the
6687
+ * write — verified against 3.1 — so nothing is silently dropped. Retrying
6688
+ * turns that into the outcome the user expected instead of a raw engine
6689
+ * error they can do nothing with.
6690
+ */
6691
+ async mutate(sql, vars, index) {
6692
+ let lastError;
6693
+ for (let attempt = 0; attempt < MUTATE_ATTEMPTS; attempt++) try {
6694
+ return {
6695
+ success: true,
6696
+ users: statementRows(await this.deps.remote.query(sql, vars), index)[0]?.users
6697
+ };
6698
+ } catch (err) {
6699
+ lastError = err;
6700
+ if (!isRetryableConflict(err) || attempt === MUTATE_ATTEMPTS - 1) break;
6701
+ await sleep(40 * (attempt + 1) + Math.random() * 40);
6702
+ }
6703
+ this.deps.logger.warn({
6704
+ err: lastError,
6705
+ sql,
6706
+ Category: "sp00ky-client::FlagsAdminService::mutate"
6707
+ }, "Feature flag mutation failed");
6708
+ return {
6709
+ success: false,
6710
+ error: message(lastError)
6711
+ };
6712
+ }
6713
+ };
6714
+ const MUTATE_ATTEMPTS = 3;
6715
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6716
+ function isRetryableConflict(err) {
6717
+ return /transaction (write )?conflict/i.test(message(err));
6718
+ }
6719
+ /**
6720
+ * Which variant, if any, this user is explicitly allowlisted into.
6721
+ *
6722
+ * `rules[].users` holds record-id strings (`flag.rs` serialises them as JSON),
6723
+ * so compare as strings. Lets the panel show "you're on the list" without
6724
+ * making the user reason about a raw rules blob.
6725
+ */
6726
+ function selfAllowlistedVariant(flag, userId) {
6727
+ return (Array.isArray(flag.rules) ? flag.rules : []).find((rule) => rule?.kind === "allowlist" && Array.isArray(rule.users) && rule.users.some((u) => String(u) === userId))?.variant;
6728
+ }
6729
+
6544
6730
  //#endregion
6545
6731
  //#region src/modules/devtools/index.ts
6546
- const CORE_VERSION = "0.0.1-canary.167";
6547
- const WASM_VERSION = "0.0.1-canary.167";
6732
+ const CORE_VERSION = "0.0.1-canary.169";
6733
+ const WASM_VERSION = "0.0.1-canary.169";
6548
6734
  const SURREAL_VERSION = "3.0.3";
6549
6735
  var DevToolsService = class DevToolsService {
6550
6736
  eventsHistory = [];
@@ -6569,6 +6755,8 @@ var DevToolsService = class DevToolsService {
6569
6755
  localTables = [];
6570
6756
  localTablesFetching = false;
6571
6757
  localTablesAt = 0;
6758
+ featureOverrides = null;
6759
+ flagsAdmin;
6572
6760
  constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
6573
6761
  this.databaseService = databaseService;
6574
6762
  this.remoteDatabaseService = remoteDatabaseService;
@@ -6576,6 +6764,17 @@ var DevToolsService = class DevToolsService {
6576
6764
  this.schema = schema;
6577
6765
  this.authService = authService;
6578
6766
  this.dataManager = dataManager;
6767
+ this.flagsAdmin = new FlagsAdminService({
6768
+ remote: this.remoteDatabaseService,
6769
+ local: this.databaseService,
6770
+ logger: this.logger,
6771
+ currentUserId: () => {
6772
+ const id = this.authService.currentUser?.id;
6773
+ if (!id) return null;
6774
+ return id instanceof RecordId ? encodeRecordId(id) : String(id);
6775
+ },
6776
+ overrides: () => this.featureOverrides
6777
+ });
6579
6778
  this.exposeToWindow();
6580
6779
  if (typeof window !== "undefined") window.addEventListener("message", (e) => {
6581
6780
  if (e.source !== window) return;
@@ -6888,11 +7087,24 @@ var DevToolsService = class DevToolsService {
6888
7087
  }
6889
7088
  return data;
6890
7089
  }
7090
+ /**
7091
+ * Hand the FeatureFlagModule to the Flags tab so it can read and write local
7092
+ * overrides. Called from `Sp00kyClient` once both are constructed; until then
7093
+ * the override methods are no-ops that report an empty map.
7094
+ */
7095
+ setFeatureFlagOverrides(store) {
7096
+ this.featureOverrides = store;
7097
+ }
6891
7098
  exposeToWindow() {
6892
7099
  if (typeof window !== "undefined") {
6893
7100
  window.__00__ = {
6894
7101
  version: this.version,
6895
7102
  getState: () => this.getState(),
7103
+ getFlags: () => this.flagsAdmin.getFlags(),
7104
+ setFlagEnabled: (key, enabled) => this.flagsAdmin.setFlagEnabled(key, enabled),
7105
+ setFlagUserVariant: (key, variant, remove, userId) => this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
7106
+ setLocalFlagOverride: (key, variant, payload) => this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
7107
+ clearLocalFlagOverrides: () => this.flagsAdmin.clearLocalFlagOverrides(),
6896
7108
  clearHistory: () => {
6897
7109
  this.eventsHistory = [];
6898
7110
  this.notifyDevTools();
@@ -8504,6 +8716,7 @@ var CrdtManager = class {
8504
8716
  //#endregion
8505
8717
  //#region src/modules/feature-flag/index.ts
8506
8718
  const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature";
8719
+ const OVERRIDE_STORAGE_KEY = "sp00ky:feature-overrides";
8507
8720
  var FeatureFlagHandle = class {
8508
8721
  latest = {
8509
8722
  variant: void 0,
@@ -8571,9 +8784,11 @@ var FeatureFlagModule = class {
8571
8784
  ttl = "10m";
8572
8785
  snapshots = /* @__PURE__ */ new Map();
8573
8786
  loaded = false;
8787
+ overrides = /* @__PURE__ */ new Map();
8574
8788
  constructor(deps) {
8575
8789
  this.deps = deps;
8576
8790
  this.logger = deps.logger.child({ service: "FeatureFlagModule" });
8791
+ this.loadOverrides();
8577
8792
  }
8578
8793
  init() {
8579
8794
  if (this.authUnsubscribe) return;
@@ -8588,10 +8803,7 @@ var FeatureFlagModule = class {
8588
8803
  this.handles.add(handle);
8589
8804
  handle.onClose(() => this.handles.delete(handle));
8590
8805
  if (options.ttl) this.ttl = options.ttl;
8591
- if (this.loaded) handle.set(this.snapshots.get(key) ?? {
8592
- variant: void 0,
8593
- payload: void 0
8594
- });
8806
+ if (this.loaded || this.overrides.has(key)) handle.set(this.resolve(key));
8595
8807
  this.ensureStarted();
8596
8808
  return handle;
8597
8809
  }
@@ -8606,10 +8818,7 @@ var FeatureFlagModule = class {
8606
8818
  this.teardownQuery();
8607
8819
  this.loaded = false;
8608
8820
  this.snapshots.clear();
8609
- for (const handle of this.handles) handle.set({
8610
- variant: void 0,
8611
- payload: void 0
8612
- });
8821
+ for (const handle of this.handles) handle.set(this.resolve(handle.key));
8613
8822
  await this.ensureStarted();
8614
8823
  }
8615
8824
  teardownQuery() {
@@ -8644,10 +8853,73 @@ var FeatureFlagModule = class {
8644
8853
  payload: row.payload
8645
8854
  });
8646
8855
  this.loaded = true;
8647
- for (const handle of this.handles) handle.set(this.snapshots.get(handle.key) ?? {
8856
+ this.pushAll();
8857
+ }
8858
+ /**
8859
+ * Force `key` to `variant` in THIS browser. Pass `null` to clear.
8860
+ *
8861
+ * Nothing is written to the server: the `_00_user_feature` assignment is
8862
+ * untouched, so clearing restores whatever the server says. Persisted to
8863
+ * localStorage on the page origin, so it survives a reload.
8864
+ */
8865
+ setLocalOverride(key, variant, payload) {
8866
+ if (variant === null) this.overrides.delete(key);
8867
+ else this.overrides.set(key, {
8868
+ variant,
8869
+ payload
8870
+ });
8871
+ this.persistOverrides();
8872
+ this.pushAll();
8873
+ }
8874
+ clearLocalOverrides() {
8875
+ this.overrides.clear();
8876
+ this.persistOverrides();
8877
+ this.pushAll();
8878
+ }
8879
+ getLocalOverrides() {
8880
+ return Object.fromEntries(this.overrides);
8881
+ }
8882
+ /** The assignment for `key`, with any local override taking precedence. */
8883
+ resolve(key) {
8884
+ const override = this.overrides.get(key);
8885
+ if (override) return {
8886
+ variant: override.variant,
8887
+ payload: override.payload
8888
+ };
8889
+ return this.snapshots.get(key) ?? {
8648
8890
  variant: void 0,
8649
8891
  payload: void 0
8650
- });
8892
+ };
8893
+ }
8894
+ pushAll() {
8895
+ for (const handle of this.handles) handle.set(this.resolve(handle.key));
8896
+ }
8897
+ loadOverrides() {
8898
+ try {
8899
+ const raw = globalThis.localStorage?.getItem(OVERRIDE_STORAGE_KEY);
8900
+ if (!raw) return;
8901
+ const parsed = JSON.parse(raw);
8902
+ for (const [key, value] of Object.entries(parsed ?? {})) if (value && typeof value.variant === "string") this.overrides.set(key, value);
8903
+ } catch (err) {
8904
+ this.logger.warn({
8905
+ err,
8906
+ Category: "sp00ky-client::FeatureFlagModule::loadOverrides"
8907
+ }, "Failed to read local feature flag overrides");
8908
+ }
8909
+ }
8910
+ persistOverrides() {
8911
+ try {
8912
+ if (this.overrides.size === 0) {
8913
+ globalThis.localStorage?.removeItem(OVERRIDE_STORAGE_KEY);
8914
+ return;
8915
+ }
8916
+ globalThis.localStorage?.setItem(OVERRIDE_STORAGE_KEY, JSON.stringify(this.getLocalOverrides()));
8917
+ } catch (err) {
8918
+ this.logger.warn({
8919
+ err,
8920
+ Category: "sp00ky-client::FeatureFlagModule::persistOverrides"
8921
+ }, "Failed to persist local feature flag overrides");
8922
+ }
8651
8923
  }
8652
8924
  };
8653
8925
 
@@ -10849,6 +11121,7 @@ var Sp00kyClient = class {
10849
11121
  logger
10850
11122
  });
10851
11123
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
11124
+ this.devTools.setFeatureFlagOverrides(this.featureFlags);
10852
11125
  this.streamProcessor.addReceiver(this.devTools);
10853
11126
  this.setupCallbacks();
10854
11127
  if (tabsSupport.supported) this.tabsCoordinator = this.buildTabsCoordinator();
@@ -10887,7 +11160,7 @@ var Sp00kyClient = class {
10887
11160
  return new TabsCoordinator({
10888
11161
  tabId,
10889
11162
  fingerprint: computeTabsFingerprint({
10890
- coreVersion: "0.0.1-canary.167",
11163
+ coreVersion: "0.0.1-canary.169",
10891
11164
  schemaHash: hash53(this.config.schemaSurql),
10892
11165
  endpoint: this.config.database.endpoint ?? "",
10893
11166
  namespace: this.config.database.namespace,
@@ -11228,6 +11501,28 @@ var Sp00kyClient = class {
11228
11501
  return this.featureFlags.feature(key, options);
11229
11502
  }
11230
11503
  /**
11504
+ * Force a feature flag to `variant` in THIS browser only; `null` clears it.
11505
+ *
11506
+ * Nothing is sent to the server — the `_00_user_feature` assignment is
11507
+ * untouched, so clearing restores whatever the server says. Persisted to
11508
+ * localStorage, survives reloads, and applies while signed out. Backs the
11509
+ * DevTools Flags tab, and is a convenient hook for tests.
11510
+ *
11511
+ * To change a flag for OTHER users you need admin rights (`spky admin add`)
11512
+ * and the DevTools Flags tab, or `spky flag`.
11513
+ */
11514
+ setFeatureOverride(key, variant, payload) {
11515
+ this.featureFlags.setLocalOverride(key, variant, payload);
11516
+ }
11517
+ /** Drop every local feature flag override set via `setFeatureOverride`. */
11518
+ clearFeatureOverrides() {
11519
+ this.featureFlags.clearLocalOverrides();
11520
+ }
11521
+ /** The local feature flag overrides currently in effect, keyed by flag. */
11522
+ getFeatureOverrides() {
11523
+ return this.featureFlags.getLocalOverrides();
11524
+ }
11525
+ /**
11231
11526
  * Observe the announced release of an app (`_00_app_release:<app>`, written
11232
11527
  * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
11233
11528
  * announced version plus the cache-bust/mandatory flags, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.167",
3
+ "version": "0.0.1-canary.169",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.167",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.167",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.169",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.169",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  FeatureFlagModule,
7
7
  FeatureFlagHandle,
8
8
  type FeatureFlagOptions,
9
+ type FeatureFlagOverride,
9
10
  type FeatureFlagSnapshot,
10
11
  } from './modules/feature-flag/index';
11
12
  export {
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Feature flag administration for the DevTools panel.
3
+ *
4
+ * Two independent capabilities, deliberately kept apart:
5
+ *
6
+ * - **Local overrides** force a variant in THIS browser. No auth, no network,
7
+ * works signed out and offline. Delegated straight to `FeatureFlagModule`.
8
+ * - **Remote changes** flip a flag for EVERY user. These require the caller to
9
+ * be listed in `_00_admin` (`spky admin add <user>`); SurrealDB enforces
10
+ * that, not this file. A non-admin's SELECT returns `[]` rather than an
11
+ * error, and the `fn::feature::*` calls are hard-denied.
12
+ *
13
+ * Everything remote goes through `RemoteDatabaseService` with BOUND params.
14
+ * The panel's generic `runQuery` bridge is not usable here: it interpolates
15
+ * the SurrealQL into an `inspectedWindow.eval` string (so a DB-sourced flag
16
+ * key or record id would be concatenated into code) and it rejects after 10s,
17
+ * which a materialize over a real user table will exceed.
18
+ *
19
+ * Definitions live in `_00_feature_flag`, which is NOT synced to clients, so
20
+ * flags must be read remotely. Per-user assignments live in `_00_user_feature`,
21
+ * which IS live-synced, so those come from the local store for free.
22
+ */
23
+
24
+ import type { Logger } from '../../services/logger/index';
25
+ import { parseRecordIdString } from '../../utils/index';
26
+
27
+ /**
28
+ * The only thing this service needs from a database. Structural rather than
29
+ * `AbstractDatabaseService`, because the local side is a `LocalStore` (which
30
+ * wraps a database rather than extending it) and both must satisfy it.
31
+ */
32
+ export interface FlagQueryable {
33
+ query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
34
+ }
35
+
36
+ /** A targeting rule as written by `spky flag` / `fn::feature::allow`. */
37
+ export interface DevToolsFlagRule {
38
+ kind: 'allowlist' | 'rollout' | string;
39
+ variant: string;
40
+ /** Allowlist only. Stored as record-id STRINGS, not records. */
41
+ users?: string[];
42
+ /** Rollout only, 0..100. */
43
+ percent?: number;
44
+ priority: number;
45
+ }
46
+
47
+ export interface DevToolsFlagRow {
48
+ key: string;
49
+ description?: string;
50
+ variants: string[];
51
+ default_variant: string;
52
+ enabled: boolean;
53
+ payloads?: Record<string, unknown>;
54
+ rules: DevToolsFlagRule[];
55
+ updated_at?: string;
56
+ /** Derived: the variant the signed-in user is allowlisted into, if any. */
57
+ selfAllowlistedVariant?: string;
58
+ }
59
+
60
+ export interface DevToolsFlagAssignment {
61
+ key: string;
62
+ variant: string;
63
+ payload?: unknown;
64
+ }
65
+
66
+ export interface DevToolsFlagOverride {
67
+ variant: string;
68
+ payload?: unknown;
69
+ }
70
+
71
+ export interface DevToolsFlagsSnapshot {
72
+ at: number;
73
+ /** Null when signed out. */
74
+ userId: string | null;
75
+ /** True when `_00_admin` has a row for the signed-in user. */
76
+ isAdmin: boolean;
77
+ /** Empty for non-admins — SurrealDB filters the rows, it does not error. */
78
+ flags: DevToolsFlagRow[];
79
+ /** From the LOCAL `_00_user_feature`: what this browser actually resolves. */
80
+ assignments: DevToolsFlagAssignment[];
81
+ /** Local-only forced variants, page-origin localStorage. */
82
+ overrides: Record<string, DevToolsFlagOverride>;
83
+ /**
84
+ * Set when the internal schema predates this feature (no `_00_admin` table),
85
+ * or the remote read failed. The panel shows it instead of a bare empty
86
+ * state, so "not migrated" doesn't look like "you're not an admin".
87
+ */
88
+ error?: string;
89
+ }
90
+
91
+ export interface DevToolsFlagResult {
92
+ success: boolean;
93
+ error?: string;
94
+ /** Users re-evaluated by `fn::feature::materialize`. */
95
+ users?: number;
96
+ }
97
+
98
+ /** The slice of `FeatureFlagModule` this service needs. */
99
+ export interface LocalOverrideStore {
100
+ setLocalOverride(key: string, variant: string | null, payload?: unknown): void;
101
+ clearLocalOverrides(): void;
102
+ getLocalOverrides(): Record<string, DevToolsFlagOverride>;
103
+ }
104
+
105
+ export interface FlagsAdminDeps {
106
+ remote: FlagQueryable;
107
+ local: FlagQueryable;
108
+ logger: Logger;
109
+ /** Resolves the signed-in user's record id, or null. */
110
+ currentUserId: () => string | null;
111
+ /** Set once the client has built its FeatureFlagModule. */
112
+ overrides: () => LocalOverrideStore | null;
113
+ }
114
+
115
+ /**
116
+ * SurrealDB's SDK returns one entry per statement; each entry is the rows for
117
+ * that statement, but older/local shapes wrap them as `{ status, result }`.
118
+ * `getTableData` in `index.ts` unwraps the same three shapes inline — this is
119
+ * that logic, reusable.
120
+ */
121
+ function statementRows(result: unknown, index = 0): unknown[] {
122
+ if (!Array.isArray(result)) return [];
123
+ const entry = result[index];
124
+ if (Array.isArray(entry)) return entry;
125
+ if (entry && typeof entry === 'object' && 'result' in entry) {
126
+ const inner = (entry as { result?: unknown }).result;
127
+ return Array.isArray(inner) ? inner : inner === undefined || inner === null ? [] : [inner];
128
+ }
129
+ return entry === undefined || entry === null ? [] : [entry];
130
+ }
131
+
132
+ function message(err: unknown): string {
133
+ return err instanceof Error ? err.message : String(err);
134
+ }
135
+
136
+ /** Row shape guards. `key` is the only field the panel truly can't do without. */
137
+ function hasStringKey(row: unknown): row is { key: string } {
138
+ return !!row && typeof row === 'object' && typeof (row as { key?: unknown }).key === 'string';
139
+ }
140
+
141
+ function isAssignment(row: unknown): row is DevToolsFlagAssignment {
142
+ return hasStringKey(row);
143
+ }
144
+
145
+ function isFlagRow(row: unknown): row is DevToolsFlagRow {
146
+ return hasStringKey(row);
147
+ }
148
+
149
+ export class FlagsAdminService {
150
+ constructor(private deps: FlagsAdminDeps) {}
151
+
152
+ /**
153
+ * Everything the Flags tab renders, in one round trip per source.
154
+ *
155
+ * Each section fails independently: a remote read that throws downgrades to
156
+ * `isAdmin: false` plus an `error`, while local assignments and overrides
157
+ * still render. Signing out must not blank the whole tab.
158
+ */
159
+ async getFlags(): Promise<DevToolsFlagsSnapshot> {
160
+ const userId = this.deps.currentUserId();
161
+ const snapshot: DevToolsFlagsSnapshot = {
162
+ at: Date.now(),
163
+ userId,
164
+ isAdmin: false,
165
+ flags: [],
166
+ assignments: [],
167
+ overrides: this.deps.overrides()?.getLocalOverrides() ?? {},
168
+ };
169
+
170
+ // Local assignments: available signed out and offline.
171
+ try {
172
+ const rows = statementRows(
173
+ await this.deps.local.query('SELECT key, variant, payload FROM _00_user_feature')
174
+ );
175
+ snapshot.assignments = rows.filter(isAssignment).map((r) => ({
176
+ key: r.key,
177
+ variant: r.variant,
178
+ payload: r.payload,
179
+ }));
180
+ } catch (err) {
181
+ this.deps.logger.debug(
182
+ { err, Category: 'sp00ky-client::FlagsAdminService::getFlags' },
183
+ 'Local feature assignments unavailable'
184
+ );
185
+ }
186
+
187
+ if (!userId) return snapshot;
188
+
189
+ try {
190
+ // Self-scoped by `_00_admin`'s own select rule: an admin sees exactly
191
+ // their own row, a non-admin sees nothing. The roster never leaks.
192
+ const admin = statementRows(
193
+ await this.deps.remote.query('SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1')
194
+ );
195
+ snapshot.isAdmin = admin.length > 0;
196
+ } catch (err) {
197
+ // A missing `_00_admin` table means the deployment hasn't applied the
198
+ // internal schema yet. Say so — otherwise it reads as "not an admin".
199
+ snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Flags tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
200
+ return snapshot;
201
+ }
202
+
203
+ if (!snapshot.isAdmin) return snapshot;
204
+
205
+ try {
206
+ const rows = statementRows(
207
+ await this.deps.remote.query(
208
+ 'SELECT key, description, variants, default_variant, enabled, payloads, rules, updated_at ' +
209
+ 'FROM _00_feature_flag ORDER BY key ASC'
210
+ )
211
+ );
212
+ snapshot.flags = rows
213
+ .filter(isFlagRow)
214
+ .map((flag) => ({
215
+ ...flag,
216
+ rules: Array.isArray(flag.rules) ? flag.rules : [],
217
+ variants: Array.isArray(flag.variants) ? flag.variants : [],
218
+ selfAllowlistedVariant: selfAllowlistedVariant(flag, userId),
219
+ }));
220
+ } catch (err) {
221
+ snapshot.error = `Could not read feature flags: ${message(err)}`;
222
+ }
223
+
224
+ return snapshot;
225
+ }
226
+
227
+ /**
228
+ * Flip a flag's global `enabled` bit for EVERY user, then re-materialize.
229
+ *
230
+ * Both statements are one request so they share a transaction: if the
231
+ * materialize fails, the `enabled` change rolls back rather than leaving the
232
+ * definition and the assignments disagreeing.
233
+ */
234
+ async setFlagEnabled(key: string, enabled: boolean): Promise<DevToolsFlagResult> {
235
+ return this.mutate(
236
+ 'UPDATE _00_feature_flag SET enabled = $enabled WHERE key = $key; ' +
237
+ 'RETURN fn::feature::materialize($key);',
238
+ { key, enabled },
239
+ 1
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Add or remove a user from `$key`'s allowlist for `$variant`, then
245
+ * re-materialize. Defaults to the signed-in user, so the common case
246
+ * ("turn this on for me, for real") needs no user picker.
247
+ */
248
+ async setFlagUserVariant(
249
+ key: string,
250
+ variant: string,
251
+ remove: boolean,
252
+ userId?: string
253
+ ): Promise<DevToolsFlagResult> {
254
+ const target = userId ?? this.deps.currentUserId();
255
+ if (!target) return { success: false, error: 'Not signed in' };
256
+
257
+ // `fn::feature::allow/disallow` declare `$user: record`. A string binding
258
+ // is rejected by the type check, so parse it into a real RecordId.
259
+ let user;
260
+ try {
261
+ user = parseRecordIdString(target);
262
+ } catch (err) {
263
+ return { success: false, error: `Invalid user id '${target}': ${message(err)}` };
264
+ }
265
+
266
+ return remove
267
+ ? this.mutate('RETURN fn::feature::disallow($key, $user);', { key, user }, 0)
268
+ : this.mutate('RETURN fn::feature::allow($key, $variant, $user);', { key, variant, user }, 0);
269
+ }
270
+
271
+ setLocalFlagOverride(
272
+ key: string,
273
+ variant: string | null,
274
+ payload?: unknown
275
+ ): { overrides: Record<string, DevToolsFlagOverride> } {
276
+ const store = this.deps.overrides();
277
+ store?.setLocalOverride(key, variant, payload);
278
+ return { overrides: store?.getLocalOverrides() ?? {} };
279
+ }
280
+
281
+ clearLocalFlagOverrides(): { overrides: Record<string, DevToolsFlagOverride> } {
282
+ const store = this.deps.overrides();
283
+ store?.clearLocalOverrides();
284
+ return { overrides: store?.getLocalOverrides() ?? {} };
285
+ }
286
+
287
+ /**
288
+ * Run a remote mutation, reporting the materialize count from `$index`.
289
+ *
290
+ * Retries on a transaction conflict. `fn::feature::allow` / `disallow` are
291
+ * read-modify-write over `_00_feature_flag.rules`, so two admins acting on
292
+ * the same flag at once collide. SurrealDB detects this and fails the loser
293
+ * with "Transaction conflict ... can be retried" rather than losing the
294
+ * write — verified against 3.1 — so nothing is silently dropped. Retrying
295
+ * turns that into the outcome the user expected instead of a raw engine
296
+ * error they can do nothing with.
297
+ */
298
+ private async mutate(
299
+ sql: string,
300
+ vars: Record<string, unknown>,
301
+ index: number
302
+ ): Promise<DevToolsFlagResult> {
303
+ let lastError: unknown;
304
+ for (let attempt = 0; attempt < MUTATE_ATTEMPTS; attempt++) {
305
+ try {
306
+ const result = await this.deps.remote.query(sql, vars);
307
+ const materialized = statementRows(result, index)[0] as { users?: number } | undefined;
308
+ return { success: true, users: materialized?.users };
309
+ } catch (err) {
310
+ lastError = err;
311
+ if (!isRetryableConflict(err) || attempt === MUTATE_ATTEMPTS - 1) break;
312
+ // Staggered so two collided clients don't line up again on the retry.
313
+ await sleep(40 * (attempt + 1) + Math.random() * 40);
314
+ }
315
+ }
316
+
317
+ this.deps.logger.warn(
318
+ { err: lastError, sql, Category: 'sp00ky-client::FlagsAdminService::mutate' },
319
+ 'Feature flag mutation failed'
320
+ );
321
+ return { success: false, error: message(lastError) };
322
+ }
323
+ }
324
+
325
+ const MUTATE_ATTEMPTS = 3;
326
+
327
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
328
+
329
+ function isRetryableConflict(err: unknown): boolean {
330
+ return /transaction (write )?conflict/i.test(message(err));
331
+ }
332
+
333
+ /**
334
+ * Which variant, if any, this user is explicitly allowlisted into.
335
+ *
336
+ * `rules[].users` holds record-id strings (`flag.rs` serialises them as JSON),
337
+ * so compare as strings. Lets the panel show "you're on the list" without
338
+ * making the user reason about a raw rules blob.
339
+ */
340
+ function selfAllowlistedVariant(flag: DevToolsFlagRow, userId: string): string | undefined {
341
+ const rules = Array.isArray(flag.rules) ? flag.rules : [];
342
+ const hit = rules.find(
343
+ (rule) =>
344
+ rule?.kind === 'allowlist' &&
345
+ Array.isArray(rule.users) &&
346
+ rule.users.some((u) => String(u) === userId)
347
+ );
348
+ return hit?.variant;
349
+ }
@@ -23,6 +23,7 @@ import {
23
23
  UNAVAILABLE,
24
24
  } from './versions';
25
25
  import { walkOpfs, type BlobCacheInfo, type SharedTabsInfo, type StorageInfo } from './storage-info';
26
+ import { FlagsAdminService, type LocalOverrideStore } from './flags';
26
27
 
27
28
  // Real bundled frontend versions, injected at build time by tsdown's
28
29
  // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
@@ -85,6 +86,12 @@ export class DevToolsService implements StreamUpdateReceiver {
85
86
  private localTablesFetching = false;
86
87
  private localTablesAt = 0;
87
88
 
89
+ // Feature flag admin, backing the panel's Flags tab. The local-override
90
+ // store is injected later (`setFeatureFlagOverrides`) because the
91
+ // FeatureFlagModule is built after this service.
92
+ private featureOverrides: LocalOverrideStore | null = null;
93
+ private readonly flagsAdmin: FlagsAdminService;
94
+
88
95
  constructor(
89
96
  private databaseService: LocalStore,
90
97
  private remoteDatabaseService: RemoteDatabaseService,
@@ -93,6 +100,18 @@ export class DevToolsService implements StreamUpdateReceiver {
93
100
  private authService: AuthService<SchemaStructure>,
94
101
  private dataManager?: DataModule<SchemaStructure>
95
102
  ) {
103
+ this.flagsAdmin = new FlagsAdminService({
104
+ remote: this.remoteDatabaseService,
105
+ local: this.databaseService,
106
+ logger: this.logger,
107
+ currentUserId: () => {
108
+ const id = this.authService.currentUser?.id;
109
+ if (!id) return null;
110
+ return id instanceof RecordId ? encodeRecordId(id) : String(id);
111
+ },
112
+ overrides: () => this.featureOverrides,
113
+ });
114
+
96
115
  this.exposeToWindow();
97
116
 
98
117
  // Stay dormant until a devtools consumer announces itself. The extension's
@@ -525,11 +544,33 @@ export class DevToolsService implements StreamUpdateReceiver {
525
544
  return data;
526
545
  }
527
546
 
547
+ /**
548
+ * Hand the FeatureFlagModule to the Flags tab so it can read and write local
549
+ * overrides. Called from `Sp00kyClient` once both are constructed; until then
550
+ * the override methods are no-ops that report an empty map.
551
+ */
552
+ public setFeatureFlagOverrides(store: LocalOverrideStore): void {
553
+ this.featureOverrides = store;
554
+ }
555
+
528
556
  private exposeToWindow() {
529
557
  if (typeof window !== 'undefined') {
530
558
  (window as any).__00__ = {
531
559
  version: this.version,
532
560
  getState: () => this.getState(),
561
+ // ---- Feature flags (Flags tab) --------------------------------
562
+ // Remote reads/writes are admin-gated by SurrealDB, not here: a
563
+ // non-admin gets an empty flag list, and the `fn::feature::*` calls
564
+ // are denied outright. The override methods are purely local and
565
+ // work signed out.
566
+ getFlags: () => this.flagsAdmin.getFlags(),
567
+ setFlagEnabled: (key: string, enabled: boolean) =>
568
+ this.flagsAdmin.setFlagEnabled(key, enabled),
569
+ setFlagUserVariant: (key: string, variant: string, remove: boolean, userId?: string) =>
570
+ this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
571
+ setLocalFlagOverride: (key: string, variant: string | null, payload?: unknown) =>
572
+ this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
573
+ clearLocalFlagOverrides: () => this.flagsAdmin.clearLocalFlagOverrides(),
533
574
  clearHistory: () => {
534
575
  this.eventsHistory = [];
535
576
  this.notifyDevTools();
@@ -1,6 +1,20 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { FeatureFlagModule } from './index';
3
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
+
4
18
  // Minimal mocks for the three deps the module touches. The DataModule mock
5
19
  // captures the single subscribe callback so a test can push live results, and
6
20
  // counts query() calls to assert the query is SHARED (one registration for all
@@ -118,3 +132,120 @@ describe('FeatureFlagModule', () => {
118
132
  expect(env.hasSub()).toBe(true); // re-registered for the new user
119
133
  });
120
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
+ });
@@ -13,6 +13,12 @@ import type { QueryTimeToLive } from '../../types';
13
13
  // fallback. Avoids one-registration-per-flag and the param-filtered live query.
14
14
  const FEATURE_QUERY = 'SELECT key, variant, payload FROM _00_user_feature';
15
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
+
16
22
  interface FeatureRow {
17
23
  key?: string;
18
24
  variant?: string;
@@ -29,6 +35,16 @@ export interface FeatureFlagOptions {
29
35
  ttl?: QueryTimeToLive;
30
36
  }
31
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
+
32
48
  export class FeatureFlagHandle {
33
49
  private latest: FeatureFlagSnapshot = { variant: undefined, payload: undefined };
34
50
  private listeners = new Set<(s: FeatureFlagSnapshot) => void>();
@@ -114,9 +130,15 @@ export class FeatureFlagModule<S extends SchemaStructure> {
114
130
  // back). `snapshots` only holds ASSIGNED keys; an absent key → fallback.
115
131
  private snapshots = new Map<string, FeatureFlagSnapshot>();
116
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>();
117
136
 
118
137
  constructor(private deps: FeatureFlagModuleDeps<S>) {
119
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();
120
142
  }
121
143
 
122
144
  init(): void {
@@ -135,8 +157,10 @@ export class FeatureFlagModule<S extends SchemaStructure> {
135
157
  if (options.ttl) this.ttl = options.ttl;
136
158
  // If the shared query already resolved, seed this handle immediately so a
137
159
  // 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 });
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));
140
164
  }
141
165
  void this.ensureStarted();
142
166
  return handle;
@@ -155,8 +179,10 @@ export class FeatureFlagModule<S extends SchemaStructure> {
155
179
  this.loaded = false;
156
180
  this.snapshots.clear();
157
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.
158
184
  for (const handle of this.handles) {
159
- handle.set({ variant: undefined, payload: undefined });
185
+ handle.set(this.resolve(handle.key));
160
186
  }
161
187
  await this.ensureStarted();
162
188
  }
@@ -202,8 +228,81 @@ export class FeatureFlagModule<S extends SchemaStructure> {
202
228
  }
203
229
  }
204
230
  this.loaded = true;
205
- for (const handle of this.handles) {
206
- handle.set(this.snapshots.get(handle.key) ?? { variant: undefined, payload: undefined });
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
+ );
207
306
  }
208
307
  }
209
308
  }
package/src/sp00ky.ts CHANGED
@@ -47,7 +47,7 @@ import type { RecordWithId } from './modules/cache/index';
47
47
  import { CrdtManager, CrdtField } from './modules/crdt/index';
48
48
  import { preloadLoro } from './modules/crdt/loro-loader';
49
49
  import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
50
- import type { FeatureFlagOptions } from './modules/feature-flag/index';
50
+ import type { FeatureFlagOptions, FeatureFlagOverride } from './modules/feature-flag/index';
51
51
  import { AppReleaseModule, AppReleaseHandle } from './modules/app-release/index';
52
52
  import type { AppReleaseOptions } from './modules/app-release/index';
53
53
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
@@ -465,6 +465,11 @@ export class Sp00kyClient<S extends SchemaStructure> {
465
465
  this.dataModule
466
466
  );
467
467
 
468
+ // Let the DevTools Flags tab read and write local flag overrides. Done
469
+ // here rather than via the constructor because FeatureFlagModule is built
470
+ // above and DevToolsService takes its deps positionally.
471
+ this.devTools.setFeatureFlagOverrides(this.featureFlags);
472
+
468
473
  // Register DevTools as a receiver for stream updates
469
474
  this.streamProcessor.addReceiver(this.devTools);
470
475
 
@@ -1054,6 +1059,31 @@ export class Sp00kyClient<S extends SchemaStructure> {
1054
1059
  return this.featureFlags.feature(key, options);
1055
1060
  }
1056
1061
 
1062
+ /**
1063
+ * Force a feature flag to `variant` in THIS browser only; `null` clears it.
1064
+ *
1065
+ * Nothing is sent to the server — the `_00_user_feature` assignment is
1066
+ * untouched, so clearing restores whatever the server says. Persisted to
1067
+ * localStorage, survives reloads, and applies while signed out. Backs the
1068
+ * DevTools Flags tab, and is a convenient hook for tests.
1069
+ *
1070
+ * To change a flag for OTHER users you need admin rights (`spky admin add`)
1071
+ * and the DevTools Flags tab, or `spky flag`.
1072
+ */
1073
+ setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
1074
+ this.featureFlags.setLocalOverride(key, variant, payload);
1075
+ }
1076
+
1077
+ /** Drop every local feature flag override set via `setFeatureOverride`. */
1078
+ clearFeatureOverrides(): void {
1079
+ this.featureFlags.clearLocalOverrides();
1080
+ }
1081
+
1082
+ /** The local feature flag overrides currently in effect, keyed by flag. */
1083
+ getFeatureOverrides(): Record<string, FeatureFlagOverride> {
1084
+ return this.featureFlags.getLocalOverrides();
1085
+ }
1086
+
1057
1087
  /**
1058
1088
  * Observe the announced release of an app (`_00_app_release:<app>`, written
1059
1089
  * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the