@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 +1 -1
- package/dist/index.d.ts +21 -7
- package/dist/index.js +158 -15
- package/dist/live.d.ts +8 -0
- package/dist/live.js +9 -3
- package/dist/pure.d.ts +8 -0
- package/dist/pure.js +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/worker-edges.js +11 -1
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +30 -7
- package/src/boot/boot.saga.ts +10 -6
- package/src/client/services.test.ts +17 -3
- package/src/client/services.ts +25 -9
- package/src/kernel/effects.ts +8 -4
- package/src/kernel/interpreter.ts +3 -3
- package/src/live/index.test.ts +14 -3
- package/src/live/index.ts +23 -3
- package/src/query/anon-query-isolation.integration.test.ts +161 -2
- package/src/query/register.saga.ts +7 -1
- package/src/query/sql.test.ts +6 -0
- package/src/query/sql.ts +8 -0
- package/src/services/database/cache-engine.ts +10 -0
- package/src/services/database/sqlite-cache-engine.ts +26 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/stream-processor/index.ts +24 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.ts +4 -1
- package/src/testing/adapters.ts +5 -1
- package/src/utils/index.ts +11 -0
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:
|
|
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
|
-
|
|
1153
|
-
'salt.
|
|
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.
|
|
2182
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 {
|
|
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,
|
|
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(
|
|
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
|
|
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
|
package/dist/worker-edges.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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({
|
|
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 = {
|
|
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
|
});
|
package/src/boot/boot.saga.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
-
yield fx.
|
|
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
|
|