@spooky-sync/core 0.0.1-canary.320 → 0.0.1-canary.322
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 +17 -7
- package/dist/index.js +133 -38
- package/dist/live.d.ts +9 -1
- package/dist/live.js +36 -6
- package/dist/pure.d.ts +38 -2
- package/dist/pure.js +2 -2
- package/dist/types.d.ts +10 -0
- package/dist/worker-edges.js +108 -67
- package/dist/worker-ref-tables.d.ts +3 -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 +25 -2
- package/src/client/services.ts +41 -13
- package/src/kernel/effects.ts +8 -4
- package/src/kernel/interpreter.ts +3 -3
- package/src/live/index.test.ts +38 -4
- package/src/live/index.ts +52 -6
- package/src/modules/ref-tables.test.ts +3 -1
- package/src/modules/ref-tables.ts +4 -1
- package/src/pure.ts +1 -0
- package/src/query/anon-query-isolation.integration.test.ts +496 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/membership.saga.test.ts +5 -5
- package/src/query/register.saga.ts +7 -1
- package/src/query/sql.test.ts +37 -0
- package/src/query/sql.ts +48 -3
- package/src/services/database/remote.ts +20 -2
- package/src/state/client-state.ts +8 -0
- package/src/state/reducers.ts +4 -1
- package/src/sync/connection.saga.test.ts +13 -2
- package/src/sync/connection.saga.ts +17 -13
- package/src/sync/policy.test.ts +13 -9
- package/src/sync/policy.ts +16 -3
- package/src/testing/adapters.ts +5 -1
- package/src/utils/index.ts +11 -0
package/dist/worker-edges.js
CHANGED
|
@@ -97,6 +97,71 @@ const surql = {
|
|
|
97
97
|
}
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/modules/ref-tables.ts
|
|
102
|
+
/**
|
|
103
|
+
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
104
|
+
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
105
|
+
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
106
|
+
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
107
|
+
*/
|
|
108
|
+
const ANON_USER_ID = "anon";
|
|
109
|
+
/** The one `_00_list_ref` table every signed-out session shares. */
|
|
110
|
+
const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
|
|
111
|
+
/**
|
|
112
|
+
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
113
|
+
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
114
|
+
* box.
|
|
115
|
+
*/
|
|
116
|
+
const DEFAULT_REF_MODE = "dedicated";
|
|
117
|
+
/**
|
|
118
|
+
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
119
|
+
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
120
|
+
* the id is missing the `user:` prefix or contains characters that
|
|
121
|
+
* aren't valid in a SurrealDB table identifier — the server-side
|
|
122
|
+
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
123
|
+
*
|
|
124
|
+
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
125
|
+
* objects (which only stringify cleanly via `.toString()`), since
|
|
126
|
+
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
127
|
+
*/
|
|
128
|
+
function sanitizeUserId(userId) {
|
|
129
|
+
if (userId === null || userId === void 0) return null;
|
|
130
|
+
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
131
|
+
if (!asString) return null;
|
|
132
|
+
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
133
|
+
if (raw.length === 0) return null;
|
|
134
|
+
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
139
|
+
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
140
|
+
* query state, and the mutation outbox never leak across accounts on a shared
|
|
141
|
+
* device. Signed-out sessions share the `anon` bucket.
|
|
142
|
+
*
|
|
143
|
+
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
144
|
+
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
145
|
+
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
146
|
+
*/
|
|
147
|
+
function bucketIdForUser(userId) {
|
|
148
|
+
if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
|
|
149
|
+
const uid = sanitizeUserId(userId);
|
|
150
|
+
if (uid) return uid;
|
|
151
|
+
return `u${cyrb53(String(userId)).toString(16)}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
155
|
+
* back to the global `_00_list_ref` when sanitization fails or in
|
|
156
|
+
* single mode.
|
|
157
|
+
*/
|
|
158
|
+
function listRefTableFor(mode, userId) {
|
|
159
|
+
if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
|
|
160
|
+
if (mode === "single") return "_00_list_ref";
|
|
161
|
+
const uid = sanitizeUserId(userId);
|
|
162
|
+
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
163
|
+
}
|
|
164
|
+
|
|
100
165
|
//#endregion
|
|
101
166
|
//#region src/query/sql.ts
|
|
102
167
|
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
@@ -119,15 +184,44 @@ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
|
|
|
119
184
|
/** Copy every legacy `_00_window` row into `_00_view` once. */
|
|
120
185
|
const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
121
186
|
const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
122
|
-
const listRefSelect = (table) => `SELECT out, version FROM ${table} WHERE
|
|
123
|
-
const subqueryListRefSelect = (table) => `SELECT out, version FROM ${table} WHERE
|
|
187
|
+
const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
|
|
188
|
+
const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
|
|
124
189
|
const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
|
|
125
|
-
const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM ${table}
|
|
190
|
+
const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
|
|
126
191
|
const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
|
|
127
192
|
/** The single-query read: edges, meta, subquery children in one request. */
|
|
128
193
|
const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
|
|
129
194
|
/** The many-query read: all edges + all metas in one request. */
|
|
130
195
|
const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
196
|
+
/**
|
|
197
|
+
* The session's one LIVE subscription on its `_00_list_ref` table.
|
|
198
|
+
*
|
|
199
|
+
* `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
|
|
200
|
+
* notification so the body arrives with the doorbell.
|
|
201
|
+
*
|
|
202
|
+
* On the anon table it is filtered to this client's own edges. Every
|
|
203
|
+
* signed-out session shares that table, so unfiltered each one received (and
|
|
204
|
+
* the server fetched a body for) every anonymous edge change of every other
|
|
205
|
+
* session, and the client then dropped all but its own. An edge carries the
|
|
206
|
+
* `session::id()` of whichever socket last registered its view, which is a
|
|
207
|
+
* socket of this client but not necessarily the current one: a view stays
|
|
208
|
+
* registered across a reconnect, and a reload keeps its salt and its views,
|
|
209
|
+
* so their older edges still name an earlier socket. `sessionIds` are those
|
|
210
|
+
* earlier sockets. The current one is matched by `session::id()` itself,
|
|
211
|
+
* which SurrealDB evaluates in the subscriber's own session, so the filter
|
|
212
|
+
* holds before the client has learned its new id.
|
|
213
|
+
*
|
|
214
|
+
* The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
|
|
215
|
+
* and without it nothing matches. Per-user tables need no filter.
|
|
216
|
+
*/
|
|
217
|
+
function listRefLive(table, opts) {
|
|
218
|
+
const join = opts.inlineBodies ? " FETCH out" : "";
|
|
219
|
+
if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
|
|
220
|
+
return {
|
|
221
|
+
sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
|
|
222
|
+
vars: { sids: [...opts.sessionIds] }
|
|
223
|
+
};
|
|
224
|
+
}
|
|
131
225
|
/** Register + read back edges/meta/children in ONE request. */
|
|
132
226
|
const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
|
|
133
227
|
const registerVars = (payload) => ({
|
|
@@ -178,69 +272,6 @@ function upsertBodiesTx(records) {
|
|
|
178
272
|
};
|
|
179
273
|
}
|
|
180
274
|
|
|
181
|
-
//#endregion
|
|
182
|
-
//#region src/modules/ref-tables.ts
|
|
183
|
-
/**
|
|
184
|
-
* Sentinel user id for unauthenticated clients when anonymous live queries are
|
|
185
|
-
* enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
|
|
186
|
-
* so it can never collide with a real user id (those arrive as `user:<id>`);
|
|
187
|
-
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
188
|
-
*/
|
|
189
|
-
const ANON_USER_ID = "anon";
|
|
190
|
-
/**
|
|
191
|
-
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
192
|
-
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
193
|
-
* box.
|
|
194
|
-
*/
|
|
195
|
-
const DEFAULT_REF_MODE = "dedicated";
|
|
196
|
-
/**
|
|
197
|
-
* Sanitize a user record id (e.g. `"user:abc"`) into the segment that
|
|
198
|
-
* goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
|
|
199
|
-
* the id is missing the `user:` prefix or contains characters that
|
|
200
|
-
* aren't valid in a SurrealDB table identifier — the server-side
|
|
201
|
-
* `ssp_protocol::sanitize_user_id` uses the same predicate.
|
|
202
|
-
*
|
|
203
|
-
* Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
|
|
204
|
-
* objects (which only stringify cleanly via `.toString()`), since
|
|
205
|
-
* `AuthService` passes the record-id object as-is to its subscribers.
|
|
206
|
-
*/
|
|
207
|
-
function sanitizeUserId(userId) {
|
|
208
|
-
if (userId === null || userId === void 0) return null;
|
|
209
|
-
const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
|
|
210
|
-
if (!asString) return null;
|
|
211
|
-
const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
|
|
212
|
-
if (raw.length === 0) return null;
|
|
213
|
-
if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
|
|
214
|
-
return raw;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Resolve the LOCAL storage bucket id for a user. Every user gets their own
|
|
218
|
-
* IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
|
|
219
|
-
* query state, and the mutation outbox never leak across accounts on a shared
|
|
220
|
-
* device. Signed-out sessions share the `anon` bucket.
|
|
221
|
-
*
|
|
222
|
-
* An id that fails sanitization still gets a DETERMINISTIC per-user bucket
|
|
223
|
-
* (cyrb53 hex of the raw id) — falling back to `anon` here would put an
|
|
224
|
-
* authenticated user in the shared bucket and recreate the cross-user leak.
|
|
225
|
-
*/
|
|
226
|
-
function bucketIdForUser(userId) {
|
|
227
|
-
if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
|
|
228
|
-
const uid = sanitizeUserId(userId);
|
|
229
|
-
if (uid) return uid;
|
|
230
|
-
return `u${cyrb53(String(userId)).toString(16)}`;
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
|
|
234
|
-
* back to the global `_00_list_ref` when sanitization fails or in
|
|
235
|
-
* single mode.
|
|
236
|
-
*/
|
|
237
|
-
function listRefTableFor(mode, userId) {
|
|
238
|
-
if (userId === ANON_USER_ID) return "_00_list_ref_anon";
|
|
239
|
-
if (mode === "single") return "_00_list_ref";
|
|
240
|
-
const uid = sanitizeUserId(userId);
|
|
241
|
-
return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
|
|
242
|
-
}
|
|
243
|
-
|
|
244
275
|
//#endregion
|
|
245
276
|
//#region src/utils/parser.ts
|
|
246
277
|
/**
|
|
@@ -288,6 +319,16 @@ const parseRecordIdString = (id) => {
|
|
|
288
319
|
return new RecordId(table, idParts.join(":"));
|
|
289
320
|
};
|
|
290
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
|
+
/**
|
|
291
332
|
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
292
333
|
* releases expose it under `milliseconds` (current) or the older private
|
|
293
334
|
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
@@ -436,4 +477,4 @@ function rowOfEdge(value) {
|
|
|
436
477
|
}
|
|
437
478
|
|
|
438
479
|
//#endregion
|
|
439
|
-
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 };
|
|
@@ -7,6 +7,8 @@ type RefMode = 'single' | 'dedicated';
|
|
|
7
7
|
* both sides resolve it to the shared `_00_list_ref_anon` table.
|
|
8
8
|
*/
|
|
9
9
|
declare const ANON_USER_ID = "anon";
|
|
10
|
+
/** The one `_00_list_ref` table every signed-out session shares. */
|
|
11
|
+
declare const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
|
|
10
12
|
/**
|
|
11
13
|
* Default ref-storage mode for this client build. Mirrors the SSP's
|
|
12
14
|
* default (`RefMode::Dedicated`) so cross-session sync works out of the
|
|
@@ -43,4 +45,4 @@ declare function bucketIdForUser(userId: unknown): string;
|
|
|
43
45
|
*/
|
|
44
46
|
declare function listRefTableFor(mode: RefMode, userId: unknown): string;
|
|
45
47
|
//#endregion
|
|
46
|
-
export {
|
|
48
|
+
export { bucketIdForUser as a, RefMode as i, ANON_USER_ID as n, listRefTableFor as o, DEFAULT_REF_MODE as r, sanitizeUserId as s, ANON_LIST_REF_TABLE as t };
|
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.322",
|
|
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.322",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.322",
|
|
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
|
|
|
@@ -25,16 +25,39 @@ describe('reload salt', () => {
|
|
|
25
25
|
|
|
26
26
|
it('a salt left on the way out is taken once, by the same principal', () => {
|
|
27
27
|
leaveReloadSalt('s1', 'user:a');
|
|
28
|
-
expect(takeReloadSalt('user:a')).
|
|
28
|
+
expect(takeReloadSalt('user:a')).toEqual({ salt: 's1', sessionIds: [], owner: null });
|
|
29
29
|
// Taken, not read: a tab duplicated from this page finds nothing.
|
|
30
30
|
expect(takeReloadSalt('user:a')).toBeNull();
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
it("the page's socket session ids travel with the salt", () => {
|
|
34
|
+
// The reloaded page's views keep edges stamped by these sockets, and the
|
|
35
|
+
// anonymous LIVE only hears sessions it knows.
|
|
36
|
+
leaveReloadSalt('s1', null, ['sid-1', 'sid-2']);
|
|
37
|
+
expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: ['sid-1', 'sid-2'], owner: null });
|
|
38
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: 's2', authId: null, sessionIds: ['ok', 7, null] }));
|
|
39
|
+
expect(takeReloadSalt(null)).toEqual({ salt: 's2', sessionIds: ['ok'], owner: null });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("the page's owner secret travels with the salt, so the reloaded page still owns its anonymous views", () => {
|
|
43
|
+
leaveReloadSalt('s1', null, [], 'secret-1');
|
|
44
|
+
expect(takeReloadSalt(null)).toEqual({ salt: 's1', sessionIds: [], owner: 'secret-1' });
|
|
45
|
+
// Gone with the salt: a duplicated tab mints its own.
|
|
46
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
47
|
+
// A different principal re-salts, and the secret is not handed over either.
|
|
48
|
+
leaveReloadSalt('s2', 'user:a', [], 'secret-2');
|
|
49
|
+
expect(takeReloadSalt(null)).toBeNull();
|
|
50
|
+
for (const owner of ['', 7, null]) {
|
|
51
|
+
(g.sessionStorage as MemoryStorage).setItem('sp00ky:salt', JSON.stringify({ salt: 's3', authId: null, owner }));
|
|
52
|
+
expect(takeReloadSalt(null)?.owner).toBeNull();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
33
56
|
it('another principal, or none, mints a fresh salt', () => {
|
|
34
57
|
leaveReloadSalt('s1', 'user:a');
|
|
35
58
|
expect(takeReloadSalt('user:b')).toBeNull();
|
|
36
59
|
leaveReloadSalt('s2', null);
|
|
37
|
-
expect(takeReloadSalt(null)).toBe('s2');
|
|
60
|
+
expect(takeReloadSalt(null)?.salt).toBe('s2');
|
|
38
61
|
leaveReloadSalt('s3', null);
|
|
39
62
|
expect(takeReloadSalt('user:a')).toBeNull();
|
|
40
63
|
});
|
package/src/client/services.ts
CHANGED
|
@@ -26,9 +26,9 @@ import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services
|
|
|
26
26
|
import type { Adapters } from '../kernel/interpreter';
|
|
27
27
|
import type { RuntimeEvent } from '../kernel/events';
|
|
28
28
|
import type { ServiceCalls } from '../kernel/effects';
|
|
29
|
-
import { encodeRecordId } from '../utils/index';
|
|
29
|
+
import { encodeRecordId, mintOwnerSecret } from '../utils/index';
|
|
30
30
|
import { sha256Hex } from '../utils/sha256';
|
|
31
|
-
import { leaveViewsBeacon } from '../query/sql';
|
|
31
|
+
import { leaveViewsBeacon, listRefLive } from '../query/sql';
|
|
32
32
|
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
33
33
|
import { mintMutationId } from '../mutation/mutation-id';
|
|
34
34
|
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
@@ -59,27 +59,50 @@ const RELOAD_SALT_KEY = 'sp00ky:salt';
|
|
|
59
59
|
* query onto the `_00_query` row it already had, which the SSP answers from
|
|
60
60
|
* the live view instead of building a new one.
|
|
61
61
|
*
|
|
62
|
+
* The page's owner secret travels with it: an anonymous row answers only to
|
|
63
|
+
* the secret it was registered with, so the next page needs the same one to
|
|
64
|
+
* rejoin those views. `null` from a page that predates it; the next page then
|
|
65
|
+
* mints one, and the rows it left (which hold no secret) take the new one.
|
|
66
|
+
*
|
|
62
67
|
* Taken, not read: the key is absent while a page lives, so a duplicated tab
|
|
63
68
|
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
64
69
|
* tab's remote views.
|
|
65
70
|
*/
|
|
66
|
-
export function takeReloadSalt(
|
|
71
|
+
export function takeReloadSalt(
|
|
72
|
+
authId: string | null
|
|
73
|
+
): { salt: string; sessionIds: string[]; owner: string | null } | null {
|
|
67
74
|
try {
|
|
68
75
|
if (typeof sessionStorage === 'undefined') return null;
|
|
69
76
|
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
70
77
|
if (raw === null) return null;
|
|
71
78
|
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
72
|
-
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown };
|
|
73
|
-
|
|
79
|
+
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown; owner?: unknown };
|
|
80
|
+
if (typeof left.salt !== 'string' || left.salt === '' || (left.authId ?? null) !== authId) return null;
|
|
81
|
+
const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid): sid is string => typeof sid === 'string') : [];
|
|
82
|
+
const owner = typeof left.owner === 'string' && left.owner !== '' ? left.owner : null;
|
|
83
|
+
return { salt: left.salt, sessionIds, owner };
|
|
74
84
|
} catch {
|
|
75
85
|
return null;
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}.
|
|
91
|
+
*
|
|
92
|
+
* The session ids of this page's sockets go with it. The views the next page
|
|
93
|
+
* re-registers keep the edges this page's sockets stamped, and the anonymous
|
|
94
|
+
* LIVE only hears edges stamped by a session it knows (see `listRefLive`).
|
|
95
|
+
* So does the page's owner secret, which those views answer to.
|
|
96
|
+
*/
|
|
97
|
+
export function leaveReloadSalt(
|
|
98
|
+
salt: string,
|
|
99
|
+
authId: string | null,
|
|
100
|
+
sessionIds: ReadonlyArray<string> = [],
|
|
101
|
+
owner: string | null = null
|
|
102
|
+
): void {
|
|
81
103
|
try {
|
|
82
|
-
if (typeof sessionStorage !== 'undefined')
|
|
104
|
+
if (typeof sessionStorage !== 'undefined')
|
|
105
|
+
sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds, owner }));
|
|
83
106
|
} catch {
|
|
84
107
|
/* private-mode storage errors: the next page mints a fresh salt */
|
|
85
108
|
}
|
|
@@ -255,8 +278,13 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
255
278
|
const services: ServiceCalls = {
|
|
256
279
|
'hint.read': readBootBucketHint,
|
|
257
280
|
'hint.write': writeBootBucketHint,
|
|
258
|
-
'salt.take':
|
|
259
|
-
|
|
281
|
+
'salt.take': (authId) => {
|
|
282
|
+
const left = takeReloadSalt(authId);
|
|
283
|
+
if (!left) return null;
|
|
284
|
+
s.remote.adoptSessionIds(left.sessionIds);
|
|
285
|
+
return { salt: left.salt, owner: left.owner };
|
|
286
|
+
},
|
|
287
|
+
'salt.leave': (salt, authId, owner) => leaveReloadSalt(salt, authId, s.remote.sessionIds(), owner),
|
|
260
288
|
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
261
289
|
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
262
290
|
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
@@ -354,8 +382,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
354
382
|
// trip. The clause is only ever added, never relied on: a server that
|
|
355
383
|
// ignores it, or a row the session cannot read, simply yields no
|
|
356
384
|
// inline row and the fetch path takes over.
|
|
357
|
-
const
|
|
358
|
-
const [uuid] = await s.remote.query<[Uuid]>(
|
|
385
|
+
const stmt = listRefLive(table, { inlineBodies: config.liveInlineBodies === true, sessionIds: s.remote.sessionIds() });
|
|
386
|
+
const [uuid] = await s.remote.query<[Uuid]>(stmt.sql, stmt.vars);
|
|
359
387
|
const live = await s.remote.getClient().liveOf(uuid);
|
|
360
388
|
live.subscribe((message) => {
|
|
361
389
|
if (message.action === 'KILLED') return;
|
|
@@ -397,7 +425,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
|
|
|
397
425
|
},
|
|
398
426
|
},
|
|
399
427
|
clock: { now: () => Date.now() },
|
|
400
|
-
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
|
|
428
|
+
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt, owner: mintOwnerSecret },
|
|
401
429
|
hash: sha256Hex,
|
|
402
430
|
services,
|
|
403
431
|
};
|
package/src/kernel/effects.ts
CHANGED
|
@@ -53,7 +53,7 @@ export type Effect =
|
|
|
53
53
|
| { kind: 'state.update'; fn: (s: ClientState) => ClientState }
|
|
54
54
|
| { kind: 'state.wait'; until: (s: ClientState) => boolean }
|
|
55
55
|
| { kind: 'now' }
|
|
56
|
-
| { kind: 'id'; scope:
|
|
56
|
+
| { kind: 'id'; scope: IdScope }
|
|
57
57
|
| { kind: 'hash'; input: string }
|
|
58
58
|
| { kind: 'emit'; event: OutEvent }
|
|
59
59
|
| { kind: 'dispatch'; event: RuntimeEvent }
|
|
@@ -62,6 +62,9 @@ export type Effect =
|
|
|
62
62
|
|
|
63
63
|
export type EffectKind = Effect['kind'];
|
|
64
64
|
|
|
65
|
+
/** `mutation`: an outbox row id. `salt`: a query-key salt. `owner`: a page's anonymous-view owner secret. */
|
|
66
|
+
export type IdScope = 'mutation' | 'salt' | 'owner';
|
|
67
|
+
|
|
65
68
|
/**
|
|
66
69
|
* Calls into the legacy services that are adapters, not state: auth, tabs,
|
|
67
70
|
* blobs, migrator, crdt, persistence, the SSP lifecycle, the remote socket.
|
|
@@ -70,8 +73,9 @@ export type EffectKind = Effect['kind'];
|
|
|
70
73
|
export interface ServiceCalls {
|
|
71
74
|
'hint.read': () => string | null;
|
|
72
75
|
'hint.write': (bucketId: string) => void;
|
|
73
|
-
|
|
74
|
-
'salt.
|
|
76
|
+
/** The salt (and owner secret) this tab's previous page left, if minted for `authId`. */
|
|
77
|
+
'salt.take': (authId: string | null) => { salt: string; owner: string | null } | null;
|
|
78
|
+
'salt.leave': (salt: string, authId: string | null, owner: string | null) => void;
|
|
75
79
|
'local.connect': (bucketId: string) => Promise<void>;
|
|
76
80
|
'local.switchStore': (bucketId: string) => Promise<void>;
|
|
77
81
|
'local.beginSwitch': () => () => void;
|
|
@@ -154,7 +158,7 @@ export const fx = {
|
|
|
154
158
|
wait: (until: (s: ClientState) => boolean): Effect => ({ kind: 'state.wait', until }),
|
|
155
159
|
},
|
|
156
160
|
now: (): Effect => ({ kind: 'now' }),
|
|
157
|
-
id: (scope:
|
|
161
|
+
id: (scope: IdScope): Effect => ({ kind: 'id', scope }),
|
|
158
162
|
hash: (input: string): Effect => ({ kind: 'hash', input }),
|
|
159
163
|
emit: (event: OutEvent): Effect => ({ kind: 'emit', event }),
|
|
160
164
|
dispatch: (event: RuntimeEvent): Effect => ({ kind: 'dispatch', event }),
|
|
@@ -4,7 +4,7 @@ import type { IngestRecord, StreamUpdate } from '../services/stream-processor/in
|
|
|
4
4
|
import type { ClientState } from '../state/client-state';
|
|
5
5
|
import type { InlineRow } from '../types';
|
|
6
6
|
import { withTimeout } from '../utils/index';
|
|
7
|
-
import type { Effect, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
|
|
7
|
+
import type { Effect, IdScope, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
|
|
8
8
|
import type { OutEvent, RuntimeEvent } from './events';
|
|
9
9
|
|
|
10
10
|
/** The five adapters plus the pure ports, as the interpreter sees them. */
|
|
@@ -36,7 +36,7 @@ export interface Adapters {
|
|
|
36
36
|
};
|
|
37
37
|
timers: { set(key: string, ms: number, fire: () => void): void; clear(key: string): void };
|
|
38
38
|
clock: { now(): number };
|
|
39
|
-
ids:
|
|
39
|
+
ids: Record<IdScope, () => string>;
|
|
40
40
|
hash(input: string): Promise<string>;
|
|
41
41
|
services: ServiceCalls;
|
|
42
42
|
}
|
|
@@ -127,7 +127,7 @@ export function createInterpreter(adapters: Adapters, host: InterpreterHost): In
|
|
|
127
127
|
case 'now':
|
|
128
128
|
return adapters.clock.now();
|
|
129
129
|
case 'id':
|
|
130
|
-
return
|
|
130
|
+
return adapters.ids[effect.scope]();
|
|
131
131
|
case 'hash':
|
|
132
132
|
return adapters.hash(effect.input);
|
|
133
133
|
case 'emit':
|
package/src/live/index.test.ts
CHANGED
|
@@ -187,7 +187,12 @@ describe('createLiveFeed', () => {
|
|
|
187
187
|
const input = (client as any).registerInput('notification', q.innerQuery, '10m');
|
|
188
188
|
const env = defaultEnv(schema);
|
|
189
189
|
const local = await runPure(registerLocal(env, input), {
|
|
190
|
-
state: {
|
|
190
|
+
state: {
|
|
191
|
+
...emptyState({ tabId: 't' }),
|
|
192
|
+
sessionId: 'sess',
|
|
193
|
+
userId: 'user:alice',
|
|
194
|
+
ownerSecret: 'owner-k',
|
|
195
|
+
},
|
|
191
196
|
handlers: {
|
|
192
197
|
'local.getById': () => null,
|
|
193
198
|
'ssp.register': () => ({
|
|
@@ -208,14 +213,14 @@ describe('createLiveFeed', () => {
|
|
|
208
213
|
});
|
|
209
214
|
|
|
210
215
|
const fake = new FakeSurreal();
|
|
211
|
-
const feed = feedWith(fake);
|
|
216
|
+
const feed = feedWith(fake, { ownerSecret: 'owner-k' });
|
|
212
217
|
const sub = feed.subscribe(q, { onSet: () => {} });
|
|
213
218
|
await sub.ready;
|
|
214
219
|
const reg = fake.calls.find((c) => c.sql.startsWith('fn::query::register'))!;
|
|
215
220
|
expect(sent).not.toBeNull();
|
|
216
221
|
expect(reg.sql).toBe(sent!.sql);
|
|
217
222
|
expect(reg.vars).toEqual(sent!.vars);
|
|
218
|
-
expect(reg.sql).toContain('FROM _00_list_ref_user_alice');
|
|
223
|
+
expect(reg.sql).toContain('FROM $in->_00_list_ref_user_alice');
|
|
219
224
|
expect((reg.vars!.config as any).params.user).toBeInstanceOf(RecordId);
|
|
220
225
|
expect(sub.key).toBe(local.result);
|
|
221
226
|
expect(await queryKey({ surql: input.surql, params: input.params }, 'sess')).toBe(local.result);
|
|
@@ -375,6 +380,29 @@ describe('createLiveFeed', () => {
|
|
|
375
380
|
await feed.close();
|
|
376
381
|
});
|
|
377
382
|
|
|
383
|
+
it("an anonymous feed's LIVE hears only its own sockets, earlier ones included after a reconnect", async () => {
|
|
384
|
+
const fake = new FakeSurreal();
|
|
385
|
+
let sid = 0;
|
|
386
|
+
const answer = fake.answer.bind(fake);
|
|
387
|
+
fake.answer = (sql, vars) =>
|
|
388
|
+
sql === 'RETURN <string>session::id()' ? [`sid-${++sid}`] : answer(sql, vars);
|
|
389
|
+
const feed = feedWith(fake, { token: undefined });
|
|
390
|
+
const sub = feed.subscribe({ surql: 'SELECT * FROM notification;' }, { onSet: () => {} });
|
|
391
|
+
await sub.ready;
|
|
392
|
+
fake.fire('disconnected');
|
|
393
|
+
fake.fire('connected');
|
|
394
|
+
await tick(60);
|
|
395
|
+
const lives = fake.calls.filter((c) => c.sql.startsWith('LIVE SELECT'));
|
|
396
|
+
expect(lives.map((c) => c.sql)).toEqual([
|
|
397
|
+
'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out',
|
|
398
|
+
'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out',
|
|
399
|
+
]);
|
|
400
|
+
// The view was registered on the first socket and is not re-registered,
|
|
401
|
+
// so its edges keep naming `sid-1`: the second LIVE must still hear them.
|
|
402
|
+
expect(lives[1].vars).toEqual({ sids: ['sid-1'] });
|
|
403
|
+
await feed.close();
|
|
404
|
+
});
|
|
405
|
+
|
|
378
406
|
it('heartbeats and re-registers a view the server reclaimed', async () => {
|
|
379
407
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
380
408
|
try {
|
|
@@ -386,7 +414,13 @@ describe('createLiveFeed', () => {
|
|
|
386
414
|
fake.heartbeatGone = true;
|
|
387
415
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
388
416
|
expect(fake.calls.some((c) => c.sql.startsWith('fn::query::heartbeat($id0)'))).toBe(true);
|
|
389
|
-
|
|
417
|
+
const registers = fake.calls.filter((c) => c.sql.startsWith('fn::query::register'));
|
|
418
|
+
expect(registers).toHaveLength(2);
|
|
419
|
+
// The re-registration proves it owns the view with the secret it was
|
|
420
|
+
// registered with (a fresh one per feed, at least 256 bits).
|
|
421
|
+
const owners = registers.map((c) => (c.vars!.config as { owner?: string }).owner);
|
|
422
|
+
expect(owners[0]).toMatch(/^[0-9a-f]{64}$/);
|
|
423
|
+
expect(owners[1]).toBe(owners[0]);
|
|
390
424
|
await feed.close();
|
|
391
425
|
} finally {
|
|
392
426
|
vi.useRealTimers();
|