@spooky-sync/core 0.0.1-canary.165 → 0.0.1-canary.168

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.165";
6547
- const WASM_VERSION = "0.0.1-canary.165";
6732
+ const CORE_VERSION = "0.0.1-canary.168";
6733
+ const WASM_VERSION = "0.0.1-canary.168";
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
 
@@ -9536,6 +9808,9 @@ var TabsCoordinator = class {
9536
9808
  }, "Promotion failed");
9537
9809
  this.hub?.detachAll();
9538
9810
  this.hub = null;
9811
+ if (previousRole === "leader") await this.deps.hooks.releaseOwnership();
9812
+ this.tabLock?.release();
9813
+ this.tabLock = null;
9539
9814
  this.broker.send({
9540
9815
  type: "leader-failed",
9541
9816
  tabId: this.deps.tabId,
@@ -10846,6 +11121,7 @@ var Sp00kyClient = class {
10846
11121
  logger
10847
11122
  });
10848
11123
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
11124
+ this.devTools.setFeatureFlagOverrides(this.featureFlags);
10849
11125
  this.streamProcessor.addReceiver(this.devTools);
10850
11126
  this.setupCallbacks();
10851
11127
  if (tabsSupport.supported) this.tabsCoordinator = this.buildTabsCoordinator();
@@ -10884,7 +11160,7 @@ var Sp00kyClient = class {
10884
11160
  return new TabsCoordinator({
10885
11161
  tabId,
10886
11162
  fingerprint: computeTabsFingerprint({
10887
- coreVersion: "0.0.1-canary.165",
11163
+ coreVersion: "0.0.1-canary.168",
10888
11164
  schemaHash: hash53(this.config.schemaSurql),
10889
11165
  endpoint: this.config.database.endpoint ?? "",
10890
11166
  namespace: this.config.database.namespace,
@@ -10998,23 +11274,27 @@ var Sp00kyClient = class {
10998
11274
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
10999
11275
  try {
11000
11276
  const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
11001
- if (this.tabsCoordinator) try {
11002
- const role = await this.tabsCoordinator.start(bootBucket);
11003
- this.sharedActive = true;
11004
- this.logger.info({
11005
- role,
11006
- bootBucket,
11007
- Category: "sp00ky-client::Sp00kyClient::init"
11008
- }, "Shared-tabs role assigned");
11009
- } catch (e) {
11010
- this.logger.warn({
11011
- err: e,
11012
- Category: "sp00ky-client::Sp00kyClient::init"
11013
- }, "Shared-tabs unavailable; booting solo");
11014
- this.sharedActive = false;
11015
- await this.local.connect(bootBucket);
11016
- }
11017
- else await this.local.connect(bootBucket);
11277
+ if (this.tabsCoordinator) {
11278
+ this.tabsCoordinator.onRoleChange((role) => {
11279
+ this.sharedActive = role !== "solo";
11280
+ });
11281
+ try {
11282
+ const role = await this.tabsCoordinator.start(bootBucket);
11283
+ this.sharedActive = true;
11284
+ this.logger.info({
11285
+ role,
11286
+ bootBucket,
11287
+ Category: "sp00ky-client::Sp00kyClient::init"
11288
+ }, "Shared-tabs role assigned");
11289
+ } catch (e) {
11290
+ this.logger.warn({
11291
+ err: e,
11292
+ Category: "sp00ky-client::Sp00kyClient::init"
11293
+ }, "Shared-tabs unavailable; booting solo");
11294
+ this.sharedActive = false;
11295
+ await this.local.connect(bootBucket);
11296
+ }
11297
+ } else await this.local.connect(bootBucket);
11018
11298
  this.logger.debug({
11019
11299
  bootBucket,
11020
11300
  Category: "sp00ky-client::Sp00kyClient::init"
@@ -11221,6 +11501,28 @@ var Sp00kyClient = class {
11221
11501
  return this.featureFlags.feature(key, options);
11222
11502
  }
11223
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
+ /**
11224
11526
  * Observe the announced release of an app (`_00_app_release:<app>`, written
11225
11527
  * by `spky deploy` / `spky release`). The handle's `snapshot()` carries the
11226
11528
  * announced version plus the cache-bust/mandatory flags, and
@@ -3,7 +3,7 @@ const PING_INTERVAL_MS = 5e3;
3
3
  const PONG_TIMEOUT_MS = 15e3;
4
4
  const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
5
5
  const LEADER_FAILURE_BACKOFF_MS = 1e3;
6
- const OPFS_FAILED_CYCLES_BEFORE_MEMORY = 3;
6
+ const FAILED_CYCLES_BEFORE_MEMORY = 3;
7
7
  const brokerInstanceId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `bk_${Math.random().toString(36).slice(2)}`;
8
8
  const namespaces = /* @__PURE__ */ new Map();
9
9
  /** Reverse index: which namespace a port belongs to (for pong routing). */
@@ -32,7 +32,7 @@ function getNamespace(fingerprint, bucketId) {
32
32
  tabs: /* @__PURE__ */ new Map(),
33
33
  leader: null,
34
34
  failedUntil: /* @__PURE__ */ new Map(),
35
- opfsFailedCycles: 0,
35
+ failedCycles: 0,
36
36
  electing: false,
37
37
  attachRetry: /* @__PURE__ */ new Map(),
38
38
  tabLockMonitor: null
@@ -171,7 +171,7 @@ function electIfNeeded(ns, previous = null) {
171
171
  brokerInstanceId,
172
172
  leadershipId,
173
173
  forceTakeover,
174
- allowMemoryFallback: ns.opfsFailedCycles >= OPFS_FAILED_CYCLES_BEFORE_MEMORY,
174
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
175
175
  resumeHeld
176
176
  });
177
177
  } finally {
@@ -358,7 +358,7 @@ function handleTabMessage(port, msg, ports) {
358
358
  break;
359
359
  }
360
360
  ns.leader.ready = true;
361
- ns.opfsFailedCycles = 0;
361
+ ns.failedCycles = 0;
362
362
  startTabLockMonitor(ns, msg.leadershipId);
363
363
  const tab = ns.tabs.get(msg.tabId);
364
364
  if (tab) tab.heldLeadership = {
@@ -376,7 +376,7 @@ function handleTabMessage(port, msg, ports) {
376
376
  }
377
377
  case "leader-failed": {
378
378
  if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
379
- if (msg.reason.includes("opfs-unavailable")) ns.opfsFailedCycles += 1;
379
+ ns.failedCycles += 1;
380
380
  ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
381
381
  const previous = clearLeader(ns, {
382
382
  demote: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.165",
3
+ "version": "0.0.1-canary.168",
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.165",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.165",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.168",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.168",
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 {