@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 +42 -1
- package/dist/index.js +308 -13
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +41 -0
- package/src/modules/feature-flag/index.test.ts +132 -1
- package/src/modules/feature-flag/index.ts +104 -5
- package/src/sp00ky.ts +31 -1
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.
|
|
6547
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
@@ -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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
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
|