@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.301
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/AGENTS.md +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14939 -2432
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -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 Access 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 Access 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
|
+
}
|