@spooky-sync/core 0.0.1-canary.74 → 0.0.1-canary.76

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.
@@ -35,6 +35,7 @@ import {
35
35
  withRetry,
36
36
  surql,
37
37
  parseParams,
38
+ cleanRecord,
38
39
  extractTablePart,
39
40
  generateId,
40
41
  } from '../../utils/index';
@@ -606,6 +607,55 @@ export class DataModule<S extends SchemaStructure> {
606
607
  return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
607
608
  }
608
609
 
610
+ /**
611
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
612
+ * `batch` (recursing for nested related fields). An embedded child is a
613
+ * value that is itself a record — a non-null object whose `id` is a
614
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
615
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
616
+ * so this never mistakes a FK column for an embedded body. Children are
617
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
618
+ * to their table's real columns (which strips the alias/related fields).
619
+ * `seen` dedupes within the batch.
620
+ */
621
+ private collectEmbeddedChildren(
622
+ record: Record<string, any>,
623
+ batch: CacheRecord[],
624
+ seen: Set<string>
625
+ ): void {
626
+ const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
627
+ !!v &&
628
+ typeof v === 'object' &&
629
+ !(v instanceof RecordId) &&
630
+ (v as { id?: unknown }).id instanceof RecordId;
631
+
632
+ for (const value of Object.values(record)) {
633
+ const children = Array.isArray(value)
634
+ ? value.filter(isEmbeddedRecord)
635
+ : isEmbeddedRecord(value)
636
+ ? [value]
637
+ : [];
638
+ for (const child of children) {
639
+ const key = encodeRecordId(child.id);
640
+ if (seen.has(key)) continue;
641
+ seen.add(key);
642
+ // Recurse FIRST so nested grandchildren are captured before `cleanRecord`
643
+ // strips this child's alias fields.
644
+ this.collectEmbeddedChildren(child, batch, seen);
645
+ const table = child.id.table.toString();
646
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
647
+ batch.push({
648
+ table,
649
+ op: 'CREATE',
650
+ record: (tableSchema
651
+ ? cleanRecord(tableSchema.columns, child)
652
+ : child) as RecordWithId,
653
+ version: (child._00_rv as number) || 1,
654
+ });
655
+ }
656
+ }
657
+ }
658
+
609
659
  /**
610
660
  * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
611
661
  * surql run directly) so the query DISPLAYS immediately, while the full realtime
@@ -627,6 +677,20 @@ export class DataModule<S extends SchemaStructure> {
627
677
  record,
628
678
  version: (record._00_rv as number) || 1,
629
679
  }));
680
+
681
+ // Flicker-free related data on cold first paint: a `.related()` query's
682
+ // rows arrive with their child rows EMBEDDED (the correlated subquery,
683
+ // e.g. `(SELECT * FROM comment WHERE game=$parent.id) AS comments`). The
684
+ // primary batch above persists only the parent table, so the immediate
685
+ // `materializeRecords` below would re-run the correlated surql against a
686
+ // local DB with no child rows and overwrite the embedded result with
687
+ // empties. Extract those embedded children (any nesting depth) and
688
+ // persist them as their own records so the re-materialization finds them.
689
+ const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
690
+ for (const record of rows) {
691
+ this.collectEmbeddedChildren(record, batch, seen);
692
+ }
693
+
630
694
  await this.cache.saveBatch(batch);
631
695
 
632
696
  // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
@@ -0,0 +1,166 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { DataModule } from '../data/index';
3
+ import type { Sp00kySync } from '../sync/index';
4
+ import type { AuthService } from '../auth/index';
5
+ import type { Logger } from '../../services/logger/index';
6
+ import type { QueryTimeToLive } from '../../types';
7
+
8
+ const FEATURE_QUERY =
9
+ 'SELECT key, variant, payload FROM _00_user_feature WHERE key = $key';
10
+
11
+ export interface FeatureFlagSnapshot {
12
+ variant: string | undefined;
13
+ payload: unknown | undefined;
14
+ }
15
+
16
+ export interface FeatureFlagOptions {
17
+ fallback?: string;
18
+ ttl?: QueryTimeToLive;
19
+ }
20
+
21
+ export class FeatureFlagHandle {
22
+ private latest: FeatureFlagSnapshot = { variant: undefined, payload: undefined };
23
+ private listeners = new Set<(s: FeatureFlagSnapshot) => void>();
24
+ private unsubscribeFn: (() => void) | null = null;
25
+ private onCloseFn: (() => void) | null = null;
26
+ private closed = false;
27
+
28
+ constructor(
29
+ public readonly key: string,
30
+ public readonly fallback: string | undefined,
31
+ ) {}
32
+
33
+ attach(unsubscribe: () => void): void {
34
+ this.unsubscribeFn?.();
35
+ this.unsubscribeFn = unsubscribe;
36
+ }
37
+
38
+ detach(): void {
39
+ this.unsubscribeFn?.();
40
+ this.unsubscribeFn = null;
41
+ }
42
+
43
+ set(snapshot: FeatureFlagSnapshot): void {
44
+ if (this.closed) return;
45
+ this.latest = snapshot;
46
+ for (const cb of this.listeners) cb(snapshot);
47
+ }
48
+
49
+ variant(): string | undefined {
50
+ return this.latest.variant ?? this.fallback;
51
+ }
52
+
53
+ payload<T = unknown>(): T | undefined {
54
+ return this.latest.payload as T | undefined;
55
+ }
56
+
57
+ enabled(): boolean {
58
+ const v = this.variant();
59
+ return v !== undefined && v !== 'off';
60
+ }
61
+
62
+ subscribe(cb: (s: FeatureFlagSnapshot) => void): () => void {
63
+ this.listeners.add(cb);
64
+ cb({ variant: this.variant(), payload: this.latest.payload });
65
+ return () => {
66
+ this.listeners.delete(cb);
67
+ };
68
+ }
69
+
70
+ onClose(cb: () => void): void {
71
+ this.onCloseFn = cb;
72
+ }
73
+
74
+ close(): void {
75
+ if (this.closed) return;
76
+ this.closed = true;
77
+ this.listeners.clear();
78
+ this.detach();
79
+ this.onCloseFn?.();
80
+ }
81
+ }
82
+
83
+ export interface FeatureFlagModuleDeps<S extends SchemaStructure> {
84
+ dataModule: DataModule<S>;
85
+ sync: Sp00kySync<S>;
86
+ auth: AuthService<S>;
87
+ logger: Logger;
88
+ }
89
+
90
+ interface ActiveHandle {
91
+ handle: FeatureFlagHandle;
92
+ ttl: QueryTimeToLive;
93
+ }
94
+
95
+ export class FeatureFlagModule<S extends SchemaStructure> {
96
+ private logger: Logger;
97
+ private active = new Set<ActiveHandle>();
98
+ private authUnsubscribe: (() => void) | null = null;
99
+ private lastUserId: string | null = null;
100
+
101
+ constructor(private deps: FeatureFlagModuleDeps<S>) {
102
+ this.logger = deps.logger.child({ service: 'FeatureFlagModule' });
103
+ }
104
+
105
+ init(): void {
106
+ if (this.authUnsubscribe) return;
107
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
108
+ if (userId === this.lastUserId) return;
109
+ this.lastUserId = userId;
110
+ void this.refreshAll();
111
+ });
112
+ }
113
+
114
+ feature(key: string, options: FeatureFlagOptions = {}): FeatureFlagHandle {
115
+ const handle = new FeatureFlagHandle(key, options.fallback);
116
+ const entry: ActiveHandle = { handle, ttl: options.ttl ?? '10m' };
117
+ this.active.add(entry);
118
+ handle.onClose(() => this.active.delete(entry));
119
+ void this.register(entry);
120
+ return handle;
121
+ }
122
+
123
+ async closeAll(): Promise<void> {
124
+ this.authUnsubscribe?.();
125
+ this.authUnsubscribe = null;
126
+ for (const entry of [...this.active]) entry.handle.close();
127
+ }
128
+
129
+ private async refreshAll(): Promise<void> {
130
+ for (const entry of this.active) {
131
+ entry.handle.detach();
132
+ entry.handle.set({ variant: undefined, payload: undefined });
133
+ await this.register(entry);
134
+ }
135
+ }
136
+
137
+ private async register(entry: ActiveHandle): Promise<void> {
138
+ const { handle, ttl } = entry;
139
+ try {
140
+ const hash = await this.deps.dataModule.query(
141
+ '_00_user_feature' as any,
142
+ FEATURE_QUERY,
143
+ { key: handle.key },
144
+ ttl,
145
+ );
146
+
147
+ this.deps.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
148
+
149
+ const unsub = this.deps.dataModule.subscribe(
150
+ hash,
151
+ (records) => {
152
+ const row = records[0] as { variant?: string; payload?: unknown } | undefined;
153
+ handle.set({ variant: row?.variant, payload: row?.payload });
154
+ },
155
+ { immediate: true },
156
+ );
157
+
158
+ handle.attach(unsub);
159
+ } catch (err) {
160
+ this.logger.warn(
161
+ { err, key: handle.key, Category: 'sp00ky-client::FeatureFlagModule::register' },
162
+ 'Failed to register feature flag query',
163
+ );
164
+ }
165
+ }
166
+ }
@@ -8,7 +8,9 @@ import type { RecordId, Uuid } from 'surrealdb';
8
8
  import {
9
9
  ArraySyncer,
10
10
  buildListRefSelect,
11
+ buildSubqueryListRefSelect,
11
12
  createDiffFromDbOp,
13
+ diffRecordVersionArray,
12
14
  listRefPollDelayMs,
13
15
  recordVersionArraysEqual,
14
16
  resolveListRefPollInterval,
@@ -350,6 +352,15 @@ export class Sp00kySync<S extends SchemaStructure> {
350
352
  'syncQuery failed during poll'
351
353
  );
352
354
  }
355
+ // Cross-session fallback for `.related()` child rows: the LIVE-permission
356
+ // gap can drop child-edge notifications, so converge their bodies on the
357
+ // poll too (idempotent — no-op when nothing changed).
358
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
359
+ this.logger.info(
360
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
361
+ 'Subquery child sync failed during poll'
362
+ );
363
+ });
353
364
  // A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
354
365
  // get a re-render from the SSP stream on this code path reliably (and the
355
366
  // non-windowed window-0 query re-queries the local DB rather than the id-set).
@@ -463,12 +474,26 @@ export class Sp00kySync<S extends SchemaStructure> {
463
474
  'Live update received'
464
475
  );
465
476
  if (message.action === 'KILLED') return;
466
- // Skip subquery entries (rows with `parent` set) they're synthetic
467
- // CRDT/cursor metadata rows. The poll filters them with `parent IS NONE`
468
- // (the client's RecordVersionArray only tracks primary rows); the LIVE
469
- // feed delivers every row, so without this they'd surface as spurious
470
- // "added" diffs.
471
- if ((message.value as { parent?: unknown }).parent != null) return;
477
+ // Subquery child edges (rows with `parent` set) are NOT primary window
478
+ // rows the client's `RecordVersionArray` only tracks primary rows, so
479
+ // routing them through `handleRemoteListRefChange` would surface them as
480
+ // spurious "added" diffs and pollute the window. Instead route them to
481
+ // the dedicated child-body sync so `.related()` data stays realtime
482
+ // cross-session (the LIVE-permission gap otherwise leaves it to the poll).
483
+ if ((message.value as { parent?: unknown }).parent != null) {
484
+ this.handleRemoteSubqueryChange(
485
+ message.action,
486
+ message.value.in as RecordId<string>,
487
+ message.value.out as RecordId<string>,
488
+ message.value.version as number
489
+ ).catch((err) => {
490
+ this.logger.error(
491
+ { err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
492
+ 'Error handling remote subquery change'
493
+ );
494
+ });
495
+ return;
496
+ }
472
497
  this.handleRemoteListRefChange(
473
498
  message.action,
474
499
  message.value.in as RecordId<string>,
@@ -535,6 +560,46 @@ export class Sp00kySync<S extends SchemaStructure> {
535
560
  await this.runSyncForQuery(hash, diff);
536
561
  }
537
562
 
563
+ /**
564
+ * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
565
+ * `parent` set) for a `.related()` query. Unlike primary rows, child rows
566
+ * must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
567
+ * keep the child BODY fresh in the local cache so the in-browser SSP's
568
+ * subquery-table dependency re-materializes the parent view.
569
+ *
570
+ * CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
571
+ * no-op: a child leaving this query's set must not delete a body another
572
+ * query may still show (see `syncSubqueryChildren` deletion-safety note);
573
+ * a genuine record delete propagates via the normal delete path.
574
+ */
575
+ private async handleRemoteSubqueryChange(
576
+ action: 'CREATE' | 'UPDATE' | 'DELETE',
577
+ queryId: RecordId,
578
+ childId: RecordId,
579
+ version: number
580
+ ) {
581
+ this.lastLiveEventAt = Date.now();
582
+ this.listRefIdleStreak = 0;
583
+
584
+ if (action === 'DELETE') return;
585
+
586
+ const existing = this.dataModule.getQueryById(queryId);
587
+ if (!existing) return;
588
+
589
+ const item = { id: childId, version };
590
+ await this.syncEngine.syncRecords(
591
+ action === 'CREATE'
592
+ ? { added: [item], updated: [], removed: [] }
593
+ : { added: [], updated: [item], removed: [] }
594
+ );
595
+
596
+ // Keep the in-memory child array in step so the poll's idempotent diff
597
+ // doesn't re-fetch this body on the next tick.
598
+ const key = encodeRecordId(childId);
599
+ const prev = existing.config.subqueryRemoteArray ?? [];
600
+ existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
601
+ }
602
+
538
603
  /**
539
604
  * Enqueues a 'down' event (from remote to local) for processing.
540
605
  * @param event The DownEvent to enqueue.
@@ -860,6 +925,64 @@ export class Sp00kySync<S extends SchemaStructure> {
860
925
  /// Incantation existed already
861
926
  await this.dataModule.updateQueryRemoteArray(queryHash, array);
862
927
  }
928
+
929
+ // Pull the bodies of any `.related()` subquery children into the local
930
+ // cache. The primary fetch above (`parent IS NONE`) tracks only window
931
+ // rows, so without this a cold-reload re-materialization of the
932
+ // correlated surql finds no child rows and related fields come back
933
+ // empty. Best-effort: never fail registration over it.
934
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
935
+ this.logger.info(
936
+ { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
937
+ 'Subquery child sync failed during registration; poll will retry'
938
+ );
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Sync the BODIES of a `.related()` query's subquery child rows into the
944
+ * local cache, separately from the primary window array. The SSP writes
945
+ * each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
946
+ * `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
947
+ * nesting depth). We diff against the in-memory `subqueryRemoteArray` and
948
+ * fetch added/updated bodies through the SyncEngine — which `saveBatch`s
949
+ * them into the local DB AND the in-browser SSP, whose subquery-table
950
+ * dependency then re-materializes the parent view (no explicit notify).
951
+ *
952
+ * Deletion safety: we pass `removed: []` deliberately. A child body can be
953
+ * shared by other queries; letting `handleRemovedRecords` delete one that
954
+ * merely left THIS query's child set would clobber data another query still
955
+ * shows. Genuine record deletes flow through the normal delete path; a
956
+ * lingering orphan body is invisible (the correlated WHERE stops matching).
957
+ *
958
+ * Kept off `runSyncForQuery` on purpose so child fetches never flip the
959
+ * query to `fetching` or skew its DevTools timings.
960
+ */
961
+ private async syncSubqueryChildren(queryHash: string): Promise<void> {
962
+ const queryState = this.dataModule.getQueryByHash(queryHash);
963
+ if (!queryState) return;
964
+
965
+ const listRefTbl = this.listRefTable();
966
+ const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
967
+ buildSubqueryListRefSelect(listRefTbl),
968
+ { in: queryState.config.id }
969
+ );
970
+ if (!Array.isArray(items)) return;
971
+
972
+ const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
973
+ const prev = queryState.config.subqueryRemoteArray ?? [];
974
+ if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
975
+
976
+ const diff = diffRecordVersionArray(prev, fresh);
977
+ if (diff.added.length > 0 || diff.updated.length > 0) {
978
+ await this.syncEngine.syncRecords({
979
+ added: diff.added,
980
+ updated: diff.updated,
981
+ removed: [], // never delete child bodies here — see method doc
982
+ });
983
+ }
984
+ // In-memory only — child rows must never enter the persisted primary array.
985
+ queryState.config.subqueryRemoteArray = fresh;
863
986
  }
864
987
 
865
988
  public async heartbeatQuery(queryHash: string) {
@@ -8,6 +8,7 @@ import {
8
8
  resolveListRefPollInterval,
9
9
  DEFAULT_LIST_REF_POLL_INTERVAL_MS,
10
10
  buildListRefSelect,
11
+ buildSubqueryListRefSelect,
11
12
  nextPollDelayMs,
12
13
  listRefPollDelayMs,
13
14
  LIST_REF_POLL_MAX_INTERVAL_MS,
@@ -377,6 +378,35 @@ describe('buildListRefSelect', () => {
377
378
  });
378
379
  });
379
380
 
381
+ describe('buildSubqueryListRefSelect', () => {
382
+ it('substitutes the table name', () => {
383
+ expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('FROM _00_list_ref ');
384
+ expect(buildSubqueryListRefSelect('_00_list_ref_user_abc')).toContain(
385
+ 'FROM _00_list_ref_user_abc '
386
+ );
387
+ });
388
+
389
+ it('filters by the bound query id', () => {
390
+ expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('WHERE in = $in');
391
+ });
392
+
393
+ it('selects ONLY subquery child rows via parent IS NOT NONE', () => {
394
+ // The mirror of `buildListRefSelect`'s `parent IS NONE`: the SSP writes
395
+ // `.related()` child rows into list_ref tagged with `parent`/`parent_rel`;
396
+ // this select pulls exactly those so their bodies can be synced into the
397
+ // local cache (separately from the primary window) and the correlated
398
+ // surql re-materializes with related data on a cold reload.
399
+ const sql = buildSubqueryListRefSelect('_00_list_ref');
400
+ expect(sql).toContain('parent IS NOT NONE');
401
+ expect(sql).not.toContain('parent IS NONE ');
402
+ });
403
+
404
+ it('selects only `out` and `version`', () => {
405
+ const sql = buildSubqueryListRefSelect('_00_list_ref');
406
+ expect(sql.startsWith('SELECT out, version FROM ')).toBe(true);
407
+ });
408
+ });
409
+
380
410
  describe('nextPollDelayMs', () => {
381
411
  it('returns the base interval when LIVE has never delivered', () => {
382
412
  expect(
@@ -188,6 +188,22 @@ export function buildListRefSelect(table: string): string {
188
188
  return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
189
189
  }
190
190
 
191
+ /**
192
+ * Build the SurrealQL select for a query's SUBQUERY child edges — the
193
+ * mirror of {@link buildListRefSelect}. `.related()` queries register a
194
+ * correlated subquery; the SSP materializes each matched child as a
195
+ * `_00_list_ref` edge tagged with `parent`/`parent_rel` (see
196
+ * `apps/ssp` edge writer). `parent IS NONE` (the primary select) drops
197
+ * these, so their bodies never reach the local cache and a cold-reload
198
+ * re-materialization of the correlated surql yields empty related
199
+ * fields. This `parent IS NOT NONE` variant pulls the child `out`+`version`
200
+ * pairs (any nesting depth) so we can sync their bodies into the local
201
+ * store SEPARATELY from the primary window array.
202
+ */
203
+ export function buildSubqueryListRefSelect(table: string): string {
204
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
205
+ }
206
+
191
207
  /**
192
208
  * Resolve the effective list-ref poll interval. Negative or zero
193
209
  * values fall back to the default — accepting them would either
@@ -68,6 +68,13 @@ export class StreamProcessorService {
68
68
  // query, so the UI updates once after the whole batch rather than row-by-row.
69
69
  private batching = false;
70
70
  private batchBuffer: Map<string, StreamUpdate> = new Map();
71
+ // Current session's auth identity, injected into every `register_view`'s
72
+ // params so the in-browser SSP can resolve `$auth`/`$access` in table
73
+ // permission predicates (mirrors the server's `fn::query::register`). Empty
74
+ // strings when logged out — a non-null `auth` keeps `permission_inject` from
75
+ // rejecting `$auth`-gated tables; the predicate just degrades to its public
76
+ // branch. Set via `setSessionAuth` on every auth state change.
77
+ private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
71
78
 
72
79
  constructor(
73
80
  public events: EventSystem<StreamProcessorEvents>,
@@ -288,6 +295,28 @@ export class StreamProcessorService {
288
295
  );
289
296
  }
290
297
 
298
+ /**
299
+ * Set the current session's auth identity for permission injection,
300
+ * mirroring the server's `fn::query::register`
301
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
302
+ * Stored as strings (empty when logged out) and applied to every
303
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
304
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
305
+ * in-browser SSP's `permission_inject` rejects it with
306
+ * "requires $auth but registration params lack it".
307
+ */
308
+ setSessionAuth(authId: string | null, access: string | null) {
309
+ this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
310
+ this.logger.debug(
311
+ {
312
+ authId: this.sessionAuth.authId,
313
+ access: this.sessionAuth.access,
314
+ Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
315
+ },
316
+ 'Session auth context updated'
317
+ );
318
+ }
319
+
291
320
  async saveState() {
292
321
  if (!this.processor) return;
293
322
  try {
@@ -411,10 +440,23 @@ export class StreamProcessorService {
411
440
  try {
412
441
  const normalizedParams = this.normalizeValue(queryPlan.params);
413
442
 
443
+ // Mirror the server's `fn::query::register` auth injection so the
444
+ // in-browser SSP can resolve `$auth`/`$access` in a table's permission
445
+ // predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
446
+ // rejected by `permission_inject` and its local view never materializes.
447
+ // Injected only into the params handed to the in-browser SSP — never into
448
+ // the persisted `queryState.config.params` / query hash / server payload
449
+ // (the server does its own injection), so the view id stays shared.
450
+ const paramsWithAuth = {
451
+ ...(normalizedParams as Record<string, unknown>),
452
+ auth: { id: this.sessionAuth.authId },
453
+ access: this.sessionAuth.access,
454
+ };
455
+
414
456
  const initialUpdate = this.processor.register_view({
415
457
  id: queryPlan.queryHash,
416
458
  surql: queryPlan.surql,
417
- params: normalizedParams,
459
+ params: paramsWithAuth,
418
460
  clientId: 'local',
419
461
  ttl: queryPlan.ttl.toString(),
420
462
  lastActiveAt: new Date().toISOString(),
package/src/sp00ky.ts CHANGED
@@ -38,8 +38,10 @@ import { EventSystem } from './events/index';
38
38
  import { CacheModule } from './modules/cache/index';
39
39
  import type { RecordWithId } from './modules/cache/index';
40
40
  import { CrdtManager, CrdtField } from './modules/crdt/index';
41
+ import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
42
+ import type { FeatureFlagOptions } from './modules/feature-flag/index';
41
43
  import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
42
- import { parseParams } from './utils/index';
44
+ import { parseParams, encodeRecordId } from './utils/index';
43
45
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
44
46
  import { ResilientPersistenceClient } from './services/persistence/resilient';
45
47
 
@@ -95,6 +97,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
95
97
  private sync: Sp00kySync<S>;
96
98
  private devTools: DevToolsService;
97
99
  private crdtManager: CrdtManager;
100
+ private featureFlags!: FeatureFlagModule<S>;
98
101
 
99
102
  private logger: ReturnType<typeof createLogger>;
100
103
  public auth: AuthService<S>;
@@ -203,6 +206,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
203
206
  { refSyncIntervalMs: this.config.refSyncIntervalMs }
204
207
  );
205
208
 
209
+ // Initialize feature flags. Reuses the down-queue to register SSP plans
210
+ // on `_00_user_feature` and the auth subscription to re-register handles
211
+ // when the signed-in user changes.
212
+ this.featureFlags = new FeatureFlagModule({
213
+ dataModule: this.dataModule,
214
+ sync: this.sync,
215
+ auth: this.auth,
216
+ logger,
217
+ });
218
+
206
219
  // Initialize DevTools
207
220
  this.devTools = new DevToolsService(
208
221
  this.local,
@@ -368,6 +381,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
368
381
  // table.
369
382
  this.auth.subscribe(async (userId) => {
370
383
  this.dataModule.setCurrentUserId(userId);
384
+ // Mirror the server's `fn::query::register` auth injection for the
385
+ // in-browser SSP: feed the current user's full record id + access
386
+ // method so `$auth`-gated table permissions (e.g. `thread`) resolve
387
+ // locally instead of being rejected. Set synchronously BEFORE the
388
+ // first `await` (like `setCurrentUserId` above) so queries that
389
+ // re-register on this auth flip see the fresh context, not a stale one.
390
+ this.streamProcessor.setSessionAuth(
391
+ this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
392
+ this.auth.access
393
+ );
371
394
  const next = await this.fetchSessionId();
372
395
  this.dataModule.setSessionId(next);
373
396
  this.crdtManager.setSessionId(next);
@@ -384,6 +407,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
384
407
  await this.sync.init();
385
408
  this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
386
409
 
410
+ this.featureFlags.init();
411
+ this.logger.debug(
412
+ { Category: 'sp00ky-client::Sp00kyClient::init' },
413
+ 'FeatureFlagModule initialized'
414
+ );
415
+
387
416
  this.logger.info(
388
417
  { Category: 'sp00ky-client::Sp00kyClient::init' },
389
418
  'Sp00kyClient initialization completed successfully'
@@ -398,11 +427,25 @@ export class Sp00kyClient<S extends SchemaStructure> {
398
427
  }
399
428
 
400
429
  async close() {
430
+ await this.featureFlags.closeAll();
401
431
  this.crdtManager.closeAll();
402
432
  await this.local.close();
403
433
  await this.remote.close();
404
434
  }
405
435
 
436
+ /**
437
+ * Subscribe to a feature flag for the current user. Returns a
438
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
439
+ * accessors reflect the latest assignment from `_00_user_feature`,
440
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
441
+ *
442
+ * Permissions are enforced by SurrealDB: a client can only ever see
443
+ * its own row, and cannot create or modify assignments.
444
+ */
445
+ feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
446
+ return this.featureFlags.feature(key, options);
447
+ }
448
+
406
449
  authenticate(token: string) {
407
450
  return this.remote.getClient().authenticate(token);
408
451
  }
package/src/types.ts CHANGED
@@ -178,6 +178,14 @@ export interface QueryConfig {
178
178
  localArray: RecordVersionArray;
179
179
  /** The version array representing the remote (server) state of results. */
180
180
  remoteArray: RecordVersionArray;
181
+ /**
182
+ * In-memory only (never persisted to `_00_query`): version array of the
183
+ * subquery CHILD rows pulled via `parent IS NOT NONE` edges, so the
184
+ * child-body sync is idempotent across polls. Kept separate from
185
+ * `remoteArray` so related child rows never enter the primary window /
186
+ * `rowCount` / `localArray`.
187
+ */
188
+ subqueryRemoteArray?: RecordVersionArray;
181
189
  /** Time-To-Live for this query. */
182
190
  ttl: QueryTimeToLive;
183
191
  /** Timestamp when the query was last accessed/active. */