@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.
package/dist/index.js CHANGED
@@ -747,6 +747,37 @@ var DataModule = class {
747
747
  return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
748
748
  }
749
749
  /**
750
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
751
+ * `batch` (recursing for nested related fields). An embedded child is a
752
+ * value that is itself a record — a non-null object whose `id` is a
753
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
754
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
755
+ * so this never mistakes a FK column for an embedded body. Children are
756
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
757
+ * to their table's real columns (which strips the alias/related fields).
758
+ * `seen` dedupes within the batch.
759
+ */
760
+ collectEmbeddedChildren(record, batch, seen) {
761
+ const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
762
+ for (const value of Object.values(record)) {
763
+ const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
764
+ for (const child of children) {
765
+ const key = encodeRecordId(child.id);
766
+ if (seen.has(key)) continue;
767
+ seen.add(key);
768
+ this.collectEmbeddedChildren(child, batch, seen);
769
+ const table = child.id.table.toString();
770
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
771
+ batch.push({
772
+ table,
773
+ op: "CREATE",
774
+ record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
775
+ version: child._00_rv || 1
776
+ });
777
+ }
778
+ }
779
+ }
780
+ /**
750
781
  * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
751
782
  * surql run directly) so the query DISPLAYS immediately, while the full realtime
752
783
  * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
@@ -766,6 +797,8 @@ var DataModule = class {
766
797
  record,
767
798
  version: record._00_rv || 1
768
799
  }));
800
+ const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
801
+ for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
769
802
  await this.cache.saveBatch(batch);
770
803
  queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
771
804
  queryState.records = await this.materializeRecords(queryState);
@@ -2244,6 +2277,21 @@ function buildListRefSelect(table) {
2244
2277
  return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
2245
2278
  }
2246
2279
  /**
2280
+ * Build the SurrealQL select for a query's SUBQUERY child edges — the
2281
+ * mirror of {@link buildListRefSelect}. `.related()` queries register a
2282
+ * correlated subquery; the SSP materializes each matched child as a
2283
+ * `_00_list_ref` edge tagged with `parent`/`parent_rel` (see
2284
+ * `apps/ssp` edge writer). `parent IS NONE` (the primary select) drops
2285
+ * these, so their bodies never reach the local cache and a cold-reload
2286
+ * re-materialization of the correlated surql yields empty related
2287
+ * fields. This `parent IS NOT NONE` variant pulls the child `out`+`version`
2288
+ * pairs (any nesting depth) so we can sync their bodies into the local
2289
+ * store SEPARATELY from the primary window array.
2290
+ */
2291
+ function buildSubqueryListRefSelect(table) {
2292
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
2293
+ }
2294
+ /**
2247
2295
  * Resolve the effective list-ref poll interval. Negative or zero
2248
2296
  * values fall back to the default — accepting them would either
2249
2297
  * disable polling silently or busy-loop the event loop.
@@ -2743,6 +2791,13 @@ var Sp00kySync = class {
2743
2791
  Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2744
2792
  }, "syncQuery failed during poll");
2745
2793
  }
2794
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
2795
+ this.logger.info({
2796
+ err: err?.message ?? err,
2797
+ queryHash,
2798
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2799
+ }, "Subquery child sync failed during poll");
2800
+ });
2746
2801
  if (removedIds.length > 0) try {
2747
2802
  await this.dataModule.notifyQuerySynced(queryHash);
2748
2803
  } catch (err) {
@@ -2827,7 +2882,15 @@ var Sp00kySync = class {
2827
2882
  Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
2828
2883
  }, "Live update received");
2829
2884
  if (message.action === "KILLED") return;
2830
- if (message.value.parent != null) return;
2885
+ if (message.value.parent != null) {
2886
+ this.handleRemoteSubqueryChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
2887
+ this.logger.error({
2888
+ err,
2889
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
2890
+ }, "Error handling remote subquery change");
2891
+ });
2892
+ return;
2893
+ }
2831
2894
  this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
2832
2895
  this.logger.error({
2833
2896
  err,
@@ -2861,6 +2924,41 @@ var Sp00kySync = class {
2861
2924
  await this.runSyncForQuery(hash, diff);
2862
2925
  }
2863
2926
  /**
2927
+ * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
2928
+ * `parent` set) for a `.related()` query. Unlike primary rows, child rows
2929
+ * must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
2930
+ * keep the child BODY fresh in the local cache so the in-browser SSP's
2931
+ * subquery-table dependency re-materializes the parent view.
2932
+ *
2933
+ * CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
2934
+ * no-op: a child leaving this query's set must not delete a body another
2935
+ * query may still show (see `syncSubqueryChildren` deletion-safety note);
2936
+ * a genuine record delete propagates via the normal delete path.
2937
+ */
2938
+ async handleRemoteSubqueryChange(action, queryId, childId, version) {
2939
+ this.lastLiveEventAt = Date.now();
2940
+ this.listRefIdleStreak = 0;
2941
+ if (action === "DELETE") return;
2942
+ const existing = this.dataModule.getQueryById(queryId);
2943
+ if (!existing) return;
2944
+ const item = {
2945
+ id: childId,
2946
+ version
2947
+ };
2948
+ await this.syncEngine.syncRecords(action === "CREATE" ? {
2949
+ added: [item],
2950
+ updated: [],
2951
+ removed: []
2952
+ } : {
2953
+ added: [],
2954
+ updated: [item],
2955
+ removed: []
2956
+ });
2957
+ const key = encodeRecordId(childId);
2958
+ const prev = existing.config.subqueryRemoteArray ?? [];
2959
+ existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
2960
+ }
2961
+ /**
2864
2962
  * Enqueues a 'down' event (from remote to local) for processing.
2865
2963
  * @param event The DownEvent to enqueue.
2866
2964
  */
@@ -3083,6 +3181,49 @@ var Sp00kySync = class {
3083
3181
  Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
3084
3182
  }, "createdRemoteQuery");
3085
3183
  if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
3184
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
3185
+ this.logger.info({
3186
+ err: err?.message ?? err,
3187
+ queryHash,
3188
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
3189
+ }, "Subquery child sync failed during registration; poll will retry");
3190
+ });
3191
+ }
3192
+ /**
3193
+ * Sync the BODIES of a `.related()` query's subquery child rows into the
3194
+ * local cache, separately from the primary window array. The SSP writes
3195
+ * each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
3196
+ * `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
3197
+ * nesting depth). We diff against the in-memory `subqueryRemoteArray` and
3198
+ * fetch added/updated bodies through the SyncEngine — which `saveBatch`s
3199
+ * them into the local DB AND the in-browser SSP, whose subquery-table
3200
+ * dependency then re-materializes the parent view (no explicit notify).
3201
+ *
3202
+ * Deletion safety: we pass `removed: []` deliberately. A child body can be
3203
+ * shared by other queries; letting `handleRemovedRecords` delete one that
3204
+ * merely left THIS query's child set would clobber data another query still
3205
+ * shows. Genuine record deletes flow through the normal delete path; a
3206
+ * lingering orphan body is invisible (the correlated WHERE stops matching).
3207
+ *
3208
+ * Kept off `runSyncForQuery` on purpose so child fetches never flip the
3209
+ * query to `fetching` or skew its DevTools timings.
3210
+ */
3211
+ async syncSubqueryChildren(queryHash) {
3212
+ const queryState = this.dataModule.getQueryByHash(queryHash);
3213
+ if (!queryState) return;
3214
+ const listRefTbl = this.listRefTable();
3215
+ const [items] = await this.remote.query(buildSubqueryListRefSelect(listRefTbl), { in: queryState.config.id });
3216
+ if (!Array.isArray(items)) return;
3217
+ const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
3218
+ const prev = queryState.config.subqueryRemoteArray ?? [];
3219
+ if (recordVersionArraysEqual(fresh, prev)) return;
3220
+ const diff = diffRecordVersionArray(prev, fresh);
3221
+ if (diff.added.length > 0 || diff.updated.length > 0) await this.syncEngine.syncRecords({
3222
+ added: diff.added,
3223
+ updated: diff.updated,
3224
+ removed: []
3225
+ });
3226
+ queryState.config.subqueryRemoteArray = fresh;
3086
3227
  }
3087
3228
  async heartbeatQuery(queryHash) {
3088
3229
  const queryState = this.dataModule.getQueryByHash(queryHash);
@@ -3171,8 +3312,8 @@ function parseBackendInfo(raw) {
3171
3312
 
3172
3313
  //#endregion
3173
3314
  //#region src/modules/devtools/index.ts
3174
- const CORE_VERSION = "0.0.1-canary.74";
3175
- const WASM_VERSION = "0.0.1-canary.74";
3315
+ const CORE_VERSION = "0.0.1-canary.76";
3316
+ const WASM_VERSION = "0.0.1-canary.76";
3176
3317
  const SURREAL_VERSION = "3.0.3";
3177
3318
  var DevToolsService = class {
3178
3319
  eventsHistory = [];
@@ -3463,10 +3604,38 @@ var DevToolsService = class {
3463
3604
 
3464
3605
  //#endregion
3465
3606
  //#region src/modules/auth/index.ts
3607
+ /**
3608
+ * Read the `AC` (access-method name) claim from a SurrealDB record-access
3609
+ * JWT without verifying it — we only need the claim, the server enforces the
3610
+ * token. Returns null on any malformed input. The in-browser SSP needs this
3611
+ * to resolve `$access` in table permission predicates (mirrors the session's
3612
+ * `$access` that the server's `fn::query::register` reads).
3613
+ */
3614
+ function decodeAccessFromToken(token) {
3615
+ try {
3616
+ const payload = token.split(".")[1];
3617
+ if (!payload) return null;
3618
+ let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3619
+ b64 += "=".repeat((4 - b64.length % 4) % 4);
3620
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
3621
+ const claims = JSON.parse(json);
3622
+ const ac = claims.AC ?? claims.ac;
3623
+ return typeof ac === "string" ? ac : null;
3624
+ } catch {
3625
+ return null;
3626
+ }
3627
+ }
3466
3628
  var AuthService = class {
3467
3629
  token = null;
3468
3630
  currentUser = null;
3469
3631
  isAuthenticated = false;
3632
+ /**
3633
+ * The record-access method name for the current session (e.g. `"account"`),
3634
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
3635
+ * permission injection so `$access`-gated table predicates resolve locally,
3636
+ * mirroring the server's `$access`. Null when logged out.
3637
+ */
3638
+ access = null;
3470
3639
  isLoading = true;
3471
3640
  events = createAuthEventSystem();
3472
3641
  get eventSystem() {
@@ -3559,6 +3728,7 @@ var AuthService = class {
3559
3728
  this.token = null;
3560
3729
  this.currentUser = null;
3561
3730
  this.isAuthenticated = false;
3731
+ this.access = null;
3562
3732
  await this.persistenceClient.remove("sp00ky_auth_token");
3563
3733
  try {
3564
3734
  await this.remote.getClient().invalidate();
@@ -3569,9 +3739,16 @@ var AuthService = class {
3569
3739
  this.token = token;
3570
3740
  this.currentUser = user;
3571
3741
  this.isAuthenticated = true;
3742
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
3572
3743
  await this.persistenceClient.set("sp00ky_auth_token", token);
3573
3744
  this.notifyListeners();
3574
3745
  }
3746
+ /** Fallback when the token carries no `AC` claim: if the schema defines
3747
+ * exactly one record-access method, assume the session used it. */
3748
+ defaultAccessName() {
3749
+ const names = Object.keys(this.schema.access ?? {});
3750
+ return names.length === 1 ? names[0] : null;
3751
+ }
3575
3752
  async signUp(accessName, params) {
3576
3753
  const def = this.getAccessDefinition(accessName);
3577
3754
  if (!def) throw new Error(`Access definition '${accessName}' not found`);
@@ -3617,6 +3794,10 @@ var StreamProcessorService = class {
3617
3794
  receivers = [];
3618
3795
  batching = false;
3619
3796
  batchBuffer = /* @__PURE__ */ new Map();
3797
+ sessionAuth = {
3798
+ authId: "",
3799
+ access: ""
3800
+ };
3620
3801
  constructor(events, db, persistenceClient, logger) {
3621
3802
  this.events = events;
3622
3803
  this.db = db;
@@ -3757,6 +3938,27 @@ var StreamProcessorService = class {
3757
3938
  Category: "sp00ky-client::StreamProcessorService::setPermissions"
3758
3939
  }, "Seeded table permissions");
3759
3940
  }
3941
+ /**
3942
+ * Set the current session's auth identity for permission injection,
3943
+ * mirroring the server's `fn::query::register`
3944
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
3945
+ * Stored as strings (empty when logged out) and applied to every
3946
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
3947
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
3948
+ * in-browser SSP's `permission_inject` rejects it with
3949
+ * "requires $auth but registration params lack it".
3950
+ */
3951
+ setSessionAuth(authId, access) {
3952
+ this.sessionAuth = {
3953
+ authId: authId ?? "",
3954
+ access: access ?? ""
3955
+ };
3956
+ this.logger.debug({
3957
+ authId: this.sessionAuth.authId,
3958
+ access: this.sessionAuth.access,
3959
+ Category: "sp00ky-client::StreamProcessorService::setSessionAuth"
3960
+ }, "Session auth context updated");
3961
+ }
3760
3962
  async saveState() {
3761
3963
  if (!this.processor) return;
3762
3964
  try {
@@ -3841,11 +4043,15 @@ var StreamProcessorService = class {
3841
4043
  Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
3842
4044
  }, "Registering query plan");
3843
4045
  try {
3844
- const normalizedParams = this.normalizeValue(queryPlan.params);
4046
+ const paramsWithAuth = {
4047
+ ...this.normalizeValue(queryPlan.params),
4048
+ auth: { id: this.sessionAuth.authId },
4049
+ access: this.sessionAuth.access
4050
+ };
3845
4051
  const initialUpdate = this.processor.register_view({
3846
4052
  id: queryPlan.queryHash,
3847
4053
  surql: queryPlan.surql,
3848
- params: normalizedParams,
4054
+ params: paramsWithAuth,
3849
4055
  clientId: "local",
3850
4056
  ttl: queryPlan.ttl.toString(),
3851
4057
  lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -4626,6 +4832,135 @@ var CrdtManager = class {
4626
4832
  }
4627
4833
  };
4628
4834
 
4835
+ //#endregion
4836
+ //#region src/modules/feature-flag/index.ts
4837
+ const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature WHERE key = $key";
4838
+ var FeatureFlagHandle = class {
4839
+ latest = {
4840
+ variant: void 0,
4841
+ payload: void 0
4842
+ };
4843
+ listeners = /* @__PURE__ */ new Set();
4844
+ unsubscribeFn = null;
4845
+ onCloseFn = null;
4846
+ closed = false;
4847
+ constructor(key, fallback) {
4848
+ this.key = key;
4849
+ this.fallback = fallback;
4850
+ }
4851
+ attach(unsubscribe) {
4852
+ this.unsubscribeFn?.();
4853
+ this.unsubscribeFn = unsubscribe;
4854
+ }
4855
+ detach() {
4856
+ this.unsubscribeFn?.();
4857
+ this.unsubscribeFn = null;
4858
+ }
4859
+ set(snapshot) {
4860
+ if (this.closed) return;
4861
+ this.latest = snapshot;
4862
+ for (const cb of this.listeners) cb(snapshot);
4863
+ }
4864
+ variant() {
4865
+ return this.latest.variant ?? this.fallback;
4866
+ }
4867
+ payload() {
4868
+ return this.latest.payload;
4869
+ }
4870
+ enabled() {
4871
+ const v = this.variant();
4872
+ return v !== void 0 && v !== "off";
4873
+ }
4874
+ subscribe(cb) {
4875
+ this.listeners.add(cb);
4876
+ cb({
4877
+ variant: this.variant(),
4878
+ payload: this.latest.payload
4879
+ });
4880
+ return () => {
4881
+ this.listeners.delete(cb);
4882
+ };
4883
+ }
4884
+ onClose(cb) {
4885
+ this.onCloseFn = cb;
4886
+ }
4887
+ close() {
4888
+ if (this.closed) return;
4889
+ this.closed = true;
4890
+ this.listeners.clear();
4891
+ this.detach();
4892
+ this.onCloseFn?.();
4893
+ }
4894
+ };
4895
+ var FeatureFlagModule = class {
4896
+ logger;
4897
+ active = /* @__PURE__ */ new Set();
4898
+ authUnsubscribe = null;
4899
+ lastUserId = null;
4900
+ constructor(deps) {
4901
+ this.deps = deps;
4902
+ this.logger = deps.logger.child({ service: "FeatureFlagModule" });
4903
+ }
4904
+ init() {
4905
+ if (this.authUnsubscribe) return;
4906
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
4907
+ if (userId === this.lastUserId) return;
4908
+ this.lastUserId = userId;
4909
+ this.refreshAll();
4910
+ });
4911
+ }
4912
+ feature(key, options = {}) {
4913
+ const handle = new FeatureFlagHandle(key, options.fallback);
4914
+ const entry = {
4915
+ handle,
4916
+ ttl: options.ttl ?? "10m"
4917
+ };
4918
+ this.active.add(entry);
4919
+ handle.onClose(() => this.active.delete(entry));
4920
+ this.register(entry);
4921
+ return handle;
4922
+ }
4923
+ async closeAll() {
4924
+ this.authUnsubscribe?.();
4925
+ this.authUnsubscribe = null;
4926
+ for (const entry of [...this.active]) entry.handle.close();
4927
+ }
4928
+ async refreshAll() {
4929
+ for (const entry of this.active) {
4930
+ entry.handle.detach();
4931
+ entry.handle.set({
4932
+ variant: void 0,
4933
+ payload: void 0
4934
+ });
4935
+ await this.register(entry);
4936
+ }
4937
+ }
4938
+ async register(entry) {
4939
+ const { handle, ttl } = entry;
4940
+ try {
4941
+ const hash = await this.deps.dataModule.query("_00_user_feature", FEATURE_QUERY, { key: handle.key }, ttl);
4942
+ this.deps.sync.enqueueDownEvent({
4943
+ type: "register",
4944
+ payload: { hash }
4945
+ });
4946
+ const unsub = this.deps.dataModule.subscribe(hash, (records) => {
4947
+ const row = records[0];
4948
+ handle.set({
4949
+ variant: row?.variant,
4950
+ payload: row?.payload
4951
+ });
4952
+ }, { immediate: true });
4953
+ handle.attach(unsub);
4954
+ } catch (err) {
4955
+ this.logger.warn({
4956
+ err,
4957
+ key: handle.key,
4958
+ Category: "sp00ky-client::FeatureFlagModule::register"
4959
+ }, "Failed to register feature flag query");
4960
+ }
4961
+ }
4962
+ };
4963
+
4629
4964
  //#endregion
4630
4965
  //#region src/services/persistence/localstorage.ts
4631
4966
  var LocalStoragePersistenceClient = class {
@@ -4780,6 +5115,7 @@ var Sp00kyClient = class {
4780
5115
  sync;
4781
5116
  devTools;
4782
5117
  crdtManager;
5118
+ featureFlags;
4783
5119
  logger;
4784
5120
  auth;
4785
5121
  streamProcessor;
@@ -4829,6 +5165,12 @@ var Sp00kyClient = class {
4829
5165
  this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
4830
5166
  this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
4831
5167
  this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, { refSyncIntervalMs: this.config.refSyncIntervalMs });
5168
+ this.featureFlags = new FeatureFlagModule({
5169
+ dataModule: this.dataModule,
5170
+ sync: this.sync,
5171
+ auth: this.auth,
5172
+ logger
5173
+ });
4832
5174
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
4833
5175
  this.streamProcessor.addReceiver(this.devTools);
4834
5176
  this.setupCallbacks();
@@ -4911,6 +5253,7 @@ var Sp00kyClient = class {
4911
5253
  }, "DataModule initialized");
4912
5254
  this.auth.subscribe(async (userId) => {
4913
5255
  this.dataModule.setCurrentUserId(userId);
5256
+ this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
4914
5257
  const next = await this.fetchSessionId();
4915
5258
  this.dataModule.setSessionId(next);
4916
5259
  this.crdtManager.setSessionId(next);
@@ -4925,6 +5268,8 @@ var Sp00kyClient = class {
4925
5268
  });
4926
5269
  await this.sync.init();
4927
5270
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
5271
+ this.featureFlags.init();
5272
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
4928
5273
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
4929
5274
  } catch (e) {
4930
5275
  this.logger.error({
@@ -4935,10 +5280,23 @@ var Sp00kyClient = class {
4935
5280
  }
4936
5281
  }
4937
5282
  async close() {
5283
+ await this.featureFlags.closeAll();
4938
5284
  this.crdtManager.closeAll();
4939
5285
  await this.local.close();
4940
5286
  await this.remote.close();
4941
5287
  }
5288
+ /**
5289
+ * Subscribe to a feature flag for the current user. Returns a
5290
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
5291
+ * accessors reflect the latest assignment from `_00_user_feature`,
5292
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
5293
+ *
5294
+ * Permissions are enforced by SurrealDB: a client can only ever see
5295
+ * its own row, and cannot create or modify assignments.
5296
+ */
5297
+ feature(key, options) {
5298
+ return this.featureFlags.feature(key, options);
5299
+ }
4942
5300
  authenticate(token) {
4943
5301
  return this.remote.getClient().authenticate(token);
4944
5302
  }
@@ -5056,4 +5414,4 @@ var Sp00kyClient = class {
5056
5414
  };
5057
5415
 
5058
5416
  //#endregion
5059
- export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
5417
+ export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
package/dist/types.d.ts CHANGED
@@ -123,6 +123,32 @@ declare class EventSystem<E extends EventTypeMap> {
123
123
  private broadcastEvent;
124
124
  }
125
125
  //#endregion
126
+ //#region src/modules/sync/events/index.d.ts
127
+ declare const SyncEventTypes: {
128
+ readonly QueryUpdated: "SYNC_QUERY_UPDATED";
129
+ readonly RemoteDataIngested: "SYNC_REMOTE_DATA_INGESTED";
130
+ readonly MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK";
131
+ };
132
+ type SyncEventTypeMap = {
133
+ [SyncEventTypes.QueryUpdated]: EventDefinition<typeof SyncEventTypes.QueryUpdated, {
134
+ queryId: any;
135
+ localHash?: string;
136
+ localArray?: RecordVersionArray;
137
+ remoteHash?: string;
138
+ remoteArray?: RecordVersionArray;
139
+ records: Record<string, any>[];
140
+ }>;
141
+ [SyncEventTypes.RemoteDataIngested]: EventDefinition<typeof SyncEventTypes.RemoteDataIngested, {
142
+ records: Record<string, any>[];
143
+ }>;
144
+ [SyncEventTypes.MutationRolledBack]: EventDefinition<typeof SyncEventTypes.MutationRolledBack, {
145
+ eventType: string;
146
+ recordId: string;
147
+ error: string;
148
+ }>;
149
+ };
150
+ type SyncEventSystem = EventSystem<SyncEventTypeMap>;
151
+ //#endregion
126
152
  //#region src/services/logger/index.d.ts
127
153
  type Logger$1 = Logger;
128
154
  //#endregion
@@ -301,6 +327,14 @@ interface QueryConfig {
301
327
  localArray: RecordVersionArray;
302
328
  /** The version array representing the remote (server) state of results. */
303
329
  remoteArray: RecordVersionArray;
330
+ /**
331
+ * In-memory only (never persisted to `_00_query`): version array of the
332
+ * subquery CHILD rows pulled via `parent IS NOT NONE` edges, so the
333
+ * child-body sync is idempotent across polls. Kept separate from
334
+ * `remoteArray` so related child rows never enter the primary window /
335
+ * `rowCount` / `localArray`.
336
+ */
337
+ subqueryRemoteArray?: RecordVersionArray;
304
338
  /** Time-To-Live for this query. */
305
339
  ttl: QueryTimeToLive;
306
340
  /** Timestamp when the query was last accessed/active. */
@@ -457,4 +491,4 @@ interface DebounceOptions {
457
491
  delay?: number;
458
492
  }
459
493
  //#endregion
460
- export { Logger$1 as A, RunOptions as C, StoreType as D, Sp00kyQueryResultPromise as E, EventSystem as M, TimingPhase as O, RegistrationTimings as S, Sp00kyQueryResult as T, QueryTimeToLive as _, MutationCallback as a, RecordVersionArray as b, PersistenceClient as c, QueryConfig as d, QueryConfigRecord as f, QueryStatusCallback as g, QueryStatus as h, MATERIALIZATION_SAMPLE_WINDOW as i, EventDefinition as j, UpdateOptions as k, PhaseStat as l, QueryState as m, EventSubscriptionOptions as n, MutationEvent as o, QueryHash as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryTimings as v, Sp00kyConfig as w, RecordVersionDiff as x, QueryUpdateCallback as y };
494
+ export { UpEvent as A, RunOptions as C, StoreType as D, Sp00kyQueryResultPromise as E, SyncEventSystem as M, EventDefinition as N, TimingPhase as O, EventSystem as P, RegistrationTimings as S, Sp00kyQueryResult as T, QueryTimeToLive as _, MutationCallback as a, RecordVersionArray as b, PersistenceClient as c, QueryConfig as d, QueryConfigRecord as f, QueryStatusCallback as g, QueryStatus as h, MATERIALIZATION_SAMPLE_WINDOW as i, Logger$1 as j, UpdateOptions as k, PhaseStat as l, QueryState as m, EventSubscriptionOptions as n, MutationEvent as o, QueryHash as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryTimings as v, Sp00kyConfig as w, RecordVersionDiff as x, QueryUpdateCallback as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.74",
3
+ "version": "0.0.1-canary.76",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@spooky-sync/query-builder": "0.0.1-canary.74",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.74",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.76",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.76",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
package/src/index.ts CHANGED
@@ -2,4 +2,10 @@ export * from './types';
2
2
  export * from './sp00ky';
3
3
  export * from './modules/auth/index';
4
4
  export { CrdtField, CrdtManager, cursorColorFromName, CURSOR_COLORS } from './modules/crdt/index';
5
+ export {
6
+ FeatureFlagModule,
7
+ FeatureFlagHandle,
8
+ type FeatureFlagOptions,
9
+ type FeatureFlagSnapshot,
10
+ } from './modules/feature-flag/index';
5
11
  export { fileToUint8Array, textToHtml } from './utils/index';
@@ -35,11 +35,41 @@ type ExtractAccessParams<
35
35
  }>
36
36
  : never;
37
37
 
38
+ /**
39
+ * Read the `AC` (access-method name) claim from a SurrealDB record-access
40
+ * JWT without verifying it — we only need the claim, the server enforces the
41
+ * token. Returns null on any malformed input. The in-browser SSP needs this
42
+ * to resolve `$access` in table permission predicates (mirrors the session's
43
+ * `$access` that the server's `fn::query::register` reads).
44
+ */
45
+ function decodeAccessFromToken(token: string): string | null {
46
+ try {
47
+ const payload = token.split('.')[1];
48
+ if (!payload) return null;
49
+ let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
50
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
51
+ const json =
52
+ typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
53
+ const claims = JSON.parse(json) as Record<string, unknown>;
54
+ const ac = claims.AC ?? claims.ac;
55
+ return typeof ac === 'string' ? ac : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
38
61
  export class AuthService<S extends SchemaStructure> {
39
62
  // State
40
63
  public token: string | null = null;
41
64
  public currentUser: any | null = null;
42
65
  public isAuthenticated: boolean = false;
66
+ /**
67
+ * The record-access method name for the current session (e.g. `"account"`),
68
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
69
+ * permission injection so `$access`-gated table predicates resolve locally,
70
+ * mirroring the server's `$access`. Null when logged out.
71
+ */
72
+ public access: string | null = null;
43
73
  public isLoading: boolean = true;
44
74
 
45
75
  private events = createAuthEventSystem();
@@ -167,6 +197,7 @@ export class AuthService<S extends SchemaStructure> {
167
197
  this.token = null;
168
198
  this.currentUser = null;
169
199
  this.isAuthenticated = false;
200
+ this.access = null;
170
201
 
171
202
  await this.persistenceClient.remove('sp00ky_auth_token');
172
203
 
@@ -183,10 +214,21 @@ export class AuthService<S extends SchemaStructure> {
183
214
  this.token = token;
184
215
  this.currentUser = user;
185
216
  this.isAuthenticated = true;
217
+ // Resolve the access-method name (e.g. "account") for in-browser SSP
218
+ // permission injection. Prefer the token's `AC` claim; fall back to the
219
+ // schema's sole record-access method if the claim is absent.
220
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
186
221
  await this.persistenceClient.set('sp00ky_auth_token', token);
187
222
  this.notifyListeners();
188
223
  }
189
224
 
225
+ /** Fallback when the token carries no `AC` claim: if the schema defines
226
+ * exactly one record-access method, assume the session used it. */
227
+ private defaultAccessName(): string | null {
228
+ const names = Object.keys(this.schema.access ?? {});
229
+ return names.length === 1 ? names[0] : null;
230
+ }
231
+
190
232
  async signUp<Name extends keyof S['access'] & string>(
191
233
  accessName: Name,
192
234
  params: ExtractAccessParams<S, Name, 'signup'>