@spooky-sync/core 0.0.1-canary.321 → 0.0.1-canary.323

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 CHANGED
@@ -42,7 +42,7 @@ re-materialized from state, never patched in place.
42
42
  - Types: `Sp00kyConfig` (client-solid wraps it as `SyncedDbConfig`), `QueryTimeToLive`, `PersistenceClient`, `StoreType`, `UpdateOptions`, `RunOptions`, `PreloadOptions`.
43
43
  - Subpath: `@spooky-sync/core/otel` — `createOtelTransmit(endpoint)` for piping pino logs to OpenTelemetry.
44
44
  - `client.webPush` (`WebPushModule`, `db.webPush` in the Solid bindings) - Web Push on the page: `support()`, `info()`, `subscribe(opts)` (call from a click), `unsubscribe(opts)`, `sync()`, `isSubscribed()`, `update(opts)`, `devices()`, `notify(msg)`, `cancel(id)`, `test()`, `bridge()`, `onMessage(cb)`. Server calls are `fn::push::*`; typed `WebPushError`; config `webPush` in `types.ts`. Sign-out runs through `AuthService.onBeforeSignOut`.
45
- - Subpaths for service workers, workers and Node (no wasm, no pino, no DOM; `scripts/check-worker-entries.mjs` enforces it in `build`): `@spooky-sync/core/pure` (protocol builders), `@spooky-sync/core/live` (`createLiveFeed`, the live subscriber over the pure-JS SDK), `@spooky-sync/core/sw` (`installPushHandlers`, `getBridgedToken`). Built by the second tsdown config; chunks are prefixed `worker-`. See `docs/web-push-design.md` at the repo root.
45
+ - Subpaths for service workers, workers and Node (no wasm, no pino, no DOM; `scripts/check-worker-entries.mjs` enforces it in `build`): `@spooky-sync/core/pure` (protocol builders), `@spooky-sync/core/live` (`createLiveFeed`, the live subscriber over the pure-JS SDK; a feed that shares views with another through the same `sessionId` must also pass the same `ownerSecret`, or a signed-out feed's second registration is refused 409 `owner_mismatch`), `@spooky-sync/core/sw` (`installPushHandlers`, `getBridgedToken`). Built by the second tsdown config; chunks are prefixed `worker-`. See `docs/web-push-design.md` at the repo root.
46
46
 
47
47
  ## Common gotchas
48
48
 
package/dist/index.d.ts CHANGED
@@ -303,6 +303,10 @@ interface CircuitPrimeContext {
303
303
  /** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
304
304
  * locally and may exceed the server's next version). */
305
305
  pendingIds: Set<string>;
306
+ /** Each table's client column set (`columnFingerprints`). An engine that
307
+ * keeps bodies across schema changes resets the versions of a table whose
308
+ * set changed before they are read, so its rows are fetched again whole. */
309
+ columnFingerprints?: Record<string, string>;
306
310
  /** Receives every `(id, rv)` the prime put into the circuit, per table, so
307
311
  * the sync layer can skip re-downloading bodies it already has. */
308
312
  onVersions?: (table: string, entries: [string, number][]) => void;
@@ -1123,7 +1127,7 @@ type Effect = {
1123
1127
  kind: 'now';
1124
1128
  } | {
1125
1129
  kind: 'id';
1126
- scope: 'mutation' | 'salt';
1130
+ scope: IdScope;
1127
1131
  } | {
1128
1132
  kind: 'hash';
1129
1133
  input: string;
@@ -1141,6 +1145,8 @@ type Effect = {
1141
1145
  name: ServiceName;
1142
1146
  args: unknown[];
1143
1147
  };
1148
+ /** `mutation`: an outbox row id. `salt`: a query-key salt. `owner`: a page's anonymous-view owner secret. */
1149
+ type IdScope = 'mutation' | 'salt' | 'owner';
1144
1150
  /**
1145
1151
  * Calls into the legacy services that are adapters, not state: auth, tabs,
1146
1152
  * blobs, migrator, crdt, persistence, the SSP lifecycle, the remote socket.
@@ -1149,8 +1155,12 @@ type Effect = {
1149
1155
  interface ServiceCalls {
1150
1156
  'hint.read': () => string | null;
1151
1157
  'hint.write': (bucketId: string) => void;
1152
- 'salt.take': (authId: string | null) => string | null;
1153
- 'salt.leave': (salt: string, authId: string | null) => void;
1158
+ /** The salt (and owner secret) this tab's previous page left, if minted for `authId`. */
1159
+ 'salt.take': (authId: string | null) => {
1160
+ salt: string;
1161
+ owner: string | null;
1162
+ } | null;
1163
+ 'salt.leave': (salt: string, authId: string | null, owner: string | null) => void;
1154
1164
  'local.connect': (bucketId: string) => Promise<void>;
1155
1165
  'local.switchStore': (bucketId: string) => Promise<void>;
1156
1166
  'local.beginSwitch': () => () => void;
@@ -1309,6 +1319,13 @@ interface ClientState {
1309
1319
  readonly userId: string | null;
1310
1320
  /** The principal the current session salt was minted for. */
1311
1321
  readonly saltUserId: string | null;
1322
+ /**
1323
+ * This page's secret, sent with every registration (`$config.owner`). An
1324
+ * anonymous view answers only to the secret it was registered with, so it
1325
+ * lives as long as the page's views: a reconnect keeps it, and a reload
1326
+ * that keeps the salt keeps it too.
1327
+ */
1328
+ readonly ownerSecret: string | null;
1312
1329
  /** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
1313
1330
  readonly pendingBucket: string | null;
1314
1331
  readonly tabId: string;
@@ -1428,10 +1445,7 @@ interface Adapters {
1428
1445
  clock: {
1429
1446
  now(): number;
1430
1447
  };
1431
- ids: {
1432
- mutation(): string;
1433
- salt(): string;
1434
- };
1448
+ ids: Record<IdScope, () => string>;
1435
1449
  hash(input: string): Promise<string>;
1436
1450
  services: ServiceCalls;
1437
1451
  }
package/dist/index.js CHANGED
@@ -569,6 +569,16 @@ function generateId() {
569
569
  return Uuid.v4().toString().replace(/-/g, "");
570
570
  }
571
571
  /**
572
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
573
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
574
+ * than `randomUUID`, which a page served over plain http does not have.
575
+ */
576
+ function mintOwnerSecret() {
577
+ const bytes = new Uint8Array(32);
578
+ globalThis.crypto.getRandomValues(bytes);
579
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
580
+ }
581
+ /**
572
582
  * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
573
583
  * releases expose it under `milliseconds` (current) or the older private
574
584
  * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
@@ -2178,8 +2188,8 @@ function trimHistory(history, cap) {
2178
2188
 
2179
2189
  //#endregion
2180
2190
  //#region src/modules/devtools/index.ts
2181
- const CORE_VERSION = "0.0.1-canary.321";
2182
- const WASM_VERSION = "0.0.1-canary.321";
2191
+ const CORE_VERSION = "0.0.1-canary.323";
2192
+ const WASM_VERSION = "0.0.1-canary.323";
2183
2193
  const SURREAL_VERSION = "3.0.3";
2184
2194
  var DevToolsService = class DevToolsService {
2185
2195
  version = CORE_VERSION;
@@ -3989,6 +3999,7 @@ function emptyState(init) {
3989
3999
  sessionId: null,
3990
4000
  userId: null,
3991
4001
  saltUserId: null,
4002
+ ownerSecret: null,
3992
4003
  pendingBucket: null,
3993
4004
  tabId: init.tabId,
3994
4005
  tabRole: "solo",
@@ -4825,7 +4836,8 @@ function* registerRemote(env, hash, retry = false) {
4825
4836
  id: entry.def.id,
4826
4837
  surql: entry.def.surql,
4827
4838
  params: { ...entry.def.params },
4828
- ttl: entry.def.ttl
4839
+ ttl: entry.def.ttl,
4840
+ ...state.ownerSecret ? { owner: state.ownerSecret } : {}
4829
4841
  }), env.remoteTimeoutMs);
4830
4842
  if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4831
4843
  const register = stmt(results, 0);
@@ -5873,11 +5885,14 @@ function* boot(env, opts) {
5873
5885
  yield fx.dispatch({ type: "PrimeCircuit" });
5874
5886
  const restoredUserId = yield fx.service("auth.restoreSession");
5875
5887
  const authId = yield fx.service("auth.sessionAuthId");
5876
- const salt = (yield fx.service("salt.take", authId)) ?? (yield fx.id("salt"));
5888
+ const left = yield fx.service("salt.take", authId);
5889
+ const salt = left?.salt ?? (yield fx.id("salt"));
5890
+ const ownerSecret = left?.owner ?? (yield fx.id("owner"));
5877
5891
  yield fx.state.update(setIdentity({
5878
5892
  sessionId: salt,
5879
5893
  userId: restoredUserId,
5880
- saltUserId: authId
5894
+ saltUserId: authId,
5895
+ ownerSecret
5881
5896
  }));
5882
5897
  yield fx.service("crdt.setSessionId", salt);
5883
5898
  if (restoredUserId) yield fx.service("ssp.setSessionAuth", authId, yield fx.service("auth.access"));
@@ -5972,12 +5987,13 @@ function* versionsPrimed(entries) {
5972
5987
  * grace finds its views still warm.
5973
5988
  */
5974
5989
  function* pageHide() {
5975
- const [salt, saltUserId, ids] = yield fx.state.read((s) => [
5990
+ const [salt, saltUserId, ownerSecret, ids] = yield fx.state.read((s) => [
5976
5991
  s.sessionId,
5977
5992
  s.saltUserId,
5993
+ s.ownerSecret,
5978
5994
  [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered").map((e) => e.def.id)
5979
5995
  ]);
5980
- if (salt) yield fx.service("salt.leave", salt, saltUserId);
5996
+ if (salt) yield fx.service("salt.leave", salt, saltUserId, ownerSecret);
5981
5997
  if (ids.length > 0) yield fx.service("remote.releaseViews", ids);
5982
5998
  }
5983
5999
  function* warmBlobs() {
@@ -6084,7 +6100,7 @@ function createInterpreter(adapters, host) {
6084
6100
  }
6085
6101
  case "state.wait": return host.waitFor(effect.until, ctx?.signal);
6086
6102
  case "now": return adapters.clock.now();
6087
- case "id": return effect.scope === "mutation" ? adapters.ids.mutation() : adapters.ids.salt();
6103
+ case "id": return adapters.ids[effect.scope]();
6088
6104
  case "hash": return adapters.hash(effect.input);
6089
6105
  case "emit": return host.emit(effect.event);
6090
6106
  case "dispatch": return host.dispatch(effect.event);
@@ -9611,6 +9627,84 @@ var PortSqliteTransport = class extends BaseTransport {
9611
9627
  }
9612
9628
  };
9613
9629
 
9630
+ //#endregion
9631
+ //#region src/services/database/sqlite-column-reset.ts
9632
+ /**
9633
+ * Cached bodies outlive a schema change on the SQLite engine, and that loses
9634
+ * columns for good.
9635
+ *
9636
+ * A body is landed through `cleanRecord`, which keeps only the columns the
9637
+ * running app's schema declares, and is stored at the server's `_00_rv`. The
9638
+ * engine is schemaless (no migrator, nothing is recreated when the schema
9639
+ * changes), so a tab on an older build that fetched a row after the server
9640
+ * added and backfilled a column stored it WITHOUT that column at the new
9641
+ * version. The next build knows the column, but its local version equals the
9642
+ * server's, so the fetch plan never asks for the row again and the column
9643
+ * stays missing until something else bumps the row. (WhitePawn, 2026-10-11:
9644
+ * every puzzle read as unconverted because `layout` never arrived.)
9645
+ *
9646
+ * So the store remembers each table's column set (`_00_schema`, row
9647
+ * `columns`). On boot, before the prime reads the versions, a table whose set
9648
+ * changed, or every table when no set was recorded yet, gets its cached
9649
+ * `_00_rv` reset to 0. The bodies stay and keep rendering; any view that names
9650
+ * one of those rows sees it as stale and fetches it again, whole. Rows with an
9651
+ * unsettled local write keep their version: their echo is a newer body anyway.
9652
+ */
9653
+ /** `_00_schema` row holding the recorded column sets. */
9654
+ const COLUMNS_ROW_ID = "columns";
9655
+ /**
9656
+ * The column set of every table, as `cleanRecord` sees it: the sorted declared
9657
+ * column names. Plain names rather than a hash, so a stored set says what it is.
9658
+ */
9659
+ function columnFingerprints(tables) {
9660
+ const out = {};
9661
+ for (const t of tables) out[t.name] = Object.keys(t.columns).sort().join(",");
9662
+ return out;
9663
+ }
9664
+ const sameSets = (a, b) => {
9665
+ if (!a) return false;
9666
+ const ka = Object.keys(a);
9667
+ return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
9668
+ };
9669
+ /**
9670
+ * What to run for the column sets `current`, given the `stored` ones (null when
9671
+ * none were recorded), the tables that exist in the store, and the encoded ids
9672
+ * (`table:id`) with an unsettled local write.
9673
+ */
9674
+ function planColumnReset(stored, current, existingTables, keep) {
9675
+ if (sameSets(stored, current)) return {
9676
+ changed: [],
9677
+ statements: []
9678
+ };
9679
+ const keepByTable = /* @__PURE__ */ new Map();
9680
+ for (const id of keep) {
9681
+ const table = id.slice(0, id.indexOf(":"));
9682
+ if (!keepByTable.has(table)) keepByTable.set(table, []);
9683
+ keepByTable.get(table).push(id);
9684
+ }
9685
+ const changed = [];
9686
+ const statements = [];
9687
+ for (const [table, set] of Object.entries(current)) {
9688
+ if (stored && stored[table] === set) continue;
9689
+ if (!existingTables.has(table)) continue;
9690
+ changed.push(table);
9691
+ const reset = `UPDATE ${`"${table.replace(/"/g, "\"\"")}"`} SET data = json_set(data, '$._00_rv', 0) WHERE json_extract(data, '$._00_rv') != 0`;
9692
+ const kept = keepByTable.get(table);
9693
+ statements.push(kept ? {
9694
+ sql: `${reset} AND id NOT IN (SELECT value FROM json_each(?))`,
9695
+ bind: [JSON.stringify(kept)]
9696
+ } : { sql: reset });
9697
+ }
9698
+ statements.push({
9699
+ sql: `INSERT OR REPLACE INTO "_00_schema"(id, data) VALUES (?, ?)`,
9700
+ bind: [COLUMNS_ROW_ID, JSON.stringify(current)]
9701
+ });
9702
+ return {
9703
+ changed,
9704
+ statements
9705
+ };
9706
+ }
9707
+
9614
9708
  //#endregion
9615
9709
  //#region src/services/database/sqlite-cache-engine.ts
9616
9710
  /**
@@ -10243,6 +10337,28 @@ var SqliteCacheEngine = class {
10243
10337
  }
10244
10338
  return out;
10245
10339
  }
10340
+ /**
10341
+ * Reset the cached `_00_rv` of every table whose column set changed since
10342
+ * the last boot, so its rows are fetched again whole. See
10343
+ * `sqlite-column-reset.ts`. Returns the tables reset.
10344
+ */
10345
+ async resetChangedTables(fingerprints, keep) {
10346
+ const { rows } = await this.call("exec", {
10347
+ sql: `SELECT data FROM "_00_schema" WHERE id = ?`,
10348
+ bind: [COLUMNS_ROW_ID]
10349
+ });
10350
+ let stored = null;
10351
+ try {
10352
+ const raw = rows?.[0]?.data;
10353
+ if (typeof raw === "string") stored = JSON.parse(raw);
10354
+ } catch {
10355
+ stored = null;
10356
+ }
10357
+ const { rows: tables } = await this.call("exec", { sql: `SELECT name FROM sqlite_master WHERE type = 'table'` });
10358
+ const plan = planColumnReset(stored, fingerprints, new Set((tables ?? []).map((t) => t.name)), keep);
10359
+ if (plan.statements.length > 0) await this.call("batch", plan.statements);
10360
+ return plan.changed;
10361
+ }
10246
10362
  async getSnapshot(key) {
10247
10363
  const { rows } = await this.call("exec", {
10248
10364
  sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
@@ -10939,6 +11055,19 @@ var StreamProcessorService = class {
10939
11055
  const epoch = this.db.epoch;
10940
11056
  const alive = () => this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
10941
11057
  const t0 = performance.now();
11058
+ if (ctx.columnFingerprints && typeof this.db.resetChangedTables === "function") try {
11059
+ const reset = await this.db.resetChangedTables(ctx.columnFingerprints, ctx.pendingIds);
11060
+ if (!alive()) return;
11061
+ if (reset.length > 0) this.logger.info({
11062
+ tables: reset,
11063
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
11064
+ }, "Columns changed since these rows were cached; they will be fetched again");
11065
+ } catch (e) {
11066
+ this.logger.warn({
11067
+ error: e,
11068
+ Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
11069
+ }, "Column change check failed; cached versions kept");
11070
+ }
10942
11071
  let restored = false;
10943
11072
  if (this.persistCircuit && typeof this.db.getSnapshot === "function" && typeof processor.load_store_state === "function") {
10944
11073
  const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
@@ -14742,6 +14871,11 @@ const RELOAD_SALT_KEY = "sp00ky:salt";
14742
14871
  * query onto the `_00_query` row it already had, which the SSP answers from
14743
14872
  * the live view instead of building a new one.
14744
14873
  *
14874
+ * The page's owner secret travels with it: an anonymous row answers only to
14875
+ * the secret it was registered with, so the next page needs the same one to
14876
+ * rejoin those views. `null` from a page that predates it; the next page then
14877
+ * mints one, and the rows it left (which hold no secret) take the new one.
14878
+ *
14745
14879
  * Taken, not read: the key is absent while a page lives, so a duplicated tab
14746
14880
  * (which copies sessionStorage) mints its own salt rather than sharing this
14747
14881
  * tab's remote views.
@@ -14755,9 +14889,11 @@ function takeReloadSalt(authId) {
14755
14889
  const left = JSON.parse(raw);
14756
14890
  if (typeof left.salt !== "string" || left.salt === "" || (left.authId ?? null) !== authId) return null;
14757
14891
  const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid) => typeof sid === "string") : [];
14892
+ const owner = typeof left.owner === "string" && left.owner !== "" ? left.owner : null;
14758
14893
  return {
14759
14894
  salt: left.salt,
14760
- sessionIds
14895
+ sessionIds,
14896
+ owner
14761
14897
  };
14762
14898
  } catch {
14763
14899
  return null;
@@ -14769,13 +14905,15 @@ function takeReloadSalt(authId) {
14769
14905
  * The session ids of this page's sockets go with it. The views the next page
14770
14906
  * re-registers keep the edges this page's sockets stamped, and the anonymous
14771
14907
  * LIVE only hears edges stamped by a session it knows (see `listRefLive`).
14908
+ * So does the page's owner secret, which those views answer to.
14772
14909
  */
14773
- function leaveReloadSalt(salt, authId, sessionIds = []) {
14910
+ function leaveReloadSalt(salt, authId, sessionIds = [], owner = null) {
14774
14911
  try {
14775
14912
  if (typeof sessionStorage !== "undefined") sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({
14776
14913
  salt,
14777
14914
  authId,
14778
- sessionIds
14915
+ sessionIds,
14916
+ owner
14779
14917
  }));
14780
14918
  } catch {}
14781
14919
  }
@@ -14840,7 +14978,7 @@ function createTabsCoordinator(config, s, host) {
14840
14978
  return new TabsCoordinator({
14841
14979
  tabId: s.tabId,
14842
14980
  fingerprint: computeTabsFingerprint({
14843
- coreVersion: "0.0.1-canary.321",
14981
+ coreVersion: "0.0.1-canary.323",
14844
14982
  schemaHash: hash53(config.schemaSurql),
14845
14983
  endpoint: config.database.endpoint ?? "",
14846
14984
  namespace: config.database.namespace,
@@ -14948,9 +15086,12 @@ function createAdapters(config, s, host, lateModules) {
14948
15086
  const left = takeReloadSalt(authId);
14949
15087
  if (!left) return null;
14950
15088
  s.remote.adoptSessionIds(left.sessionIds);
14951
- return left.salt;
15089
+ return {
15090
+ salt: left.salt,
15091
+ owner: left.owner
15092
+ };
14952
15093
  },
14953
- "salt.leave": (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
15094
+ "salt.leave": (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
14954
15095
  "local.connect": (bucketId) => s.local.connect(bucketId),
14955
15096
  "local.switchStore": (bucketId) => s.local.switchStore(bucketId),
14956
15097
  "local.beginSwitch": () => s.local.beginSwitch(),
@@ -14977,6 +15118,7 @@ function createAdapters(config, s, host, lateModules) {
14977
15118
  ],
14978
15119
  schemaHash: String(hash53(config.schemaSurql)),
14979
15120
  pendingIds: new Set(pendingIds),
15121
+ columnFingerprints: columnFingerprints(config.schema.tables),
14980
15122
  onVersions: (_table, entries) => host.dispatch({
14981
15123
  type: "VersionsPrimed",
14982
15124
  entries
@@ -15087,7 +15229,8 @@ function createAdapters(config, s, host, lateModules) {
15087
15229
  clock: { now: () => Date.now() },
15088
15230
  ids: {
15089
15231
  mutation: () => mintMutationId(s.tabId),
15090
- salt: mintSalt
15232
+ salt: mintSalt,
15233
+ owner: mintOwnerSecret
15091
15234
  },
15092
15235
  hash: sha256Hex,
15093
15236
  services
package/dist/live.d.ts CHANGED
@@ -43,6 +43,14 @@ interface LiveFeedOptions {
43
43
  ttl?: string;
44
44
  /** Salt of the `_00_query` keys. Default: a fresh random id per feed. */
45
45
  sessionId?: string;
46
+ /**
47
+ * The secret the feed's registrations prove they own their views with
48
+ * (`$config.owner`): a signed-out feed's view answers only to the secret it
49
+ * was registered with. Default: a fresh one per feed. Pass the same one
50
+ * wherever you pass the same `sessionId`, or the second registrant of a
51
+ * shared anonymous view is refused.
52
+ */
53
+ ownerSecret?: string;
46
54
  /**
47
55
  * The generated schema. Used to type query params (record ids, datetimes)
48
56
  * exactly like the full client does before registering. Taken from a query
package/dist/live.js CHANGED
@@ -1,4 +1,4 @@
1
- import { D as registerSelect, F as ANON_USER_ID, H as queryHashInput, I as DEFAULT_REF_MODE, O as registerVars, R as listRefTableFor, V as sha256Hex, _ as heartbeatBatch, a as metaFromRow, c as parseDuration, d as parseQueryParams, h as bodySelect, k as singleSnapshotSelect, l as parseRecordIdString, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as withTimeout, v as heartbeatRowGone, x as listRefLive } from "./worker-edges.js";
1
+ import { A as singleSnapshotSelect, H as sha256Hex, I as ANON_USER_ID, L as DEFAULT_REF_MODE, O as registerSelect, S as listRefLive, U as queryHashInput, a as metaFromRow, c as mintOwnerSecret, d as withTimeout, f as parseQueryParams, g as bodySelect, k as registerVars, l as parseDuration, n as rowOfEdge, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, y as heartbeatRowGone, z as listRefTableFor } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
@@ -15,7 +15,8 @@ import { RecordId, Surreal } from "surrealdb";
15
15
  * - `fn::query::register($config)` plus the membership read-back in one
16
16
  * request (`registerSelect` / `registerVars`, the same builders the full
17
17
  * client's `registerRemote` uses; `$config = { id: _00_query:<key>, surql,
18
- * params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
18
+ * params, ttl, owner }`, key = sha256(JSON.stringify({ surql, params,
19
+ * sessionId })), owner = the feed's `ownerSecret`);
19
20
  * - bodies with `SELECT * FROM $ids`;
20
21
  * - one `LIVE SELECT * FROM <the user's _00_list_ref table> FETCH out` per
21
22
  * feed (on the shared anon table, filtered to the feed's own edges; see
@@ -186,6 +187,7 @@ var Feed = class {
186
187
  liveStarting = null;
187
188
  subs = /* @__PURE__ */ new Map();
188
189
  sessionId;
190
+ ownerSecret;
189
191
  /** `session::id()` of every socket this feed has had, oldest first. */
190
192
  sockets = [];
191
193
  ttl;
@@ -202,6 +204,7 @@ var Feed = class {
202
204
  constructor(options) {
203
205
  this.options = options;
204
206
  this.sessionId = options.sessionId ?? randomId();
207
+ this.ownerSecret = options.ownerSecret ?? mintOwnerSecret();
205
208
  this.ttl = options.ttl ?? "10m";
206
209
  this.timeoutMs = options.timeoutMs ?? 15e3;
207
210
  }
@@ -539,7 +542,10 @@ var Feed = class {
539
542
  }
540
543
  /** Register (or re-register) a view and read its membership back, in one request. */
541
544
  async register(sub) {
542
- let snap = snapshotAt(await this.q(registerSelect(this.table), registerVars(sub.payload)), 1);
545
+ let snap = snapshotAt(await this.q(registerSelect(this.table), registerVars({
546
+ ...sub.payload,
547
+ owner: this.ownerSecret
548
+ })), 1);
543
549
  for (let i = 0; snap && snap.state === "materializing" && i < MATERIALIZE_RETRIES; i++) {
544
550
  await sleep(MATERIALIZE_RETRY_MS);
545
551
  snap = await this.readSnapshot(sub);
package/dist/pure.d.ts CHANGED
@@ -234,6 +234,14 @@ interface RegisterPayload {
234
234
  surql: string;
235
235
  params: Record<string, unknown>;
236
236
  ttl: string;
237
+ /**
238
+ * The registering page's secret. `fn::query::register` forwards only its
239
+ * hash, and the SSP lets a registration onto an existing anonymous view
240
+ * only with the hash that view was registered with: every signed-out
241
+ * visitor is `anon`, and anyone can read an anonymous view's id. Kept out
242
+ * of `params`, which name the computation the SSP shares between views.
243
+ */
244
+ owner?: string;
237
245
  }
238
246
  /** Register + read back edges/meta/children in ONE request. */
239
247
  declare const registerSelect: (table: string) => string;
package/dist/pure.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as subqueryListRefSelect, B as surql, C as queryRowCountBatchSelect, D as registerSelect, E as readViewRows, F as ANON_USER_ID, H as queryHashInput, I as DEFAULT_REF_MODE, L as bucketIdForUser, M as viewRecordId, N as viewRow, O as registerVars, P as ANON_LIST_REF_TABLE, R as listRefTableFor, S as listRefSelect, T as readLegacyViewRows, U as viewKeyInput, V as sha256Hex, _ as heartbeatBatch, a as metaFromRow, b as listRefBatchSelect, c as parseDuration, d as parseQueryParams, f as LEGACY_VIEW_TABLE, g as countViewRows, h as bodySelect, i as dedupeRecordVersions, j as upsertBodiesTx, k as singleSnapshotSelect, l as parseRecordIdString, m as batchSnapshotSelect, n as rowOfEdge, o as snapshotFromSingle, p as VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as heartbeatRowGone, w as queryRowCountSelect, x as listRefLive, y as leaveViewsBeacon, z as sanitizeUserId } from "./worker-edges.js";
1
+ import { A as singleSnapshotSelect, B as sanitizeUserId, C as listRefSelect, D as readViewRows, E as readLegacyViewRows, F as ANON_LIST_REF_TABLE, H as sha256Hex, I as ANON_USER_ID, L as DEFAULT_REF_MODE, M as upsertBodiesTx, N as viewRecordId, O as registerSelect, P as viewRow, R as bucketIdForUser, S as listRefLive, T as queryRowCountSelect, U as queryHashInput, V as surql, W as viewKeyInput, _ as countViewRows, a as metaFromRow, b as leaveViewsBeacon, f as parseQueryParams, g as bodySelect, h as batchSnapshotSelect, i as dedupeRecordVersions, j as subqueryListRefSelect, k as registerVars, l as parseDuration, m as VIEW_TABLE, n as rowOfEdge, o as snapshotFromSingle, p as LEGACY_VIEW_TABLE, r as FAILED, s as encodeRecordId, t as hashOfEdge, u as parseRecordIdString, v as heartbeatBatch, w as queryRowCountBatchSelect, x as listRefBatchSelect, y as heartbeatRowGone, z as listRefTableFor } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims, t as IMPERSONATION_ACCESS } from "./worker-impersonation.js";
3
3
  import { a as sameBytes, i as isPushPayload, n as base64UrlToBytes, r as bytesToBase64Url, t as PUSH_PAYLOAD_VERSION } from "./worker-types.js";
4
4
 
package/dist/types.d.ts CHANGED
@@ -451,6 +451,16 @@ interface LocalStore extends LocalCacheEngine {
451
451
  * without it boots the circuit empty, as before.
452
452
  */
453
453
  scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
454
+ /**
455
+ * Reset the cached `_00_rv` of every table whose column set (`fingerprints`,
456
+ * see `columnFingerprints`) changed since the store last recorded it, except
457
+ * the rows in `keep`, and record the new sets. Returns the tables reset.
458
+ * For engines that keep bodies across schema changes: a body landed under an
459
+ * older schema lacks the columns it did not know, at the server's version,
460
+ * and would never be fetched again. OPTIONAL: an engine that rebuilds its
461
+ * store on a schema change (SurrealDB) does not need it.
462
+ */
463
+ resetChangedTables?(fingerprints: Record<string, string>, keep: Set<string>): Promise<string[]>;
454
464
  /**
455
465
  * Circuit snapshot storage, keyed. Lives in the same durable store as the
456
466
  * rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
@@ -319,6 +319,16 @@ const parseRecordIdString = (id) => {
319
319
  return new RecordId(table, idParts.join(":"));
320
320
  };
321
321
  /**
322
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
323
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
324
+ * than `randomUUID`, which a page served over plain http does not have.
325
+ */
326
+ function mintOwnerSecret() {
327
+ const bytes = new Uint8Array(32);
328
+ globalThis.crypto.getRandomValues(bytes);
329
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
330
+ }
331
+ /**
322
332
  * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
323
333
  * releases expose it under `milliseconds` (current) or the older private
324
334
  * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
@@ -467,4 +477,4 @@ function rowOfEdge(value) {
467
477
  }
468
478
 
469
479
  //#endregion
470
- export { subqueryListRefSelect as A, surql as B, queryRowCountBatchSelect as C, registerSelect as D, readViewRows as E, ANON_USER_ID as F, queryHashInput as H, DEFAULT_REF_MODE as I, bucketIdForUser as L, viewRecordId as M, viewRow as N, registerVars as O, ANON_LIST_REF_TABLE as P, listRefTableFor as R, listRefSelect as S, readLegacyViewRows as T, viewKeyInput as U, sha256Hex as V, heartbeatBatch as _, metaFromRow as a, listRefBatchSelect as b, parseDuration as c, parseQueryParams as d, LEGACY_VIEW_TABLE as f, countViewRows as g, bodySelect as h, dedupeRecordVersions as i, upsertBodiesTx as j, singleSnapshotSelect as k, parseRecordIdString as l, batchSnapshotSelect as m, rowOfEdge as n, snapshotFromSingle as o, VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, heartbeatRowGone as v, queryRowCountSelect as w, listRefLive as x, leaveViewsBeacon as y, sanitizeUserId as z };
480
+ export { singleSnapshotSelect as A, sanitizeUserId as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, ANON_LIST_REF_TABLE as F, sha256Hex as H, ANON_USER_ID as I, DEFAULT_REF_MODE as L, upsertBodiesTx as M, viewRecordId as N, registerSelect as O, viewRow as P, bucketIdForUser as R, listRefLive as S, queryRowCountSelect as T, queryHashInput as U, surql as V, viewKeyInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, subqueryListRefSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, listRefTableFor as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.321",
3
+ "version": "0.0.1-canary.323",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.321",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.321",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.323",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.323",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -43,24 +43,42 @@ describe('boot', () => {
43
43
  'releases.init',
44
44
  ]);
45
45
  expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
46
- expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
46
+ expect(out.state).toMatchObject({
47
+ bucketId: 'u1',
48
+ userId: 'user:u1',
49
+ saltUserId: 'user:u1',
50
+ sessionId: 'salt-1',
51
+ ownerSecret: 'owner-2',
52
+ localReady: true,
53
+ });
47
54
  expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'AdoptPendingWrites', 'LifecycleTick', 'StartRemote']);
48
55
  // The view index is loaded before anything can paint; the first GC waits out the boot.
49
56
  expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
50
57
  expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
51
58
  expect(svc.names()).not.toContain('remote.connect');
52
59
  });
53
- it('a reload keeps the salt its tab left for the same principal', async () => {
60
+ it('a reload keeps the salt its tab left for the same principal, and the owner secret its views answer to', async () => {
54
61
  const svc = fakeServices({
55
62
  'hint.read': () => 'u1',
56
63
  'auth.restoreSession': async () => 'user:u1',
57
64
  'auth.sessionAuthId': () => 'user:u1',
58
- 'salt.take': () => 'kept-salt',
65
+ 'salt.take': () => ({ salt: 'kept-salt', owner: 'kept-owner' }),
59
66
  });
60
67
  const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
61
68
  expect(svc.calls).toContainEqual(['salt.take', ['user:u1']]);
62
69
  expect(svc.calls).toContainEqual(['crdt.setSessionId', ['kept-salt']]);
63
- expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1' });
70
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', saltUserId: 'user:u1', ownerSecret: 'kept-owner' });
71
+ expect(out.log.filter((e) => e.kind === 'id')).toEqual([]);
72
+ });
73
+ it('a salt left by a page that kept no owner secret gets a fresh one', async () => {
74
+ const svc = fakeServices({
75
+ 'hint.read': () => null,
76
+ 'auth.restoreSession': async () => null,
77
+ 'auth.sessionAuthId': () => null,
78
+ 'salt.take': () => ({ salt: 'kept-salt', owner: null }),
79
+ });
80
+ const out = await runPure(boot(env, { sharedTabs: false }), { handlers: { service: svc.handler, 'local.query': () => [] } });
81
+ expect(out.state).toMatchObject({ sessionId: 'kept-salt', ownerSecret: 'owner-1' });
64
82
  });
65
83
  it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
66
84
  const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
@@ -146,12 +164,17 @@ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', (
146
164
  expect(f.state.primed).toBe(true);
147
165
  expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
148
166
  });
149
- it('pageHide leaves the salt for a reload before releasing views', async () => {
167
+ it('pageHide leaves the salt and owner secret for a reload before releasing views', async () => {
150
168
  const svc = fakeServices();
151
- const s = { ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]), sessionId: 'salt-1', saltUserId: 'user:u1' };
169
+ const s = {
170
+ ...buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } })]),
171
+ sessionId: 'salt-1',
172
+ saltUserId: 'user:u1',
173
+ ownerSecret: 'owner-1',
174
+ };
152
175
  await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
153
176
  expect(svc.calls).toEqual([
154
- ['salt.leave', ['salt-1', 'user:u1']],
177
+ ['salt.leave', ['salt-1', 'user:u1', 'owner-1']],
155
178
  ['remote.releaseViews', [[new RecordId('_00_query', 'a')]]],
156
179
  ]);
157
180
  });
@@ -44,9 +44,12 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
44
44
 
45
45
  const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
46
46
  const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
47
- // A reload keeps the salt its tab had, so registrations land on warm views.
48
- const salt = (((yield fx.service('salt.take', authId)) as string | null) ?? (yield fx.id('salt'))) as string;
49
- yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
47
+ // A reload keeps the salt its tab had, so registrations land on warm views,
48
+ // and the owner secret those views answer to.
49
+ const left = (yield fx.service('salt.take', authId)) as { salt: string; owner: string | null } | null;
50
+ const salt = (left?.salt ?? (yield fx.id('salt'))) as string;
51
+ const ownerSecret = (left?.owner ?? (yield fx.id('owner'))) as string;
52
+ yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId, ownerSecret }));
50
53
  yield fx.service('crdt.setSessionId', salt);
51
54
  if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
52
55
 
@@ -123,12 +126,13 @@ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga
123
126
  * grace finds its views still warm.
124
127
  */
125
128
  export function* pageHide(): Saga<void> {
126
- const [salt, saltUserId, ids] = (yield fx.state.read((s) => [
129
+ const [salt, saltUserId, ownerSecret, ids] = (yield fx.state.read((s) => [
127
130
  s.sessionId,
128
131
  s.saltUserId,
132
+ s.ownerSecret,
129
133
  [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id),
130
- ])) as [string | null, string | null, unknown[]];
131
- if (salt) yield fx.service('salt.leave', salt, saltUserId);
134
+ ])) as [string | null, string | null, string | null, unknown[]];
135
+ if (salt) yield fx.service('salt.leave', salt, saltUserId, ownerSecret);
132
136
  if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
133
137
  }
134
138