@spooky-sync/core 0.0.1-canary.215 → 0.0.1-canary.217
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +32 -0
- package/dist/index.js +90 -18
- package/package.json +3 -3
- package/src/modules/sync/queue/queue-down.test.ts +50 -2
- package/src/modules/sync/queue/queue-down.ts +28 -0
- package/src/modules/sync/sync.ts +92 -22
package/dist/index.d.ts
CHANGED
|
@@ -1314,6 +1314,10 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1314
1314
|
* that a genuine drop minutes later still refetches.
|
|
1315
1315
|
*/
|
|
1316
1316
|
private static readonly RECONNECT_REFETCH_COOLDOWN_MS;
|
|
1317
|
+
/** Poll interval while waiting for a reconnected session to re-authenticate. */
|
|
1318
|
+
private static readonly AUTH_READY_RETRY_MS;
|
|
1319
|
+
/** Attempts before giving up on re-auth and skipping the refetch entirely. */
|
|
1320
|
+
private static readonly AUTH_READY_MAX_ATTEMPTS;
|
|
1317
1321
|
events: SyncEventSystem;
|
|
1318
1322
|
private currentUserId;
|
|
1319
1323
|
private tabRole;
|
|
@@ -1555,6 +1559,34 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1555
1559
|
*/
|
|
1556
1560
|
private invalidateRefLiveQuery;
|
|
1557
1561
|
private subscribeToReconnect;
|
|
1562
|
+
/**
|
|
1563
|
+
* Whether the REMOTE SESSION currently carries `$auth.id`.
|
|
1564
|
+
*
|
|
1565
|
+
* Not the same question as `currentUserId`, and that gap is the whole point:
|
|
1566
|
+
* `currentUserId` is this client's own record of who signed in and survives a
|
|
1567
|
+
* socket drop untouched, while `$auth` lives on the WebSocket session and has
|
|
1568
|
+
* to be re-applied after every reconnect. Registering in the window between
|
|
1569
|
+
* the two is silently destructive, because `fn::query::register` sends
|
|
1570
|
+
* `<string>($auth.id OR '')` and the SSP stores that value write-once: the
|
|
1571
|
+
* view's edges then route to the global `_00_list_ref` stamped `auth_id = ''`,
|
|
1572
|
+
* which that table's own permission rule (`auth_id = $auth.id`) makes
|
|
1573
|
+
* unreadable to the very user who registered it.
|
|
1574
|
+
*
|
|
1575
|
+
* Signed-out clients answer `true`: `''` is the honest identity there, not a
|
|
1576
|
+
* race.
|
|
1577
|
+
*/
|
|
1578
|
+
private remoteAuthEstablished;
|
|
1579
|
+
/**
|
|
1580
|
+
* Wait for the reconnected session to carry an identity again, then
|
|
1581
|
+
* re-register every active query and re-bind LIVE.
|
|
1582
|
+
*
|
|
1583
|
+
* Giving up without registering is deliberately better than registering
|
|
1584
|
+
* anyway: a registration made with no `$auth.id` produces a view its own
|
|
1585
|
+
* owner cannot read, and it is write-once, so it stays that way. Skipping
|
|
1586
|
+
* leaves the query unregistered and visibly loading, which the next
|
|
1587
|
+
* reconnect or heartbeat retries.
|
|
1588
|
+
*/
|
|
1589
|
+
private refetchAfterReconnect;
|
|
1558
1590
|
private startRefLiveQueries;
|
|
1559
1591
|
private handleRemoteListRefChange;
|
|
1560
1592
|
/**
|
package/dist/index.js
CHANGED
|
@@ -5338,9 +5338,23 @@ var DownQueue = class {
|
|
|
5338
5338
|
return this.queue.length;
|
|
5339
5339
|
}
|
|
5340
5340
|
push(event) {
|
|
5341
|
+
if (event.type === "register" && this.lastQueuedTypeFor(event.payload.hash) === "register") {
|
|
5342
|
+
this.logger.debug({
|
|
5343
|
+
hash: event.payload.hash,
|
|
5344
|
+
Category: "sp00ky-client::DownQueue::push"
|
|
5345
|
+
}, "Register already queued for this hash; coalescing");
|
|
5346
|
+
return;
|
|
5347
|
+
}
|
|
5341
5348
|
this.queue.push(event);
|
|
5342
5349
|
this.emitPushEvent();
|
|
5343
5350
|
}
|
|
5351
|
+
/** Type of the last queued event for `hash`, or `undefined` if none. */
|
|
5352
|
+
lastQueuedTypeFor(hash) {
|
|
5353
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
5354
|
+
const queued = this.queue[i];
|
|
5355
|
+
if (queued.payload.hash === hash) return queued.type;
|
|
5356
|
+
}
|
|
5357
|
+
}
|
|
5344
5358
|
emitPushEvent() {
|
|
5345
5359
|
this._events.addEvent({
|
|
5346
5360
|
type: SyncQueueEventTypes.QueryItemEnqueued,
|
|
@@ -6012,6 +6026,10 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6012
6026
|
* that a genuine drop minutes later still refetches.
|
|
6013
6027
|
*/
|
|
6014
6028
|
static RECONNECT_REFETCH_COOLDOWN_MS = 1e4;
|
|
6029
|
+
/** Poll interval while waiting for a reconnected session to re-authenticate. */
|
|
6030
|
+
static AUTH_READY_RETRY_MS = 500;
|
|
6031
|
+
/** Attempts before giving up on re-auth and skipping the refetch entirely. */
|
|
6032
|
+
static AUTH_READY_MAX_ATTEMPTS = 10;
|
|
6015
6033
|
events = createSyncEventSystem();
|
|
6016
6034
|
currentUserId = null;
|
|
6017
6035
|
tabRole = "solo";
|
|
@@ -6176,7 +6194,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6176
6194
|
else if (this.downQueue.size > 0) await this.scheduler.syncDown();
|
|
6177
6195
|
else {
|
|
6178
6196
|
const hashes = this.dataModule.getActiveQueryHashes();
|
|
6179
|
-
if (hashes.length > 0) {
|
|
6197
|
+
if (hashes.length > 0 && await this.remoteAuthEstablished()) {
|
|
6180
6198
|
for (const hash of hashes) this.scheduler.enqueueDownEvent({
|
|
6181
6199
|
type: "register",
|
|
6182
6200
|
payload: { hash }
|
|
@@ -6725,21 +6743,75 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6725
6743
|
return;
|
|
6726
6744
|
}
|
|
6727
6745
|
this.lastReconnectRefetchAt = Date.now();
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6746
|
+
this.refetchAfterReconnect();
|
|
6747
|
+
});
|
|
6748
|
+
}
|
|
6749
|
+
/**
|
|
6750
|
+
* Whether the REMOTE SESSION currently carries `$auth.id`.
|
|
6751
|
+
*
|
|
6752
|
+
* Not the same question as `currentUserId`, and that gap is the whole point:
|
|
6753
|
+
* `currentUserId` is this client's own record of who signed in and survives a
|
|
6754
|
+
* socket drop untouched, while `$auth` lives on the WebSocket session and has
|
|
6755
|
+
* to be re-applied after every reconnect. Registering in the window between
|
|
6756
|
+
* the two is silently destructive, because `fn::query::register` sends
|
|
6757
|
+
* `<string>($auth.id OR '')` and the SSP stores that value write-once: the
|
|
6758
|
+
* view's edges then route to the global `_00_list_ref` stamped `auth_id = ''`,
|
|
6759
|
+
* which that table's own permission rule (`auth_id = $auth.id`) makes
|
|
6760
|
+
* unreadable to the very user who registered it.
|
|
6761
|
+
*
|
|
6762
|
+
* Signed-out clients answer `true`: `''` is the honest identity there, not a
|
|
6763
|
+
* race.
|
|
6764
|
+
*/
|
|
6765
|
+
async remoteAuthEstablished() {
|
|
6766
|
+
if (!this.currentUserId) return true;
|
|
6767
|
+
try {
|
|
6768
|
+
const result = await this.remote.query("RETURN <string>($auth.id OR '')");
|
|
6769
|
+
const authId = Array.isArray(result) ? result[0] : void 0;
|
|
6770
|
+
return typeof authId === "string" && authId.length > 0;
|
|
6771
|
+
} catch (err) {
|
|
6772
|
+
this.logger.debug({
|
|
6773
|
+
err,
|
|
6774
|
+
Category: "sp00ky-client::Sp00kySync::remoteAuthEstablished"
|
|
6775
|
+
}, "Auth probe failed; treating the session as not yet authenticated");
|
|
6776
|
+
return false;
|
|
6777
|
+
}
|
|
6778
|
+
}
|
|
6779
|
+
/**
|
|
6780
|
+
* Wait for the reconnected session to carry an identity again, then
|
|
6781
|
+
* re-register every active query and re-bind LIVE.
|
|
6782
|
+
*
|
|
6783
|
+
* Giving up without registering is deliberately better than registering
|
|
6784
|
+
* anyway: a registration made with no `$auth.id` produces a view its own
|
|
6785
|
+
* owner cannot read, and it is write-once, so it stays that way. Skipping
|
|
6786
|
+
* leaves the query unregistered and visibly loading, which the next
|
|
6787
|
+
* reconnect or heartbeat retries.
|
|
6788
|
+
*/
|
|
6789
|
+
async refetchAfterReconnect() {
|
|
6790
|
+
for (let attempt = 1; attempt <= Sp00kySync.AUTH_READY_MAX_ATTEMPTS; attempt++) {
|
|
6791
|
+
if (await this.remoteAuthEstablished()) break;
|
|
6792
|
+
if (attempt === Sp00kySync.AUTH_READY_MAX_ATTEMPTS) {
|
|
6793
|
+
this.logger.warn({
|
|
6794
|
+
attempts: attempt,
|
|
6740
6795
|
Category: "sp00ky-client::Sp00kySync::onReconnect"
|
|
6741
|
-
}, "
|
|
6742
|
-
|
|
6796
|
+
}, "Reconnected but the session still carries no $auth.id; not re-registering (a registration now would stamp every view with an empty identity)");
|
|
6797
|
+
return;
|
|
6798
|
+
}
|
|
6799
|
+
await new Promise((resolve) => setTimeout(resolve, Sp00kySync.AUTH_READY_RETRY_MS));
|
|
6800
|
+
}
|
|
6801
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
6802
|
+
this.logger.info({
|
|
6803
|
+
queries: hashes.length,
|
|
6804
|
+
Category: "sp00ky-client::Sp00kySync::onReconnect"
|
|
6805
|
+
}, "Remote reconnected, refetching active queries");
|
|
6806
|
+
for (const hash of hashes) this.scheduler.enqueueDownEvent({
|
|
6807
|
+
type: "register",
|
|
6808
|
+
payload: { hash }
|
|
6809
|
+
});
|
|
6810
|
+
if (this.currentUserId || this.anonLiveEnabled) this.restartRefLiveQuery().catch((err) => {
|
|
6811
|
+
this.logger.debug({
|
|
6812
|
+
err,
|
|
6813
|
+
Category: "sp00ky-client::Sp00kySync::onReconnect"
|
|
6814
|
+
}, "LIVE restart after reconnect failed; relying on poll fallback");
|
|
6743
6815
|
});
|
|
6744
6816
|
}
|
|
6745
6817
|
async startRefLiveQueries() {
|
|
@@ -7560,8 +7632,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7560
7632
|
|
|
7561
7633
|
//#endregion
|
|
7562
7634
|
//#region src/modules/devtools/index.ts
|
|
7563
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7564
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7635
|
+
const CORE_VERSION = "0.0.1-canary.217";
|
|
7636
|
+
const WASM_VERSION = "0.0.1-canary.217";
|
|
7565
7637
|
const SURREAL_VERSION = "3.0.3";
|
|
7566
7638
|
var DevToolsService = class DevToolsService {
|
|
7567
7639
|
eventsHistory = [];
|
|
@@ -12572,7 +12644,7 @@ var Sp00kyClient = class {
|
|
|
12572
12644
|
return new TabsCoordinator({
|
|
12573
12645
|
tabId,
|
|
12574
12646
|
fingerprint: computeTabsFingerprint({
|
|
12575
|
-
coreVersion: "0.0.1-canary.
|
|
12647
|
+
coreVersion: "0.0.1-canary.217",
|
|
12576
12648
|
schemaHash: hash53(this.config.schemaSurql),
|
|
12577
12649
|
endpoint: this.config.database.endpoint ?? "",
|
|
12578
12650
|
namespace: this.config.database.namespace,
|
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.217",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.217",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.217",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"blurhash": "^2.0.5",
|
|
@@ -12,6 +12,7 @@ const silentLogger = {
|
|
|
12
12
|
} as any;
|
|
13
13
|
|
|
14
14
|
const register = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
|
|
15
|
+
const cleanup = (hash: string) => ({ type: 'cleanup', payload: { hash } }) as DownEvent;
|
|
15
16
|
|
|
16
17
|
const hashOf = (e: DownEvent) => e.payload.hash;
|
|
17
18
|
|
|
@@ -129,7 +130,7 @@ describe('DownQueue.takeNext (per-hash ordering under concurrency)', () => {
|
|
|
129
130
|
// queue and is simply passed over.
|
|
130
131
|
const q = makeQueue();
|
|
131
132
|
q.push(register('a'));
|
|
132
|
-
q.push(
|
|
133
|
+
q.push(cleanup('a'));
|
|
133
134
|
q.push(register('b'));
|
|
134
135
|
|
|
135
136
|
const busy = new Set(['a']);
|
|
@@ -138,7 +139,7 @@ describe('DownQueue.takeNext (per-hash ordering under concurrency)', () => {
|
|
|
138
139
|
|
|
139
140
|
// Once 'a' frees up, its two events come back out in their original order.
|
|
140
141
|
const rest = [q.takeNext(new Set())!, q.takeNext(new Set())!];
|
|
141
|
-
expect(rest.map(
|
|
142
|
+
expect(rest.map((e) => e.type)).toEqual(['register', 'cleanup']);
|
|
142
143
|
expect(q.size).toBe(0);
|
|
143
144
|
});
|
|
144
145
|
|
|
@@ -178,3 +179,50 @@ describe('DownQueue.run', () => {
|
|
|
178
179
|
expect(q.size).toBe(0);
|
|
179
180
|
});
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
describe('DownQueue.push register coalescing', () => {
|
|
184
|
+
it('drops a register for a hash that already has one queued', () => {
|
|
185
|
+
// Four paths re-enqueue `register` for every active hash (reconnect,
|
|
186
|
+
// self-heal, re-mount, and a re-headed failure) and they stack: one tab was
|
|
187
|
+
// measured issuing 84 registrations in 93 seconds for 38 hashes. A register
|
|
188
|
+
// carries nothing but the hash, so the second is identical work.
|
|
189
|
+
const q = makeQueue();
|
|
190
|
+
q.push(register('a'));
|
|
191
|
+
q.push(register('a'));
|
|
192
|
+
q.push(register('a'));
|
|
193
|
+
|
|
194
|
+
expect(q.size).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('keeps registers for DIFFERENT hashes', () => {
|
|
198
|
+
const q = makeQueue();
|
|
199
|
+
q.push(register('a'));
|
|
200
|
+
q.push(register('b'));
|
|
201
|
+
|
|
202
|
+
expect(q.size).toBe(2);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does NOT coalesce a register queued behind a cleanup for the same hash', () => {
|
|
206
|
+
// Ordering is per hash: dropping this register would let the cleanup tear
|
|
207
|
+
// the query down with nothing left to re-establish it.
|
|
208
|
+
const q = makeQueue();
|
|
209
|
+
q.push(register('a'));
|
|
210
|
+
q.push(cleanup('a'));
|
|
211
|
+
q.push(register('a'));
|
|
212
|
+
|
|
213
|
+
expect(q.size).toBe(3);
|
|
214
|
+
expect([q.takeNext(new Set())!, q.takeNext(new Set())!, q.takeNext(new Set())!].map((e) => e.type)).toEqual([
|
|
215
|
+
'register',
|
|
216
|
+
'cleanup',
|
|
217
|
+
'register',
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('leaves non-register events alone', () => {
|
|
222
|
+
const q = makeQueue();
|
|
223
|
+
q.push(cleanup('a'));
|
|
224
|
+
q.push(cleanup('a'));
|
|
225
|
+
|
|
226
|
+
expect(q.size).toBe(2);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -70,10 +70,38 @@ export class DownQueue {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
push(event: DownEvent) {
|
|
73
|
+
// Coalesce repeat registrations. Four separate paths re-enqueue `register`
|
|
74
|
+
// for every active hash — reconnect, self-heal while degraded, a
|
|
75
|
+
// re-mounted subscription, and a failed event re-headed by `run` — and
|
|
76
|
+
// they stack, so one tab was observed issuing 84 `fn::query::register`
|
|
77
|
+
// round trips in 93 seconds for 38 hashes. A register is idempotent and
|
|
78
|
+
// carries no payload beyond the hash, so a second one queued behind the
|
|
79
|
+
// first would re-do identical work.
|
|
80
|
+
//
|
|
81
|
+
// Only when the LAST queued event for the hash is itself a `register`.
|
|
82
|
+
// Ordering never mattered globally but it does per hash (see `takeNext`):
|
|
83
|
+
// dropping a register queued behind a `cleanup` would let the cleanup tear
|
|
84
|
+
// the query down with nothing to re-establish it.
|
|
85
|
+
if (event.type === 'register' && this.lastQueuedTypeFor(event.payload.hash) === 'register') {
|
|
86
|
+
this.logger.debug(
|
|
87
|
+
{ hash: event.payload.hash, Category: 'sp00ky-client::DownQueue::push' },
|
|
88
|
+
'Register already queued for this hash; coalescing'
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
73
92
|
this.queue.push(event);
|
|
74
93
|
this.emitPushEvent();
|
|
75
94
|
}
|
|
76
95
|
|
|
96
|
+
/** Type of the last queued event for `hash`, or `undefined` if none. */
|
|
97
|
+
private lastQueuedTypeFor(hash: string): DownEvent['type'] | undefined {
|
|
98
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
99
|
+
const queued = this.queue[i]!;
|
|
100
|
+
if (queued.payload.hash === hash) return queued.type;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
private emitPushEvent() {
|
|
78
106
|
this._events.addEvent({
|
|
79
107
|
type: SyncQueueEventTypes.QueryItemEnqueued,
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -126,6 +126,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
126
126
|
* that a genuine drop minutes later still refetches.
|
|
127
127
|
*/
|
|
128
128
|
private static readonly RECONNECT_REFETCH_COOLDOWN_MS = 10_000;
|
|
129
|
+
/** Poll interval while waiting for a reconnected session to re-authenticate. */
|
|
130
|
+
private static readonly AUTH_READY_RETRY_MS = 500;
|
|
131
|
+
/** Attempts before giving up on re-auth and skipping the refetch entirely. */
|
|
132
|
+
private static readonly AUTH_READY_MAX_ATTEMPTS = 10;
|
|
129
133
|
public events = createSyncEventSystem();
|
|
130
134
|
|
|
131
135
|
// Auth identity that drives per-user `_00_list_ref_user_<id>` routing
|
|
@@ -405,8 +409,14 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
405
409
|
// re-register active queries — mirroring the reconnect handler — so
|
|
406
410
|
// there's a concrete op whose success flips health. If there are no
|
|
407
411
|
// active queries either, probe connectivity directly.
|
|
412
|
+
// Same identity gate as the reconnect path: self-heal fires while
|
|
413
|
+
// sync is degraded, which is exactly when a session is most likely
|
|
414
|
+
// to have lost its `$auth`, and a register issued there stamps the
|
|
415
|
+
// view with an empty identity permanently. See
|
|
416
|
+
// `remoteAuthEstablished`.
|
|
408
417
|
const hashes = this.dataModule.getActiveQueryHashes();
|
|
409
|
-
|
|
418
|
+
const canRegister = hashes.length > 0 && (await this.remoteAuthEstablished());
|
|
419
|
+
if (canRegister) {
|
|
410
420
|
for (const hash of hashes) {
|
|
411
421
|
this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
412
422
|
}
|
|
@@ -1177,29 +1187,89 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1177
1187
|
return;
|
|
1178
1188
|
}
|
|
1179
1189
|
this.lastReconnectRefetchAt = Date.now();
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1190
|
+
void this.refetchAfterReconnect();
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Whether the REMOTE SESSION currently carries `$auth.id`.
|
|
1196
|
+
*
|
|
1197
|
+
* Not the same question as `currentUserId`, and that gap is the whole point:
|
|
1198
|
+
* `currentUserId` is this client's own record of who signed in and survives a
|
|
1199
|
+
* socket drop untouched, while `$auth` lives on the WebSocket session and has
|
|
1200
|
+
* to be re-applied after every reconnect. Registering in the window between
|
|
1201
|
+
* the two is silently destructive, because `fn::query::register` sends
|
|
1202
|
+
* `<string>($auth.id OR '')` and the SSP stores that value write-once: the
|
|
1203
|
+
* view's edges then route to the global `_00_list_ref` stamped `auth_id = ''`,
|
|
1204
|
+
* which that table's own permission rule (`auth_id = $auth.id`) makes
|
|
1205
|
+
* unreadable to the very user who registered it.
|
|
1206
|
+
*
|
|
1207
|
+
* Signed-out clients answer `true`: `''` is the honest identity there, not a
|
|
1208
|
+
* race.
|
|
1209
|
+
*/
|
|
1210
|
+
private async remoteAuthEstablished(): Promise<boolean> {
|
|
1211
|
+
if (!this.currentUserId) return true;
|
|
1212
|
+
try {
|
|
1213
|
+
const result = await this.remote.query<[string]>("RETURN <string>($auth.id OR '')");
|
|
1214
|
+
const authId = Array.isArray(result) ? result[0] : undefined;
|
|
1215
|
+
return typeof authId === 'string' && authId.length > 0;
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
this.logger.debug(
|
|
1218
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::remoteAuthEstablished' },
|
|
1219
|
+
'Auth probe failed; treating the session as not yet authenticated'
|
|
1184
1220
|
);
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1221
|
+
return false;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Wait for the reconnected session to carry an identity again, then
|
|
1227
|
+
* re-register every active query and re-bind LIVE.
|
|
1228
|
+
*
|
|
1229
|
+
* Giving up without registering is deliberately better than registering
|
|
1230
|
+
* anyway: a registration made with no `$auth.id` produces a view its own
|
|
1231
|
+
* owner cannot read, and it is write-once, so it stays that way. Skipping
|
|
1232
|
+
* leaves the query unregistered and visibly loading, which the next
|
|
1233
|
+
* reconnect or heartbeat retries.
|
|
1234
|
+
*/
|
|
1235
|
+
private async refetchAfterReconnect(): Promise<void> {
|
|
1236
|
+
for (let attempt = 1; attempt <= Sp00kySync.AUTH_READY_MAX_ATTEMPTS; attempt++) {
|
|
1237
|
+
if (await this.remoteAuthEstablished()) break;
|
|
1238
|
+
if (attempt === Sp00kySync.AUTH_READY_MAX_ATTEMPTS) {
|
|
1239
|
+
this.logger.warn(
|
|
1240
|
+
{
|
|
1241
|
+
attempts: attempt,
|
|
1242
|
+
Category: 'sp00ky-client::Sp00kySync::onReconnect',
|
|
1243
|
+
},
|
|
1244
|
+
'Reconnected but the session still carries no $auth.id; not re-registering (a registration now would stamp every view with an empty identity)'
|
|
1245
|
+
);
|
|
1246
|
+
return;
|
|
1201
1247
|
}
|
|
1202
|
-
|
|
1248
|
+
await new Promise((resolve) => setTimeout(resolve, Sp00kySync.AUTH_READY_RETRY_MS));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
1252
|
+
this.logger.info(
|
|
1253
|
+
{ queries: hashes.length, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
|
|
1254
|
+
'Remote reconnected, refetching active queries'
|
|
1255
|
+
);
|
|
1256
|
+
for (const hash of hashes) {
|
|
1257
|
+
this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
1258
|
+
}
|
|
1259
|
+
// The WS reconnect leaves the server-side LIVE subscription dead — the
|
|
1260
|
+
// re-enqueued `register` events only re-fetch initial state, they don't
|
|
1261
|
+
// re-subscribe. Without this, LIVE never recovers after a reconnect and
|
|
1262
|
+
// the poll silently becomes the sole sync path (and never backs off).
|
|
1263
|
+
// Authenticated → per-user table; signed-out with anon live enabled →
|
|
1264
|
+
// the shared `_00_list_ref_anon`. Otherwise there's no table to re-bind.
|
|
1265
|
+
if (this.currentUserId || this.anonLiveEnabled) {
|
|
1266
|
+
this.restartRefLiveQuery().catch((err) => {
|
|
1267
|
+
this.logger.debug(
|
|
1268
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::onReconnect' },
|
|
1269
|
+
'LIVE restart after reconnect failed; relying on poll fallback'
|
|
1270
|
+
);
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1203
1273
|
}
|
|
1204
1274
|
|
|
1205
1275
|
private async startRefLiveQueries() {
|