@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.291

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.
Files changed (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14941 -2433
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1765 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +57 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +750 -96
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +75 -9
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +2 -1
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. 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
+ }