@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- 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/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- 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 +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- 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/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- 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 +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -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.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +59 -3
- 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 +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mintMutationId, mutationOwnerTabId } from './mutation-id';
|
|
3
|
+
|
|
4
|
+
// The old `_00_pending_mutations:${Date.now()}` collided within a millisecond
|
|
5
|
+
// (guaranteed once multiple tabs share a store) and never actually ordered the
|
|
6
|
+
// drain. These pin the new id's three properties: unique, sortable, routable.
|
|
7
|
+
describe('mutation ids', () => {
|
|
8
|
+
it('never collides within a burst', () => {
|
|
9
|
+
const ids = new Set(Array.from({ length: 1000 }, () => mintMutationId('tabx')));
|
|
10
|
+
expect(ids.size).toBe(1000);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('sorts lexicographically in mint order (ORDER BY id is chronological)', () => {
|
|
14
|
+
const ids = Array.from({ length: 200 }, () => mintMutationId('tabx'));
|
|
15
|
+
expect([...ids].sort()).toEqual(ids);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('routes back to the owning tab', () => {
|
|
19
|
+
expect(mutationOwnerTabId(mintMutationId('tab-abc'))).toBe('tab-abc');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null for a legacy numeric id instead of misrouting', () => {
|
|
23
|
+
expect(mutationOwnerTabId('_00_pending_mutations:1753875000000')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox mutation ids. Previously `_00_pending_mutations:${Date.now()}`, which
|
|
3
|
+
* (a) collides when two mutations land in the same millisecond, a real case
|
|
4
|
+
* once multiple tabs share one store, and (b) never actually ordered the
|
|
5
|
+
* drain, because `loadFromDatabase` sorted by a `created_at` field that does
|
|
6
|
+
* not exist on the table. The new shape is sortable AND collision-free:
|
|
7
|
+
*
|
|
8
|
+
* <13-digit zero-padded ms timestamp>_<4-digit base36 seq>_<tabId>
|
|
9
|
+
*
|
|
10
|
+
* Lexicographic order == chronological order (per tab; cross-tab within-ms
|
|
11
|
+
* ordering is arbitrary, which matches reality), so `ORDER BY id` in the
|
|
12
|
+
* drain is now meaningful. The tabId suffix also routes rollbacks back to the
|
|
13
|
+
* tab that owns the mutation in shared-tabs mode.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let seq = 0;
|
|
17
|
+
|
|
18
|
+
/** Tab identity for mutation ids in SOLO mode, minted once per session. In
|
|
19
|
+
* shared-tabs mode the coordinator's tabId is passed in instead. */
|
|
20
|
+
const FALLBACK_TAB_ID = Math.random().toString(36).slice(2, 8);
|
|
21
|
+
|
|
22
|
+
export function mintMutationId(tabId?: string): string {
|
|
23
|
+
const ts = Date.now().toString().padStart(13, '0');
|
|
24
|
+
const n = (seq = (seq + 1) % 36 ** 4).toString(36).padStart(4, '0');
|
|
25
|
+
return `_00_pending_mutations:${ts}_${n}_${tabId ?? FALLBACK_TAB_ID}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The tab that created the mutation, or null for a legacy numeric id. */
|
|
29
|
+
export function mutationOwnerTabId(mutationId: string): string | null {
|
|
30
|
+
const raw = mutationId.startsWith('_00_pending_mutations:')
|
|
31
|
+
? mutationId.slice('_00_pending_mutations:'.length)
|
|
32
|
+
: mutationId;
|
|
33
|
+
const parts = raw.split('_');
|
|
34
|
+
return parts.length >= 3 ? parts[parts.length - 1] : null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildWindowMaterialization } from './window-query';
|
|
3
|
+
|
|
4
|
+
describe('buildWindowMaterialization', () => {
|
|
5
|
+
it('rewrites the game-list window query (START 30) to select the id-set, keeping ORDER BY', () => {
|
|
6
|
+
const surql =
|
|
7
|
+
'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 30;';
|
|
8
|
+
const r = buildWindowMaterialization(surql);
|
|
9
|
+
expect(r).not.toBeNull();
|
|
10
|
+
expect(r!.query).toBe('SELECT * FROM $__win ORDER BY sort_index asc, date desc');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns null for START 0 (offset-free windows still work via the normal re-query)', () => {
|
|
14
|
+
const surql =
|
|
15
|
+
'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 0;';
|
|
16
|
+
expect(buildWindowMaterialization(surql)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null when there is no START clause', () => {
|
|
20
|
+
expect(
|
|
21
|
+
buildWindowMaterialization('SELECT * FROM game WHERE database = $database LIMIT 30;')
|
|
22
|
+
).toBeNull();
|
|
23
|
+
expect(buildWindowMaterialization('SELECT * FROM game;')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves a custom projection', () => {
|
|
27
|
+
const surql = 'SELECT id, white, black FROM game ORDER BY date desc LIMIT 30 START 60;';
|
|
28
|
+
expect(buildWindowMaterialization(surql)!.query).toBe(
|
|
29
|
+
'SELECT id, white, black FROM $__win ORDER BY date desc'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('omits ORDER BY when the query had none', () => {
|
|
34
|
+
const surql = 'SELECT * FROM game LIMIT 30 START 30;';
|
|
35
|
+
expect(buildWindowMaterialization(surql)!.query).toBe('SELECT * FROM $__win');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores FROM/ORDER BY/LIMIT/START inside subqueries (paren-aware)', () => {
|
|
39
|
+
const surql =
|
|
40
|
+
'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments ' +
|
|
41
|
+
'FROM game ORDER BY sort_index asc LIMIT 30 START 90;';
|
|
42
|
+
expect(buildWindowMaterialization(surql)!.query).toBe(
|
|
43
|
+
'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM $__win ORDER BY sort_index asc'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not treat a START inside a string literal as the offset', () => {
|
|
48
|
+
const surql = "SELECT * FROM game WHERE note = 'LIMIT 30 START 30' ORDER BY date desc LIMIT 30 START 30;";
|
|
49
|
+
const r = buildWindowMaterialization(surql);
|
|
50
|
+
expect(r!.query).toBe('SELECT * FROM $__win ORDER BY date desc');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
|
|
3
|
+
* materialized from an explicit record-id set — the window the SSP already
|
|
4
|
+
* computed — instead of re-running the original query (with its `START m`)
|
|
5
|
+
* against the shared local DB.
|
|
6
|
+
*
|
|
7
|
+
* Why: `DataModule.processStreamUpdate` materializes a query's rows by
|
|
8
|
+
* re-querying the local in-browser SurrealDB with the original surql. For an
|
|
9
|
+
* offset query that re-applies `START m` against the *shared* local store —
|
|
10
|
+
* which, with sparse windowing, may hold only this window's rows — so it skips
|
|
11
|
+
* them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
|
|
12
|
+
* materialized view (`StreamUpdate.localArray`) is exactly this window's row
|
|
13
|
+
* ids, so we select those ids directly and re-apply the original `ORDER BY` for
|
|
14
|
+
* stable display order.
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` for non-offset queries (`START` absent or 0) — the caller
|
|
17
|
+
* keeps the normal re-query path, so only the broken case changes behavior.
|
|
18
|
+
*
|
|
19
|
+
* Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
|
|
20
|
+
* drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
|
|
21
|
+
* projection are preserved verbatim (the scanner is paren/quote aware), with
|
|
22
|
+
* one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
|
|
23
|
+
* shape — such windowed+subquery queries are rare and were already returning 0,
|
|
24
|
+
* so this is no regression.
|
|
25
|
+
*/
|
|
26
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plan-level window materialization: restrict a windowed query's base rows to
|
|
30
|
+
* exactly the SSP-computed id-set, dropping `where`/`limit`/`offset` but keeping
|
|
31
|
+
* `orderBy`, `select` and `relations`. The engine-neutral counterpart of
|
|
32
|
+
* {@link buildWindowMaterialization} (which does the same by string surgery for
|
|
33
|
+
* the raw-SurrealQL path). Returns `null` for non-offset queries so the caller
|
|
34
|
+
* keeps the normal re-query path.
|
|
35
|
+
*/
|
|
36
|
+
export function buildWindowMaterializationPlan(
|
|
37
|
+
plan: QueryPlan,
|
|
38
|
+
ids: unknown[]
|
|
39
|
+
): QueryPlan | null {
|
|
40
|
+
if (plan.offset === undefined || plan.offset <= 0) return null;
|
|
41
|
+
return buildIdSetPlan(plan, ids);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Raw-SurrealQL counterpart of {@link buildIdSetPlan}, for queries that arrived
|
|
46
|
+
* without a plan. Same rewrite as {@link buildWindowMaterialization} without the
|
|
47
|
+
* offset guard: keep the projection and top-level `ORDER BY`, replace the source
|
|
48
|
+
* with the bound id-set.
|
|
49
|
+
*/
|
|
50
|
+
export function buildIdSetSurql(
|
|
51
|
+
surql: string,
|
|
52
|
+
idsParam = '__win'
|
|
53
|
+
): { query: string } | null {
|
|
54
|
+
const kw = scanTopLevelClauses(surql);
|
|
55
|
+
if (kw.fromIndex === null) return null;
|
|
56
|
+
return { query: renderIdSetSelect(surql, kw, idsParam) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shared body of the two id-set surql rewrites: keep the projection and the
|
|
60
|
+
* top-level `ORDER BY`, replace the source with the bound id-set. */
|
|
61
|
+
function renderIdSetSelect(surql: string, kw: TopLevelClauses, idsParam: string): string {
|
|
62
|
+
const selectClause = surql.slice(0, kw.fromIndex!).trimEnd(); // "SELECT <projection>"
|
|
63
|
+
|
|
64
|
+
let orderBy = '';
|
|
65
|
+
const orderByIndex = kw.orderByIndex;
|
|
66
|
+
if (orderByIndex !== null) {
|
|
67
|
+
const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
|
|
68
|
+
(n): n is number => n !== null && n > orderByIndex
|
|
69
|
+
);
|
|
70
|
+
orderBy = ' ' + surql.slice(orderByIndex, Math.min(...ends)).trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `${selectClause} FROM $${idsParam}${orderBy}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Restrict any plan's base rows to an explicit id-set, regardless of offset.
|
|
78
|
+
*
|
|
79
|
+
* Same rewrite as {@link buildWindowMaterializationPlan} without the offset
|
|
80
|
+
* guard, for rendering a query from its authoritative membership list rather
|
|
81
|
+
* than by re-running its predicate.
|
|
82
|
+
*
|
|
83
|
+
* `where` is dropped deliberately: the id-set already IS the answer to the
|
|
84
|
+
* predicate as the server evaluated it, and re-applying the `WHERE` locally is
|
|
85
|
+
* exactly the bug — a row that left the window keeps its (stale, never
|
|
86
|
+
* re-fetched) local body, so it keeps matching and keeps rendering.
|
|
87
|
+
* `limit`/`offset` go for the same reason: both are already baked into which ids
|
|
88
|
+
* are in the set, so the set itself is the bound. (Both engines ignore them on
|
|
89
|
+
* the `ids` path regardless — only `select` and `orderBy` are honored there.)
|
|
90
|
+
* `orderBy` is kept so display order stays deterministic.
|
|
91
|
+
*/
|
|
92
|
+
export function buildIdSetPlan(plan: QueryPlan, ids: unknown[]): QueryPlan {
|
|
93
|
+
return {
|
|
94
|
+
...plan,
|
|
95
|
+
ids,
|
|
96
|
+
where: undefined,
|
|
97
|
+
limit: undefined,
|
|
98
|
+
offset: undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildWindowMaterialization(
|
|
103
|
+
surql: string,
|
|
104
|
+
idsParam = '__win'
|
|
105
|
+
): { query: string } | null {
|
|
106
|
+
const kw = scanTopLevelClauses(surql);
|
|
107
|
+
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
108
|
+
if (kw.fromIndex === null) return null;
|
|
109
|
+
return { query: renderIdSetSelect(surql, kw, idsParam) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface TopLevelClauses {
|
|
113
|
+
fromIndex: number | null;
|
|
114
|
+
orderByIndex: number | null;
|
|
115
|
+
limitIndex: number | null;
|
|
116
|
+
startIndex: number | null;
|
|
117
|
+
startValue: number | null;
|
|
118
|
+
semicolonIndex: number | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Single pass over the query, tracking paren depth and single-quoted strings so
|
|
122
|
+
// that clauses inside subqueries (which live in parens) are ignored — only the
|
|
123
|
+
// outermost (depth-0) clause keywords are recorded.
|
|
124
|
+
function scanTopLevelClauses(sql: string): TopLevelClauses {
|
|
125
|
+
const out: TopLevelClauses = {
|
|
126
|
+
fromIndex: null,
|
|
127
|
+
orderByIndex: null,
|
|
128
|
+
limitIndex: null,
|
|
129
|
+
startIndex: null,
|
|
130
|
+
startValue: null,
|
|
131
|
+
semicolonIndex: null,
|
|
132
|
+
};
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let inStr = false;
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < sql.length; i++) {
|
|
137
|
+
const ch = sql[i];
|
|
138
|
+
if (inStr) {
|
|
139
|
+
if (ch === '\\') i++; // skip escaped char
|
|
140
|
+
else if (ch === "'") inStr = false;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (ch === "'") { inStr = true; continue; }
|
|
144
|
+
if (ch === '(') { depth++; continue; }
|
|
145
|
+
if (ch === ')') { depth--; continue; }
|
|
146
|
+
if (depth !== 0) continue;
|
|
147
|
+
if (ch === ';' && out.semicolonIndex === null) { out.semicolonIndex = i; continue; }
|
|
148
|
+
|
|
149
|
+
// Only test keywords at a word boundary.
|
|
150
|
+
if (!isWordBoundary(sql, i)) continue;
|
|
151
|
+
if (out.fromIndex === null && matchKeyword(sql, i, 'FROM')) { out.fromIndex = i; continue; }
|
|
152
|
+
// FROM must come before the trailing clauses; only record them once seen.
|
|
153
|
+
if (out.fromIndex === null) continue;
|
|
154
|
+
if (out.orderByIndex === null && matchKeyword(sql, i, 'ORDER BY')) { out.orderByIndex = i; continue; }
|
|
155
|
+
if (out.limitIndex === null && matchKeyword(sql, i, 'LIMIT')) { out.limitIndex = i; continue; }
|
|
156
|
+
if (out.startIndex === null && matchKeyword(sql, i, 'START')) {
|
|
157
|
+
out.startIndex = i;
|
|
158
|
+
out.startValue = readNumberAfter(sql, i + 'START'.length);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isWordBoundary(sql: string, i: number): boolean {
|
|
166
|
+
if (i === 0) return true;
|
|
167
|
+
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Case-insensitive keyword match where internal whitespace (e.g. "ORDER BY")
|
|
171
|
+
// matches any run of whitespace, and the keyword ends on a non-word char.
|
|
172
|
+
function matchKeyword(sql: string, i: number, keyword: string): boolean {
|
|
173
|
+
const parts = keyword.split(' ');
|
|
174
|
+
let pos = i;
|
|
175
|
+
for (let p = 0; p < parts.length; p++) {
|
|
176
|
+
const word = parts[p];
|
|
177
|
+
if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
|
|
178
|
+
pos += word.length;
|
|
179
|
+
if (p < parts.length - 1) {
|
|
180
|
+
const wsStart = pos;
|
|
181
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
182
|
+
if (pos === wsStart) return false; // required whitespace
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Must end on a non-word char (or end of string).
|
|
186
|
+
return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readNumberAfter(sql: string, from: number): number | null {
|
|
190
|
+
let pos = from;
|
|
191
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
192
|
+
const m = /^\d+/.exec(sql.slice(pos));
|
|
193
|
+
return m ? parseInt(m[0], 10) : null;
|
|
194
|
+
}
|
|
@@ -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
|
+
}
|