@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 +42 -1
- package/dist/index.js +332 -30
- package/dist/tabs-broker-worker.js +5 -5
- 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/services/tabs/coordinator.test.ts +55 -3
- package/src/services/tabs/coordinator.ts +9 -0
- package/src/services/tabs/fake-ports.fixture.ts +42 -0
- package/src/services/tabs/protocol.ts +7 -4
- package/src/services/tabs/tabs-broker-worker.ts +12 -7
- package/src/sp00ky.ts +38 -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.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.
|
|
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
|
|
|
@@ -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.
|
|
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)
|
|
11002
|
-
|
|
11003
|
-
|
|
11004
|
-
|
|
11005
|
-
|
|
11006
|
-
bootBucket
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
}
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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",
|