@spooky-sync/core 0.0.1-canary.327 → 0.0.1-canary.329
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 +3 -1
- package/dist/index.js +38 -9
- package/dist/sqlite-open.js +14 -1
- package/dist/tabs-broker-worker.js +9 -4
- package/dist/types.d.ts +10 -1
- package/package.json +5 -4
- package/scripts/check-worker-entries.mjs +1 -0
- package/skills/sp00ky-core/SKILL.md +1 -1
- package/skills/sp00ky-core/references/config.md +1 -1
- package/src/client/sp00ky-client.test.ts +35 -0
- package/src/client/sp00ky-client.ts +7 -1
- package/src/modules/auth/auth.local-first.test.ts +14 -0
- package/src/modules/auth/index.ts +3 -3
- package/src/query/membership.saga.test.ts +23 -0
- package/src/services/database/database.ts +10 -0
- package/src/services/database/local.test.ts +86 -0
- package/src/services/database/local.ts +22 -1
- package/src/services/database/sqlite-cache-engine.ts +2 -1
- package/src/services/database/sqlite-plan-sql.test.ts +19 -0
- package/src/services/database/sqlite-plan-sql.ts +15 -1
- package/src/services/tabs/broker.test.ts +54 -0
- package/src/services/tabs/tabs-broker-worker.ts +16 -4
- package/src/testing/fake-services.ts +16 -1
package/AGENTS.md
CHANGED
|
@@ -37,7 +37,7 @@ re-materialized from state, never patched in place.
|
|
|
37
37
|
- `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
|
|
38
38
|
- `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
|
|
39
39
|
- `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
|
|
40
|
-
- `AuthService` — token management, sign-in/sign-out events.
|
|
40
|
+
- `AuthService` — token management, sign-in/sign-out events. `subscribe(cb, { immediate? })` calls `cb` at once with the current user unless `immediate: false`, then on every change.
|
|
41
41
|
- `CrdtManager`, `CrdtField`, `cursorColorFromName`, `CURSOR_COLORS`: collaborative `@crdt` fields on Loro. Client methods: `openCrdtField(table, recordId, field, draft?)` -> `Promise<CrdtField>` (every open of one field in a tab shares one `LoroDoc`; pair each with a close), `closeCrdtField(table, recordId, field)` -> `Promise<void>` (resolves once unsent edits are in the outbox; offline, once they are saved on the device, never waiting on the network), `createCrdtDraft(table, field)` -> a detached `CrdtField` for a record not created yet (`db.create` first, then pass it to `openCrdtField` as `draft`). `CrdtField`: `getDoc()`, `hasContent()`, `pushCursorState(bytes)`, `onCursorUpdate = cb` (a setter). Config: `crdt: true` preloads Loro, `crdtDebounceMs` (default 300, max wait 2 s). Design: `docs/crdt-v2.md` at the repo root.
|
|
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.
|
|
@@ -57,6 +57,8 @@ re-materialized from state, never patched in place.
|
|
|
57
57
|
- **`@parent` columns are auto-populated.** Defined in the `.surql` schema as `record<user>` with the `-- @parent` annotation; never write them yourself — they're set from the auth context server-side.
|
|
58
58
|
- **`db.preload` is a registered query.** Resolved before on this device (a `_00_view` row exists) it returns at once; never resolved it waits until the server's membership and every body are local. A signed-out session without `enableAnonymousLiveQueries` is never sent membership, so its preload returns at once instead of blocking. It holds no subscriber, so it is evicted a ttl after registration unless a view mounts the same query. `refresh`/`staleTime` are ignored; pass `signal` to abort a cold wait.
|
|
59
59
|
- **A never-resolved query paints only vouched rows.** Before the server answers (`cold`), the local predicate scan shows a cached body only if some `_00_view` row names it (as a member or subquery child) or a write for it is in flight. A body no view names is an orphan (deleted upstream while nothing watched it): it stays hidden, and the GC deletes it 30 s after boot and then hourly. A `_00_view` row no query has resolved for 14 days is retired by the same sweep, so that query paints cold again.
|
|
60
|
+
- **`database.store` defaults to `'memory'`.** `'indexeddb'` keeps one IndexedDB database per user bucket (`sp00ky-<bucket>`) and needs `@surrealdb/wasm` >= 3.1.0: the 3.0.x engine opened the store but failed every write (`An IndexedDB error occured: idb error`), so each session fell back to memory. 3.1.0 ships the engine as `@surrealdb/wasm-native`; a Vite app lists it in `optimizeDeps.exclude` next to `@surrealdb/wasm`. A store that still cannot open after a drop runs the session in memory (error log `falling back to in-memory`). Under `localEngine: 'sqlite'`, `'indexeddb'` means OPFS.
|
|
61
|
+
- **Boot owns the first identity.** The facade subscribes to auth with `immediate: false`: boot opens the bucket the hint names and `auth.restoreSession` announces the user. `LocalDatabaseService.switchStore` also waits for an in-flight `connect`, and lets statements already past the gate finish before it closes a client: the 3.1 engine streams results from its worker, and a statement whose client closed under it stayed pending until its 30 s deadline with every later local statement queued behind it.
|
|
60
62
|
- **A rejected write is not lost.** Application errors roll the local row back (create/update/delete) and move the mutation to `_00_failed_mutations`; the tray API above lists, retries (as a fresh write) or discards it. Network errors keep the row queued.
|
|
61
63
|
|
|
62
64
|
## Module map (under `src/`)
|
package/dist/index.js
CHANGED
|
@@ -2188,9 +2188,9 @@ function trimHistory(history, cap) {
|
|
|
2188
2188
|
|
|
2189
2189
|
//#endregion
|
|
2190
2190
|
//#region src/modules/devtools/index.ts
|
|
2191
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2193
|
-
const SURREAL_VERSION = "3.0
|
|
2191
|
+
const CORE_VERSION = "0.0.1-canary.329";
|
|
2192
|
+
const WASM_VERSION = "0.0.1-canary.329";
|
|
2193
|
+
const SURREAL_VERSION = "3.1.0";
|
|
2194
2194
|
var DevToolsService = class DevToolsService {
|
|
2195
2195
|
version = CORE_VERSION;
|
|
2196
2196
|
backendInfo = emptyBackendInfo();
|
|
@@ -7848,6 +7848,15 @@ var AbstractDatabaseService = class {
|
|
|
7848
7848
|
if (next) next();
|
|
7849
7849
|
}
|
|
7850
7850
|
/**
|
|
7851
|
+
* Resolve once every statement issued before this call has settled: take
|
|
7852
|
+
* every slot (FIFO, so earlier statements go first), then hand them back.
|
|
7853
|
+
* Bounded by {@link queryTimeoutMs}, like the statements it waits on.
|
|
7854
|
+
*/
|
|
7855
|
+
async drainStatements() {
|
|
7856
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) await this.acquireQuerySlot();
|
|
7857
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) this.releaseQuerySlot();
|
|
7858
|
+
}
|
|
7859
|
+
/**
|
|
7851
7860
|
* Hook run right before a statement is sent, inside its slot. The remote
|
|
7852
7861
|
* service uses it to hold statements while a connect is in flight, so a
|
|
7853
7862
|
* query never runs on a socket that is open but has not had its namespace
|
|
@@ -8060,6 +8069,8 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8060
8069
|
gate = Promise.resolve();
|
|
8061
8070
|
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
8062
8071
|
pendingSwitchClient = null;
|
|
8072
|
+
/** The open `connect()` started, until it settles. */
|
|
8073
|
+
opening = null;
|
|
8063
8074
|
constructor(config, logger) {
|
|
8064
8075
|
const events = createDatabaseEventSystem();
|
|
8065
8076
|
super(createBareSurrealClient(), logger, events);
|
|
@@ -8107,6 +8118,15 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8107
8118
|
return super.query(query, vars);
|
|
8108
8119
|
}
|
|
8109
8120
|
async connect(bucketId = ANON_USER_ID) {
|
|
8121
|
+
const opening = this.openBucket(bucketId);
|
|
8122
|
+
this.opening = opening;
|
|
8123
|
+
try {
|
|
8124
|
+
await opening;
|
|
8125
|
+
} finally {
|
|
8126
|
+
if (this.opening === opening) this.opening = null;
|
|
8127
|
+
}
|
|
8128
|
+
}
|
|
8129
|
+
async openBucket(bucketId) {
|
|
8110
8130
|
const { namespace, database } = this.getConfig();
|
|
8111
8131
|
const store = this.getConfig().store ?? "memory";
|
|
8112
8132
|
this.bucketId = bucketId;
|
|
@@ -8129,14 +8149,20 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8129
8149
|
* async chains can detect they're stale.
|
|
8130
8150
|
*
|
|
8131
8151
|
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
8132
|
-
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
8152
|
+
* timers BEFORE calling this; re-provision + rebind AFTER). Statements that
|
|
8153
|
+
* passed the gate before it closed are let finish before the old client
|
|
8154
|
+
* closes: the engine streams results from its worker, and a client closed
|
|
8155
|
+
* under a statement leaves that statement pending until its deadline, with
|
|
8156
|
+
* every later local statement queued behind it.
|
|
8133
8157
|
*/
|
|
8134
8158
|
async switchStore(bucketId) {
|
|
8159
|
+
if (this.opening) await this.opening.catch(() => {});
|
|
8135
8160
|
if (bucketId === this.bucketId) return;
|
|
8136
8161
|
const { namespace, database } = this.getConfig();
|
|
8137
8162
|
const store = this.getConfig().store ?? "memory";
|
|
8138
8163
|
this.storeEpoch++;
|
|
8139
8164
|
if (store === "memory") {
|
|
8165
|
+
await this.drainStatements();
|
|
8140
8166
|
try {
|
|
8141
8167
|
await this.client.close();
|
|
8142
8168
|
} catch {}
|
|
@@ -8155,6 +8181,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8155
8181
|
} finally {
|
|
8156
8182
|
this.pendingSwitchClient = null;
|
|
8157
8183
|
}
|
|
8184
|
+
await this.drainStatements();
|
|
8158
8185
|
const old = this.client;
|
|
8159
8186
|
this.client = next;
|
|
8160
8187
|
this.bucketId = bucketId;
|
|
@@ -9868,7 +9895,8 @@ function pureWriteOpResult(op) {
|
|
|
9868
9895
|
* shared {@link resolveRelations} — identical to the SurrealDB backend.
|
|
9869
9896
|
*
|
|
9870
9897
|
* Value normalization (JSON round-trip):
|
|
9871
|
-
* -
|
|
9898
|
+
* - Bytes in any shape (`Uint8Array`, the SDK's `ArrayBuffer`) →
|
|
9899
|
+
* `{ "__u8": <base64> }`, read back as `Uint8Array` (CRDT data survives).
|
|
9872
9900
|
* - Record links / ids → their `table:id` string form (so `json_extract`
|
|
9873
9901
|
* comparisons and `IN` matching are consistent). NOTE: link fields therefore
|
|
9874
9902
|
* read back as strings, not `RecordId` instances — the one shape difference
|
|
@@ -11855,9 +11883,10 @@ var AuthService = class {
|
|
|
11855
11883
|
/**
|
|
11856
11884
|
* Subscribe to auth state changes.
|
|
11857
11885
|
* callback is called immediately with current value and whenever validation status changes.
|
|
11886
|
+
* `immediate: false` skips that first call: only changes are reported.
|
|
11858
11887
|
*/
|
|
11859
|
-
subscribe(cb) {
|
|
11860
|
-
cb(this.announcedUserId());
|
|
11888
|
+
subscribe(cb, opts = {}) {
|
|
11889
|
+
if (opts.immediate !== false) cb(this.announcedUserId());
|
|
11861
11890
|
const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
|
|
11862
11891
|
cb(event.payload);
|
|
11863
11892
|
});
|
|
@@ -15364,7 +15393,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15364
15393
|
return new TabsCoordinator({
|
|
15365
15394
|
tabId: s.tabId,
|
|
15366
15395
|
fingerprint: computeTabsFingerprint({
|
|
15367
|
-
coreVersion: "0.0.1-canary.
|
|
15396
|
+
coreVersion: "0.0.1-canary.329",
|
|
15368
15397
|
schemaHash: hash53(config.schemaSurql),
|
|
15369
15398
|
endpoint: config.database.endpoint ?? "",
|
|
15370
15399
|
namespace: config.database.namespace,
|
|
@@ -16134,7 +16163,7 @@ var Sp00kyClient = class {
|
|
|
16134
16163
|
s.auth.subscribe((userId) => void this.runtime.dispatch({
|
|
16135
16164
|
type: "AuthFlip",
|
|
16136
16165
|
userId
|
|
16137
|
-
}));
|
|
16166
|
+
}), { immediate: false });
|
|
16138
16167
|
this.impersonationBanner = new ImpersonationBanner(() => s.auth.stopImpersonating(), this.config.impersonationBanner);
|
|
16139
16168
|
const banner = this.impersonationBanner;
|
|
16140
16169
|
this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
|
package/dist/sqlite-open.js
CHANGED
|
@@ -194,9 +194,10 @@ function scalar(value) {
|
|
|
194
194
|
}
|
|
195
195
|
function serializeRow(row) {
|
|
196
196
|
return JSON.stringify(row, function(k, v) {
|
|
197
|
-
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
198
197
|
const raw = this[k];
|
|
199
198
|
if (raw && typeof raw === "object") {
|
|
199
|
+
const bytes = bytesOf(raw);
|
|
200
|
+
if (bytes) return { __u8: toBase64(bytes) };
|
|
200
201
|
const key = recordIdKey(raw);
|
|
201
202
|
if (key !== null) return key;
|
|
202
203
|
}
|
|
@@ -204,6 +205,18 @@ function serializeRow(row) {
|
|
|
204
205
|
});
|
|
205
206
|
}
|
|
206
207
|
/**
|
|
208
|
+
* The bytes of a binary value, whatever shape it arrived in. Rows from the
|
|
209
|
+
* server carry a `bytes` column as an `ArrayBuffer` (the surrealdb SDK's CBOR
|
|
210
|
+
* decode), not a `Uint8Array`, and `JSON.stringify` turns an `ArrayBuffer`
|
|
211
|
+
* into `{}`: every synced CRDT log row and presence state was stored empty.
|
|
212
|
+
*/
|
|
213
|
+
function bytesOf(value) {
|
|
214
|
+
if (value instanceof Uint8Array) return value;
|
|
215
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
216
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
207
220
|
* Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
|
|
208
221
|
* fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
|
|
209
222
|
*
|
|
@@ -81,7 +81,10 @@ function clearLeader(ns, opts) {
|
|
|
81
81
|
brokerInstanceId,
|
|
82
82
|
leadershipId: leader.leadershipId
|
|
83
83
|
});
|
|
84
|
-
if (opts.removeTab && tab) removeTab(ns, leader.tabId, {
|
|
84
|
+
if (opts.removeTab && tab) removeTab(ns, leader.tabId, {
|
|
85
|
+
notifyLeader: false,
|
|
86
|
+
closePort: false
|
|
87
|
+
});
|
|
85
88
|
for (const t of ns.tabs.values()) if (t.tabId !== leader.tabId) post(t.port, {
|
|
86
89
|
type: "close-follower-ports",
|
|
87
90
|
brokerInstanceId,
|
|
@@ -131,8 +134,9 @@ function failPromotion(ns, tabId, leadershipId, opts) {
|
|
|
131
134
|
/**
|
|
132
135
|
* Fast crash detection: queue a Web Lock request on the leader's tab lock.
|
|
133
136
|
* Locks release the instant their holder's tab dies, so being GRANTED means
|
|
134
|
-
* the leader is gone
|
|
135
|
-
*
|
|
137
|
+
* the leader is gone from this bucket (dead, or moved to another bucket on
|
|
138
|
+
* sign-in/out); release immediately (return from the callback) and re-elect.
|
|
139
|
+
* The leadershipId guard drops stale grants after a normal handoff.
|
|
136
140
|
*/
|
|
137
141
|
function startTabLockMonitor(ns, leadershipId) {
|
|
138
142
|
const locks = navigator.locks;
|
|
@@ -344,8 +348,9 @@ function handleTabMessage(port, msg, ports) {
|
|
|
344
348
|
});
|
|
345
349
|
return;
|
|
346
350
|
}
|
|
347
|
-
const ns = getNamespace(msg.fingerprint, msg.bucketId);
|
|
348
351
|
for (const other of [...namespaces.values()]) if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, "rehello", { closePort: false });
|
|
352
|
+
canonicalFingerprint ??= msg.fingerprint;
|
|
353
|
+
const ns = getNamespace(msg.fingerprint, msg.bucketId);
|
|
349
354
|
const tab = {
|
|
350
355
|
port,
|
|
351
356
|
tabId: msg.tabId,
|
package/dist/types.d.ts
CHANGED
|
@@ -601,6 +601,12 @@ declare abstract class AbstractDatabaseService {
|
|
|
601
601
|
/** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
|
|
602
602
|
private acquireQuerySlot;
|
|
603
603
|
private releaseQuerySlot;
|
|
604
|
+
/**
|
|
605
|
+
* Resolve once every statement issued before this call has settled: take
|
|
606
|
+
* every slot (FIFO, so earlier statements go first), then hand them back.
|
|
607
|
+
* Bounded by {@link queryTimeoutMs}, like the statements it waits on.
|
|
608
|
+
*/
|
|
609
|
+
protected drainStatements(): Promise<void>;
|
|
604
610
|
/**
|
|
605
611
|
* Hook run right before a statement is sent, inside its slot. The remote
|
|
606
612
|
* service uses it to hold statements while a connect is in flight, so a
|
|
@@ -851,8 +857,11 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
851
857
|
/**
|
|
852
858
|
* Subscribe to auth state changes.
|
|
853
859
|
* callback is called immediately with current value and whenever validation status changes.
|
|
860
|
+
* `immediate: false` skips that first call: only changes are reported.
|
|
854
861
|
*/
|
|
855
|
-
subscribe(cb: (userId: string | null) => void
|
|
862
|
+
subscribe(cb: (userId: string | null) => void, opts?: {
|
|
863
|
+
immediate?: boolean;
|
|
864
|
+
}): () => void;
|
|
856
865
|
/**
|
|
857
866
|
* Run `hook` at the start of every sign-out, while the token and the socket
|
|
858
867
|
* still belong to the leaving user (Web Push removes this device's
|
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.329",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,10 +75,11 @@
|
|
|
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.329",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.329",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
|
-
"@surrealdb/wasm": "^3.0
|
|
81
|
+
"@surrealdb/wasm": "^3.1.0",
|
|
82
|
+
"@surrealdb/wasm-native": "^3.3.4",
|
|
82
83
|
"blurhash": "^2.0.5",
|
|
83
84
|
"fast-json-patch": "^3.1.1",
|
|
84
85
|
"loro-crdt": "1.11.0",
|
|
@@ -47,7 +47,7 @@ See [references/config.md](references/config.md) for all configuration options.
|
|
|
47
47
|
Key fields:
|
|
48
48
|
- `database.endpoint` — SurrealDB WebSocket URL
|
|
49
49
|
- `database.namespace` / `database.database` — SurrealDB namespace and database
|
|
50
|
-
- `database.store` — `'
|
|
50
|
+
- `database.store` — `'memory'` (transient, the default) or `'indexeddb'` (persistent; needs `@surrealdb/wasm` >= 3.1.0, and Vite apps exclude `@surrealdb/wasm-native` from `optimizeDeps`)
|
|
51
51
|
- `schema` — The `SchemaStructure` object (use `as const satisfies SchemaStructure`)
|
|
52
52
|
- `schemaSurql` — The compiled SURQL schema string for local DB provisioning
|
|
53
53
|
- `logLevel` — `'debug' | 'info' | 'warn' | 'error'`
|
|
@@ -9,7 +9,7 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
9
9
|
namespace: string;
|
|
10
10
|
/** SurrealDB database name */
|
|
11
11
|
database: string;
|
|
12
|
-
/** Local store type: 'memory' (transient) or 'indexeddb' (persistent) */
|
|
12
|
+
/** Local store type: 'memory' (transient, default) or 'indexeddb' (persistent) */
|
|
13
13
|
store?: 'memory' | 'indexeddb';
|
|
14
14
|
/** Authentication token */
|
|
15
15
|
token?: string;
|
|
@@ -78,6 +78,41 @@ describe('Sp00kyClient facade', () => {
|
|
|
78
78
|
expect(client.liveRetryCount).toBe(0);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it('a warm boot opens the hinted bucket alone; no anon switch races it', async () => {
|
|
82
|
+
// A reload of a signed-in tab: the hint names the user's bucket and the
|
|
83
|
+
// store takes a while to open (IndexedDB). The auth service's signed-out
|
|
84
|
+
// answer at construction used to dispatch a flip to anon, which switched
|
|
85
|
+
// stores and provisioned while boot was still connecting.
|
|
86
|
+
let bucket = 'anon';
|
|
87
|
+
let opened!: () => void;
|
|
88
|
+
const { client, a, services } = makeClient({
|
|
89
|
+
services: {
|
|
90
|
+
'local.connect': (id: string) => {
|
|
91
|
+
bucket = id;
|
|
92
|
+
return new Promise<void>((r) => (opened = r));
|
|
93
|
+
},
|
|
94
|
+
'local.currentBucketId': () => bucket,
|
|
95
|
+
'local.beginSwitch': () => () => {},
|
|
96
|
+
'local.switchStore': async (id: string) => void (bucket = id),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const init = client.init();
|
|
100
|
+
await flush();
|
|
101
|
+
opened();
|
|
102
|
+
await init;
|
|
103
|
+
await flush();
|
|
104
|
+
expect(a.calls.filter(([n]) => n === 'service.local.connect').map(([, args]) => args)).toEqual([['u1']]);
|
|
105
|
+
expect(a.names()).not.toContain('service.local.beginSwitch');
|
|
106
|
+
expect(a.names()).not.toContain('service.local.switchStore');
|
|
107
|
+
expect(a.names()).not.toContain('service.hint.write');
|
|
108
|
+
expect(client.state.bucketId).toBe('u1');
|
|
109
|
+
|
|
110
|
+
// A real auth change still flips.
|
|
111
|
+
services.authListeners.forEach((cb) => cb(null));
|
|
112
|
+
await flush();
|
|
113
|
+
expect(a.calls.filter(([n]) => n === 'service.local.switchStore').map(([, args]) => args)).toEqual([['anon']]);
|
|
114
|
+
});
|
|
115
|
+
|
|
81
116
|
it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
|
|
82
117
|
// Boot loads the view index: `thing:1` is vouched for, `thing:2` is an orphan and stays hidden.
|
|
83
118
|
const stored = (sql: string) =>
|
|
@@ -223,7 +223,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
223
223
|
const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
|
|
224
224
|
s.streamProcessor.addReceiver(receiver);
|
|
225
225
|
s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
|
|
226
|
-
|
|
226
|
+
// Changes only. The immediate answer here is always "signed out" (nothing
|
|
227
|
+
// is restored before `init()`), and boot owns the first identity: it opens
|
|
228
|
+
// the bucket the hint names and announces the restored session itself. As
|
|
229
|
+
// a flip, that answer switched to the anon bucket while boot was still
|
|
230
|
+
// opening the user's, so two clients provisioned at once, boot failed, and
|
|
231
|
+
// a persistent store never got past the provider's fallback on reload.
|
|
232
|
+
s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }), { immediate: false });
|
|
227
233
|
// Whoever is looking at the page should be able to tell, and leave. The
|
|
228
234
|
// app can restyle this, replace it (`mode: 'custom'`, with a fallback if
|
|
229
235
|
// its own never renders) or take the job over entirely (`mode: 'none'`).
|
|
@@ -56,6 +56,20 @@ describe('restoreSessionFromToken', () => {
|
|
|
56
56
|
expect(seen).toContain('user:abc');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it('reports only changes to a subscriber that opts out of the immediate call', async () => {
|
|
60
|
+
const { auth } = makeAuth({ token: jwt({ AC: 'account', ID: 'user:abc' }) });
|
|
61
|
+
const all: (string | null)[] = [];
|
|
62
|
+
const changes: (string | null)[] = [];
|
|
63
|
+
auth.subscribe((uid) => all.push(uid));
|
|
64
|
+
auth.subscribe((uid) => changes.push(uid), { immediate: false });
|
|
65
|
+
expect(all).toEqual([null]);
|
|
66
|
+
expect(changes).toEqual([]);
|
|
67
|
+
|
|
68
|
+
await auth.restoreSessionFromToken();
|
|
69
|
+
|
|
70
|
+
expect(changes).toEqual(['user:abc']);
|
|
71
|
+
});
|
|
72
|
+
|
|
59
73
|
it('returns null when there is no token, and when the token carries no id', async () => {
|
|
60
74
|
expect(await makeAuth().auth.restoreSessionFromToken()).toBeNull();
|
|
61
75
|
const noId = makeAuth({ token: jwt({ AC: 'account' }) });
|
|
@@ -150,10 +150,10 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
150
150
|
/**
|
|
151
151
|
* Subscribe to auth state changes.
|
|
152
152
|
* callback is called immediately with current value and whenever validation status changes.
|
|
153
|
+
* `immediate: false` skips that first call: only changes are reported.
|
|
153
154
|
*/
|
|
154
|
-
subscribe(cb: (userId: string | null) => void): () => void {
|
|
155
|
-
|
|
156
|
-
cb(this.announcedUserId());
|
|
155
|
+
subscribe(cb: (userId: string | null) => void, opts: { immediate?: boolean } = {}): () => void {
|
|
156
|
+
if (opts.immediate !== false) cb(this.announcedUserId());
|
|
157
157
|
|
|
158
158
|
const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
|
|
159
159
|
cb(event.payload);
|
|
@@ -178,6 +178,29 @@ describe('rows the server takes out of a query', () => {
|
|
|
178
178
|
expect((await runPure(applySubqueryChildren('a', []), { state: pending, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
|
|
179
179
|
const follower = { ...base, tabRole: 'follower' as const };
|
|
180
180
|
expect((await runPure(applySubqueryChildren('a', []), { state: follower, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
|
|
181
|
+
const writing = {
|
|
182
|
+
...base,
|
|
183
|
+
pendingWrites: new Map([['k', { key: 'k', table: 'c', recordId: 'c:1', data: {}, before: null, firstAt: 0, mirrorId: null }]]),
|
|
184
|
+
};
|
|
185
|
+
expect((await runPure(applySubqueryChildren('a', []), { state: writing, handlers })).log.some((e) => e.kind === 'local.delete')).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
it('a leader relays the removal to its followers; a failed local delete keeps the row known', async () => {
|
|
188
|
+
const base = buildState(
|
|
189
|
+
[buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' }, remoteArray: [['t:1', 1]], subqueryRemoteArray: [['c:1', 1]] })],
|
|
190
|
+
R.setVersions([['t:1', 1], ['c:1', 1]])
|
|
191
|
+
);
|
|
192
|
+
const ok = { 'local.upsert': () => undefined, 'local.delete': () => undefined, 'ssp.ingest': () => undefined };
|
|
193
|
+
const leader = await runPure(applySubqueryChildren('a', []), { state: { ...base, tabRole: 'leader' as const }, handlers: ok });
|
|
194
|
+
expect(leader.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [{ table: 'c', op: 'DELETE', id: 'c:1', record: {} }] } });
|
|
195
|
+
const failing = {
|
|
196
|
+
...ok,
|
|
197
|
+
'local.delete': () => {
|
|
198
|
+
throw new Error('locked');
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const out = await runPure(applySubqueryChildren('a', []), { state: base, handlers: failing });
|
|
202
|
+
expect(out.state.versions.has('c:1')).toBe(true);
|
|
203
|
+
expect(out.log.some((e) => e.kind === 'ssp.ingest')).toBe(false);
|
|
181
204
|
});
|
|
182
205
|
});
|
|
183
206
|
|
|
@@ -86,6 +86,16 @@ export abstract class AbstractDatabaseService {
|
|
|
86
86
|
if (next) next();
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Resolve once every statement issued before this call has settled: take
|
|
91
|
+
* every slot (FIFO, so earlier statements go first), then hand them back.
|
|
92
|
+
* Bounded by {@link queryTimeoutMs}, like the statements it waits on.
|
|
93
|
+
*/
|
|
94
|
+
protected async drainStatements(): Promise<void> {
|
|
95
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) await this.acquireQuerySlot();
|
|
96
|
+
for (let i = 0; i < this.maxConcurrentQueries; i++) this.releaseQuerySlot();
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
/**
|
|
90
100
|
* Hook run right before a statement is sent, inside its slot. The remote
|
|
91
101
|
* service uses it to hold statements while a connect is in flight, so a
|
|
@@ -62,3 +62,89 @@ describe('matchesBucketStore', () => {
|
|
|
62
62
|
expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
// A bucket switch that lands while the boot open is still in flight (a reload
|
|
67
|
+
// of a signed-in tab, with IndexedDB slow to open) must wait for it. Run
|
|
68
|
+
// concurrently, the switch closed the client `connect` was opening and both
|
|
69
|
+
// provisioned a store at once, so boot never finished.
|
|
70
|
+
import { vi } from 'vitest';
|
|
71
|
+
import { Surreal } from 'surrealdb';
|
|
72
|
+
import { LocalDatabaseService } from './local';
|
|
73
|
+
|
|
74
|
+
vi.mock('@surrealdb/wasm', () => ({ createWasmWorkerEngines: () => ({}) }));
|
|
75
|
+
|
|
76
|
+
describe('LocalDatabaseService open and switch', () => {
|
|
77
|
+
it('a switch waits for the in-flight connect instead of closing the client it opens', async () => {
|
|
78
|
+
const opens: Array<{ url: string; done: () => void }> = [];
|
|
79
|
+
const closed: string[] = [];
|
|
80
|
+
const urlOf = new WeakMap<object, string>();
|
|
81
|
+
vi.spyOn(Surreal.prototype, 'connect').mockImplementation(function (this: Surreal, url: string | URL) {
|
|
82
|
+
urlOf.set(this, String(url));
|
|
83
|
+
return new Promise<true>((resolve) => opens.push({ url: String(url), done: () => resolve(true) }));
|
|
84
|
+
});
|
|
85
|
+
vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
|
|
86
|
+
vi.spyOn(Surreal.prototype, 'close').mockImplementation(async function (this: Surreal) {
|
|
87
|
+
closed.push(urlOf.get(this) ?? 'placeholder');
|
|
88
|
+
return true as const;
|
|
89
|
+
});
|
|
90
|
+
const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
91
|
+
logger.child = () => logger;
|
|
92
|
+
const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store: 'indexeddb' } as any, logger);
|
|
93
|
+
|
|
94
|
+
const booting = local.connect('u1');
|
|
95
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
96
|
+
const switching = local.switchStore('anon');
|
|
97
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
98
|
+
expect(opens.map((o) => o.url)).toEqual(['indxdb://sp00ky-u1']);
|
|
99
|
+
expect(closed).toEqual([]);
|
|
100
|
+
|
|
101
|
+
opens[0].done();
|
|
102
|
+
await booting;
|
|
103
|
+
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
104
|
+
expect(opens[1].url).toBe('indxdb://sp00ky-anon');
|
|
105
|
+
opens[1].done();
|
|
106
|
+
await switching;
|
|
107
|
+
expect(closed).toEqual(['indxdb://sp00ky-u1']);
|
|
108
|
+
expect(local.currentBucketId).toBe('anon');
|
|
109
|
+
vi.restoreAllMocks();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The engine streams a statement's result from its worker; closing the
|
|
113
|
+
// client under it left the statement pending until its 30 s deadline, and
|
|
114
|
+
// every later local statement queued behind it (sign-in hung on "Loading").
|
|
115
|
+
it.each(['memory', 'indexeddb'] as const)('%s: a switch lets a statement in flight finish before it closes the client', async (store) => {
|
|
116
|
+
let answer!: (rows: unknown[]) => void;
|
|
117
|
+
const events: string[] = [];
|
|
118
|
+
vi.spyOn(Surreal.prototype, 'connect').mockResolvedValue(true);
|
|
119
|
+
vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
|
|
120
|
+
vi.spyOn(Surreal.prototype, 'query').mockImplementation((() =>
|
|
121
|
+
new Promise((r) => {
|
|
122
|
+
answer = (rows) => {
|
|
123
|
+
events.push('answered');
|
|
124
|
+
r(rows);
|
|
125
|
+
};
|
|
126
|
+
})) as any);
|
|
127
|
+
vi.spyOn(Surreal.prototype, 'close').mockImplementation(async () => {
|
|
128
|
+
events.push('close');
|
|
129
|
+
return true as const;
|
|
130
|
+
});
|
|
131
|
+
const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
132
|
+
logger.child = () => logger;
|
|
133
|
+
const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store } as any, logger);
|
|
134
|
+
await local.connect('anon');
|
|
135
|
+
|
|
136
|
+
const reading = local.query('SELECT * FROM thing');
|
|
137
|
+
await vi.waitFor(() => expect(answer).toBeDefined());
|
|
138
|
+
const release = local.beginSwitch();
|
|
139
|
+
const switching = local.switchStore('u1');
|
|
140
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
141
|
+
expect(events).toEqual([]);
|
|
142
|
+
|
|
143
|
+
answer([[]]);
|
|
144
|
+
await reading;
|
|
145
|
+
await switching;
|
|
146
|
+
release();
|
|
147
|
+
expect(events).toEqual(['answered', 'close']);
|
|
148
|
+
vi.restoreAllMocks();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -104,6 +104,8 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
104
104
|
private gate: Promise<void> = Promise.resolve();
|
|
105
105
|
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
106
106
|
private pendingSwitchClient: Surreal | null = null;
|
|
107
|
+
/** The open `connect()` started, until it settles. */
|
|
108
|
+
private opening: Promise<void> | null = null;
|
|
107
109
|
|
|
108
110
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
|
|
109
111
|
const events = createDatabaseEventSystem();
|
|
@@ -176,6 +178,16 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
async connect(bucketId: string = ANON_USER_ID): Promise<void> {
|
|
181
|
+
const opening = this.openBucket(bucketId);
|
|
182
|
+
this.opening = opening;
|
|
183
|
+
try {
|
|
184
|
+
await opening;
|
|
185
|
+
} finally {
|
|
186
|
+
if (this.opening === opening) this.opening = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async openBucket(bucketId: string): Promise<void> {
|
|
179
191
|
const { namespace, database } = this.getConfig();
|
|
180
192
|
const store = this.getConfig().store ?? 'memory';
|
|
181
193
|
this.bucketId = bucketId;
|
|
@@ -201,9 +213,16 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
201
213
|
* async chains can detect they're stale.
|
|
202
214
|
*
|
|
203
215
|
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
204
|
-
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
216
|
+
* timers BEFORE calling this; re-provision + rebind AFTER). Statements that
|
|
217
|
+
* passed the gate before it closed are let finish before the old client
|
|
218
|
+
* closes: the engine streams results from its worker, and a client closed
|
|
219
|
+
* under a statement leaves that statement pending until its deadline, with
|
|
220
|
+
* every later local statement queued behind it.
|
|
205
221
|
*/
|
|
206
222
|
async switchStore(bucketId: string): Promise<void> {
|
|
223
|
+
// Never overlap the boot open: the switch would close the client `connect`
|
|
224
|
+
// is still opening, and both would provision a store at once.
|
|
225
|
+
if (this.opening) await this.opening.catch(() => {});
|
|
207
226
|
if (bucketId === this.bucketId) return;
|
|
208
227
|
const { namespace, database } = this.getConfig();
|
|
209
228
|
const store = this.getConfig().store ?? 'memory';
|
|
@@ -212,6 +231,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
212
231
|
if (store === 'memory') {
|
|
213
232
|
// mem:// has no per-user persistence; close + reopen the same client
|
|
214
233
|
// yields a fresh empty store, which is exactly the reset we want.
|
|
234
|
+
await this.drainStatements();
|
|
215
235
|
try {
|
|
216
236
|
await this.client.close();
|
|
217
237
|
} catch {
|
|
@@ -241,6 +261,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
241
261
|
this.pendingSwitchClient = null;
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
await this.drainStatements();
|
|
244
265
|
const old = this.client;
|
|
245
266
|
this.client = next;
|
|
246
267
|
this.bucketId = bucketId;
|
|
@@ -102,7 +102,8 @@ export function pureWriteOpResult(op: SqlOp): unknown {
|
|
|
102
102
|
* shared {@link resolveRelations} — identical to the SurrealDB backend.
|
|
103
103
|
*
|
|
104
104
|
* Value normalization (JSON round-trip):
|
|
105
|
-
* -
|
|
105
|
+
* - Bytes in any shape (`Uint8Array`, the SDK's `ArrayBuffer`) →
|
|
106
|
+
* `{ "__u8": <base64> }`, read back as `Uint8Array` (CRDT data survives).
|
|
106
107
|
* - Record links / ids → their `table:id` string form (so `json_extract`
|
|
107
108
|
* comparisons and `IN` matching are consistent). NOTE: link fields therefore
|
|
108
109
|
* read back as strings, not `RecordId` instances — the one shape difference
|
|
@@ -93,6 +93,25 @@ describe('serializeRow / reviveRow round-trip', () => {
|
|
|
93
93
|
expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
// The surrealdb SDK decodes a `bytes` column as an ArrayBuffer, which
|
|
97
|
+
// JSON.stringify writes as `{}`: synced CRDT log rows (and presence states)
|
|
98
|
+
// landed with no data, so every other client's edit was refused as empty.
|
|
99
|
+
it('keeps the bytes of every binary shape: an SDK ArrayBuffer, a view into a larger buffer', () => {
|
|
100
|
+
const backing = new Uint8Array([9, 9, 1, 2, 250, 9]);
|
|
101
|
+
const json = serializeRow({
|
|
102
|
+
id: 'thread__crdt_log:1',
|
|
103
|
+
data: backing.slice(2, 5).buffer,
|
|
104
|
+
window: backing.subarray(2, 5),
|
|
105
|
+
dv: new DataView(backing.buffer, 2, 3),
|
|
106
|
+
nested: { state: [new Int8Array([1, 2, -6]).buffer] },
|
|
107
|
+
});
|
|
108
|
+
const back = reviveRow(json) as any;
|
|
109
|
+
for (const bytes of [back.data, back.window, back.dv, back.nested.state[0]]) {
|
|
110
|
+
expect(bytes).toBeInstanceOf(Uint8Array);
|
|
111
|
+
expect([...bytes]).toEqual([1, 2, 250]);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
96
115
|
it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
|
|
97
116
|
const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
|
|
98
117
|
expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
|
|
@@ -61,11 +61,12 @@ export function scalar(value: unknown): unknown {
|
|
|
61
61
|
|
|
62
62
|
export function serializeRow(row: Row): string {
|
|
63
63
|
return JSON.stringify(row, function (this: Record<string, unknown>, k, v) {
|
|
64
|
-
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
65
64
|
// `v` has already been through `toJSON()`, and a RecordId's is escaped
|
|
66
65
|
// SurrealQL (`game:⟨476g…⟩`), so key the original value instead.
|
|
67
66
|
const raw = this[k];
|
|
68
67
|
if (raw && typeof raw === 'object') {
|
|
68
|
+
const bytes = bytesOf(raw);
|
|
69
|
+
if (bytes) return { __u8: toBase64(bytes) };
|
|
69
70
|
const key = recordIdKey(raw);
|
|
70
71
|
if (key !== null) return key;
|
|
71
72
|
}
|
|
@@ -73,6 +74,19 @@ export function serializeRow(row: Row): string {
|
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The bytes of a binary value, whatever shape it arrived in. Rows from the
|
|
79
|
+
* server carry a `bytes` column as an `ArrayBuffer` (the surrealdb SDK's CBOR
|
|
80
|
+
* decode), not a `Uint8Array`, and `JSON.stringify` turns an `ArrayBuffer`
|
|
81
|
+
* into `{}`: every synced CRDT log row and presence state was stored empty.
|
|
82
|
+
*/
|
|
83
|
+
function bytesOf(value: object): Uint8Array | null {
|
|
84
|
+
if (value instanceof Uint8Array) return value;
|
|
85
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
86
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
/**
|
|
77
91
|
* Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
|
|
78
92
|
* fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
|
|
@@ -324,4 +324,58 @@ describe('tabs broker: namespaces', () => {
|
|
|
324
324
|
// and a is offered leadership of the new bucket.
|
|
325
325
|
expect(a.ofType('become-leader').length).toBeGreaterThan(1);
|
|
326
326
|
});
|
|
327
|
+
|
|
328
|
+
it('keeps the port of a leader whose released tab lock means it left the bucket, not that it died', async () => {
|
|
329
|
+
// The broker's crash detector queues a request on the leader's tab lock;
|
|
330
|
+
// here it is granted the moment the tab releases it, as on sign-in.
|
|
331
|
+
const grants: Array<() => void> = [];
|
|
332
|
+
vi.stubGlobal('navigator', {
|
|
333
|
+
locks: {
|
|
334
|
+
request: (_name: string, opts: { ifAvailable?: boolean; steal?: boolean }, cb: (lock: unknown) => unknown) =>
|
|
335
|
+
new Promise((resolve) => {
|
|
336
|
+
if (opts.ifAvailable || opts.steal) resolve(cb({}));
|
|
337
|
+
else grants.push(() => resolve(cb({})));
|
|
338
|
+
}),
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
try {
|
|
342
|
+
const a = connectTab('tab-a');
|
|
343
|
+
await ackLeadership(a);
|
|
344
|
+
expect(grants).toHaveLength(1);
|
|
345
|
+
grants[0]();
|
|
346
|
+
await flush();
|
|
347
|
+
a.send({
|
|
348
|
+
type: 'hello',
|
|
349
|
+
tabId: 'tab-a',
|
|
350
|
+
fingerprint: 'fp-test',
|
|
351
|
+
bucketId: 'user1',
|
|
352
|
+
visibility: 'visible',
|
|
353
|
+
heldLeadership: null,
|
|
354
|
+
});
|
|
355
|
+
await flush();
|
|
356
|
+
expect(a.ofType('become-leader').map((m) => m.leadershipId)).toEqual([1, 2]);
|
|
357
|
+
} finally {
|
|
358
|
+
vi.unstubAllGlobals();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('elects the only tab in its new bucket when it signs in alone', async () => {
|
|
363
|
+
const a = connectTab('tab-a');
|
|
364
|
+
await ackLeadership(a);
|
|
365
|
+
a.send({
|
|
366
|
+
type: 'hello',
|
|
367
|
+
tabId: 'tab-a',
|
|
368
|
+
fingerprint: 'fp-test',
|
|
369
|
+
bucketId: 'user1',
|
|
370
|
+
visibility: 'visible',
|
|
371
|
+
heldLeadership: null,
|
|
372
|
+
});
|
|
373
|
+
await flush();
|
|
374
|
+
expect(a.ofType('become-leader').map((m) => m.leadershipId)).toEqual([1, 2]);
|
|
375
|
+
await ackLeadership(a, 'user1');
|
|
376
|
+
// Still pinged in the new bucket, so the client's watchdog stays quiet.
|
|
377
|
+
const pings = a.ofType('ping').length;
|
|
378
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
379
|
+
expect(a.ofType('ping').length).toBeGreaterThan(pings);
|
|
380
|
+
});
|
|
327
381
|
});
|
|
@@ -183,7 +183,12 @@ function clearLeader(
|
|
|
183
183
|
if (tab && opts.demote) {
|
|
184
184
|
post(tab.port, { type: 'demote', brokerInstanceId, leadershipId: leader.leadershipId });
|
|
185
185
|
}
|
|
186
|
-
|
|
186
|
+
// Its port stays open: the only caller is the tab-lock monitor, and a lock
|
|
187
|
+
// released is not always a dead tab. A leader that signs in or out drops
|
|
188
|
+
// its lock before re-helloing into the new bucket, and closing the port
|
|
189
|
+
// here swallowed that hello, leaving the tab roleless until its watchdog
|
|
190
|
+
// reconnected. A dead tab's port is gone anyway.
|
|
191
|
+
if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false, closePort: false });
|
|
187
192
|
// Followers must drop their ports; new ones are minted after re-election.
|
|
188
193
|
for (const t of ns.tabs.values()) {
|
|
189
194
|
if (t.tabId !== leader.tabId) {
|
|
@@ -250,8 +255,9 @@ function failPromotion(
|
|
|
250
255
|
/**
|
|
251
256
|
* Fast crash detection: queue a Web Lock request on the leader's tab lock.
|
|
252
257
|
* Locks release the instant their holder's tab dies, so being GRANTED means
|
|
253
|
-
* the leader is gone
|
|
254
|
-
*
|
|
258
|
+
* the leader is gone from this bucket (dead, or moved to another bucket on
|
|
259
|
+
* sign-in/out); release immediately (return from the callback) and re-elect.
|
|
260
|
+
* The leadershipId guard drops stale grants after a normal handoff.
|
|
255
261
|
*/
|
|
256
262
|
function startTabLockMonitor(ns: Namespace, leadershipId: number): void {
|
|
257
263
|
const locks = (navigator as { locks?: LockManager }).locks;
|
|
@@ -489,7 +495,6 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
|
|
|
489
495
|
post(port, { type: 'unsupported', brokerInstanceId, reason: 'fingerprint-mismatch' });
|
|
490
496
|
return;
|
|
491
497
|
}
|
|
492
|
-
const ns = getNamespace(msg.fingerprint, msg.bucketId);
|
|
493
498
|
// Drop stale state for this tab EVERYWHERE first. A re-hello is either a
|
|
494
499
|
// reconnect (same namespace) or a bucket switch (leave old namespace,
|
|
495
500
|
// join the new one); in both cases exactly one entry may remain, and if
|
|
@@ -498,6 +503,13 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
|
|
|
498
503
|
for (const other of [...namespaces.values()]) {
|
|
499
504
|
if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, 'rehello', { closePort: false });
|
|
500
505
|
}
|
|
506
|
+
// Only now look the namespace up: evicting the broker's last tab resets it
|
|
507
|
+
// as idle (all namespaces and the fingerprint dropped), and a namespace
|
|
508
|
+
// fetched before that would be orphaned, never pinged or found again. A
|
|
509
|
+
// lone tab signing in lost its leader that way until its watchdog
|
|
510
|
+
// reconnected.
|
|
511
|
+
canonicalFingerprint ??= msg.fingerprint;
|
|
512
|
+
const ns = getNamespace(msg.fingerprint, msg.bucketId);
|
|
501
513
|
const tab: BrokerTab = {
|
|
502
514
|
port,
|
|
503
515
|
tabId: msg.tabId,
|
|
@@ -38,7 +38,22 @@ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Servi
|
|
|
38
38
|
streamProcessor: { addReceiver: (r: unknown) => void receivers.push(r), checkpoint: async () => undefined, dispose: noop } as any,
|
|
39
39
|
migrator: {} as any,
|
|
40
40
|
crdt: { attach: noop, setSessionId: noop, closeAll: noop, dispose: noop, open: async () => 'field', close: async () => {} } as any,
|
|
41
|
-
|
|
41
|
+
// Mirrors `AuthService.subscribe`: the current (signed-out) identity at
|
|
42
|
+
// once unless the subscriber opts out, then every change.
|
|
43
|
+
auth: {
|
|
44
|
+
subscribe: (cb: (u: string | null) => void, opts: { immediate?: boolean } = {}) => {
|
|
45
|
+
if (opts.immediate !== false) cb(null);
|
|
46
|
+
authListeners.push(cb);
|
|
47
|
+
return noop;
|
|
48
|
+
},
|
|
49
|
+
subscribeImpersonation: () => noop,
|
|
50
|
+
onBeforeSignOut: () => noop,
|
|
51
|
+
currentUser: null,
|
|
52
|
+
isAuthenticated: false,
|
|
53
|
+
token: null,
|
|
54
|
+
impersonation: null,
|
|
55
|
+
eventSystem: { subscribe: noop },
|
|
56
|
+
} as any,
|
|
42
57
|
tabs: null,
|
|
43
58
|
tabsUnsupportedReason: 'test',
|
|
44
59
|
tabId: 'tab-test',
|