@spooky-sync/core 0.0.1-canary.192 → 0.0.1-canary.193
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 +7 -0
- package/dist/index.js +19 -10
- package/package.json +3 -3
- package/src/modules/sync/sync.cleanup.test.ts +25 -16
- package/src/modules/sync/sync.ts +42 -10
package/dist/index.d.ts
CHANGED
|
@@ -1180,6 +1180,13 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1180
1180
|
private startSelfHeal;
|
|
1181
1181
|
private scheduleSelfHeal;
|
|
1182
1182
|
private stopSelfHeal;
|
|
1183
|
+
/**
|
|
1184
|
+
* Release a deregistered query's remote view immediately instead of leaving
|
|
1185
|
+
* it to the TTL sweep. Off by default; see the reasoning in
|
|
1186
|
+
* {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
|
|
1187
|
+
* can be re-enabled in a test once the subquery-body repair path exists.
|
|
1188
|
+
*/
|
|
1189
|
+
private readonly releaseQueriesEagerly;
|
|
1183
1190
|
constructor(local: LocalStore, remote: RemoteDatabaseService, cache: CacheModule, dataModule: DataModule<S>, schema: S, logger: Logger$1, options?: Sp00kySyncOptions);
|
|
1184
1191
|
/**
|
|
1185
1192
|
* Initializes the synchronization system.
|
package/dist/index.js
CHANGED
|
@@ -5784,6 +5784,13 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5784
5784
|
}
|
|
5785
5785
|
this.selfHealAttempts = 0;
|
|
5786
5786
|
}
|
|
5787
|
+
/**
|
|
5788
|
+
* Release a deregistered query's remote view immediately instead of leaving
|
|
5789
|
+
* it to the TTL sweep. Off by default; see the reasoning in
|
|
5790
|
+
* {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
|
|
5791
|
+
* can be re-enabled in a test once the subquery-body repair path exists.
|
|
5792
|
+
*/
|
|
5793
|
+
releaseQueriesEagerly = false;
|
|
5787
5794
|
constructor(local, remote, cache, dataModule, schema, logger, options) {
|
|
5788
5795
|
this.local = local;
|
|
5789
5796
|
this.remote = remote;
|
|
@@ -6676,13 +6683,15 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6676
6683
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
6677
6684
|
if (!queryState) return;
|
|
6678
6685
|
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
this.
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
+
if (this.releaseQueriesEagerly) {
|
|
6687
|
+
await this.remote.query("fn::query::unsubscribe($id)", { id: queryState.config.id });
|
|
6688
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
6689
|
+
this.enqueueDownEvent({
|
|
6690
|
+
type: "register",
|
|
6691
|
+
payload: { hash: queryHash }
|
|
6692
|
+
});
|
|
6693
|
+
return;
|
|
6694
|
+
}
|
|
6686
6695
|
}
|
|
6687
6696
|
this.dataModule.finalizeDeregister(queryHash);
|
|
6688
6697
|
}
|
|
@@ -7003,8 +7012,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7003
7012
|
|
|
7004
7013
|
//#endregion
|
|
7005
7014
|
//#region src/modules/devtools/index.ts
|
|
7006
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7007
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7015
|
+
const CORE_VERSION = "0.0.1-canary.193";
|
|
7016
|
+
const WASM_VERSION = "0.0.1-canary.193";
|
|
7008
7017
|
const SURREAL_VERSION = "3.0.3";
|
|
7009
7018
|
var DevToolsService = class DevToolsService {
|
|
7010
7019
|
eventsHistory = [];
|
|
@@ -11435,7 +11444,7 @@ var Sp00kyClient = class {
|
|
|
11435
11444
|
return new TabsCoordinator({
|
|
11436
11445
|
tabId,
|
|
11437
11446
|
fingerprint: computeTabsFingerprint({
|
|
11438
|
-
coreVersion: "0.0.1-canary.
|
|
11447
|
+
coreVersion: "0.0.1-canary.193",
|
|
11439
11448
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11440
11449
|
endpoint: this.config.database.endpoint ?? "",
|
|
11441
11450
|
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.193",
|
|
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.193",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.193",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"fast-json-patch": "^3.1.1",
|
|
@@ -47,15 +47,26 @@ function makeSync(opts: { hasSubscribers?: boolean[] } = {}) {
|
|
|
47
47
|
describe('cleanupQuery — releasing a possibly-shared view', () => {
|
|
48
48
|
beforeEach(() => vi.clearAllMocks());
|
|
49
49
|
|
|
50
|
-
it('
|
|
51
|
-
|
|
50
|
+
it('does not touch the remote view at all; the TTL sweep reclaims it', async () => {
|
|
51
|
+
// Eager release is deliberately disabled. It was inert for months (the
|
|
52
|
+
// table granted no delete permission), and making it real turned every
|
|
53
|
+
// best-effort guard misfire into a live delete of the row and all its
|
|
54
|
+
// edges. TTL remains the only reclamation that has actually run.
|
|
55
|
+
const { remote, run } = makeSync();
|
|
56
|
+
|
|
57
|
+
await run('h1');
|
|
58
|
+
|
|
59
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never issues a bare DELETE', async () => {
|
|
63
|
+
// Belt and braces: if the eager path is ever re-enabled, it must go through
|
|
64
|
+
// the refcounted `fn::query::unsubscribe`, never a raw DELETE, which would
|
|
65
|
+
// tear the view out from under other sessions sharing the row.
|
|
66
|
+
const { remote, run } = makeSync();
|
|
52
67
|
|
|
53
68
|
await run('h1');
|
|
54
69
|
|
|
55
|
-
expect(remote.query).toHaveBeenCalledWith('fn::query::unsubscribe($id)', {
|
|
56
|
-
id: queryId,
|
|
57
|
-
});
|
|
58
|
-
// The regression that would blank other tabs' lists.
|
|
59
70
|
const sql = remote.query.mock.calls.map((c: any[]) => c[0]).join('\n');
|
|
60
71
|
expect(sql).not.toMatch(/\bDELETE\b/i);
|
|
61
72
|
});
|
|
@@ -80,21 +91,19 @@ describe('cleanupQuery — releasing a possibly-shared view', () => {
|
|
|
80
91
|
expect(finalizeDeregister).not.toHaveBeenCalled();
|
|
81
92
|
});
|
|
82
93
|
|
|
83
|
-
it('
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
94
|
+
it('still frees local state when a subscriber reappears mid-cleanup', async () => {
|
|
95
|
+
// With no remote round trip there is no window to lose a re-subscribe in,
|
|
96
|
+
// so this collapses to the ordinary local free. The remote view survives
|
|
97
|
+
// regardless (TTL owns it), which is precisely why the reappearing
|
|
98
|
+
// subscriber is safe: a re-register finds the row still there.
|
|
99
|
+
const { remote, finalizeDeregister, run } = makeSync({
|
|
88
100
|
hasSubscribers: [false, true],
|
|
89
101
|
});
|
|
90
102
|
|
|
91
103
|
await run('h1');
|
|
92
104
|
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
payload: { hash: 'h1' },
|
|
96
|
-
});
|
|
97
|
-
expect(finalizeDeregister).not.toHaveBeenCalled();
|
|
105
|
+
expect(remote.query).not.toHaveBeenCalled();
|
|
106
|
+
expect(finalizeDeregister).toHaveBeenCalledWith('h1');
|
|
98
107
|
});
|
|
99
108
|
|
|
100
109
|
it('is tolerant of an already torn-down query', async () => {
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -424,6 +424,14 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
424
424
|
this.selfHealAttempts = 0;
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Release a deregistered query's remote view immediately instead of leaving
|
|
429
|
+
* it to the TTL sweep. Off by default; see the reasoning in
|
|
430
|
+
* {@link cleanupQuery}. Kept as a field rather than deleted so the eager path
|
|
431
|
+
* can be re-enabled in a test once the subquery-body repair path exists.
|
|
432
|
+
*/
|
|
433
|
+
private readonly releaseQueriesEagerly = false;
|
|
434
|
+
|
|
427
435
|
constructor(
|
|
428
436
|
private local: LocalStore,
|
|
429
437
|
private remote: RemoteDatabaseService,
|
|
@@ -1771,17 +1779,41 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1771
1779
|
// Re-subscribed before the queued cleanup ran → keep everything as-is.
|
|
1772
1780
|
if (this.dataModule.hasSubscribers(queryHash)) return;
|
|
1773
1781
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1782
|
+
// EAGER REMOTE RELEASE IS DISABLED. Deliberate, and not a leak: the TTL
|
|
1783
|
+
// sweep reclaims the row and its edges on `lastActiveAt + ttl`, which is the
|
|
1784
|
+
// ONLY reclamation that has ever actually run in production.
|
|
1785
|
+
//
|
|
1786
|
+
// Until canary.190 `_00_query` granted no delete permission, so the bare
|
|
1787
|
+
// `DELETE $id` this used to issue affected zero rows. .190 granted delete
|
|
1788
|
+
// and .191 wired `fn::query::unsubscribe`, which made teardown real for the
|
|
1789
|
+
// first time -- and the guards above are best-effort by construction
|
|
1790
|
+
// (`hasSubscribers` can be momentarily false during a rebind or a windowed
|
|
1791
|
+
// list re-flow). Every misfire that had been silently inert for months
|
|
1792
|
+
// became a live delete of the row AND every `_00_list_ref` edge on it.
|
|
1793
|
+
//
|
|
1794
|
+
// That matches a report of chat suddenly rendering raw record ids instead
|
|
1795
|
+
// of users, with the message list re-flowing underneath. Server state was
|
|
1796
|
+
// measured intact at the time (`rowCount` equalled the actual edge count on
|
|
1797
|
+
// every row), so the damage is on the client side of a teardown, not in the
|
|
1798
|
+
// materialization.
|
|
1799
|
+
//
|
|
1800
|
+
// Re-enable only together with a repair path that can re-fetch subquery
|
|
1801
|
+
// child bodies whose `subqueryRemoteArray` entry claims they are already
|
|
1802
|
+
// synced -- otherwise a torn-down-and-recreated view never restores the
|
|
1803
|
+
// related records it dropped, because the idempotence check skips them.
|
|
1804
|
+
if (this.releaseQueriesEagerly) {
|
|
1805
|
+
await this.remote.query('fn::query::unsubscribe($id)', {
|
|
1806
|
+
id: queryState.config.id,
|
|
1807
|
+
});
|
|
1777
1808
|
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1809
|
+
// Re-subscribed while we awaited the release → re-register. Covers both
|
|
1810
|
+
// outcomes: if we were the last subscriber the remote view is gone and
|
|
1811
|
+
// this recreates it, and if it survived for other sessions this re-adds
|
|
1812
|
+
// us to `subscribers` so our heartbeats keep counting.
|
|
1813
|
+
if (this.dataModule.hasSubscribers(queryHash)) {
|
|
1814
|
+
this.enqueueDownEvent({ type: 'register', payload: { hash: queryHash } });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1785
1817
|
}
|
|
1786
1818
|
|
|
1787
1819
|
// No subscribers throughout → safe to free the local view + state.
|