@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { LoroDoc } from 'loro-crdt';
|
|
3
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
4
|
+
import { CrdtManager } from './index';
|
|
5
|
+
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
6
|
+
import type { Logger } from '../../services/logger/index';
|
|
7
|
+
|
|
8
|
+
// Regression test for the offline-reload formatting-loss class of bug.
|
|
9
|
+
//
|
|
10
|
+
// Repro pattern: app uses `store: 'memory'` for the local SurrealDB (or
|
|
11
|
+
// any scenario where the local row exists without a CRDT snapshot —
|
|
12
|
+
// fresh device, post-sync-down with stale meta, …). On reload, the
|
|
13
|
+
// editor mounts but stays empty until some unrelated edit fires the
|
|
14
|
+
// parent LIVE feed. From the user's POV, formatting is gone.
|
|
15
|
+
//
|
|
16
|
+
// Fix: when `CrdtManager.open` finds no local snapshot, it must fetch
|
|
17
|
+
// the parent row from remote (the snapshot lives inline on the row in
|
|
18
|
+
// the consolidated design) and dispatch its CRDT field. This test pins
|
|
19
|
+
// that behavior across both shapes — `@crdt`-only fields where the
|
|
20
|
+
// snapshot IS the field value, and `@crdt @cursor` fields where the
|
|
21
|
+
// snapshot lives at `<field>.state`.
|
|
22
|
+
|
|
23
|
+
function silentLogger(): Logger {
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
const fake: any = {};
|
|
26
|
+
fake.info = noop;
|
|
27
|
+
fake.warn = noop;
|
|
28
|
+
fake.debug = noop;
|
|
29
|
+
fake.error = noop;
|
|
30
|
+
fake.trace = noop;
|
|
31
|
+
fake.child = () => fake;
|
|
32
|
+
return fake as Logger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function singleTableSchema(opts: { cursor?: boolean } = {}): SchemaStructure {
|
|
36
|
+
return {
|
|
37
|
+
tables: [
|
|
38
|
+
{
|
|
39
|
+
name: 'thread',
|
|
40
|
+
columns: {
|
|
41
|
+
body: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
optional: false,
|
|
44
|
+
crdt: 'text',
|
|
45
|
+
...(opts.cursor ? { cursor: true } : {}),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
primaryKey: ['id'],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
relationships: [],
|
|
52
|
+
backends: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSnapshot(text: string): Uint8Array {
|
|
57
|
+
const doc = new LoroDoc();
|
|
58
|
+
doc.getText('text').insert(0, text);
|
|
59
|
+
return doc.export({ mode: 'snapshot' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeRemote(handlers: {
|
|
63
|
+
selectRow?: () => unknown;
|
|
64
|
+
}): RemoteDatabaseService {
|
|
65
|
+
const remote = {
|
|
66
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
67
|
+
if (sql.includes('LIVE SELECT')) return ['live-uuid'];
|
|
68
|
+
if (sql.includes('SELECT * FROM ONLY')) {
|
|
69
|
+
return [handlers.selectRow ? handlers.selectRow() : null];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}),
|
|
73
|
+
getClient: () =>
|
|
74
|
+
({
|
|
75
|
+
liveOf: async () => ({
|
|
76
|
+
subscribe: () => () => {},
|
|
77
|
+
}),
|
|
78
|
+
}) as any,
|
|
79
|
+
// CrdtManager watches transport events so it can re-issue table LIVEs after
|
|
80
|
+
// a reconnect. These tests never drop the socket, so a no-op is enough.
|
|
81
|
+
getStatus: () => 'connected' as const,
|
|
82
|
+
subscribeConnection: vi.fn().mockReturnValue(() => {}),
|
|
83
|
+
} satisfies Partial<RemoteDatabaseService> as unknown as RemoteDatabaseService;
|
|
84
|
+
return remote;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('CrdtManager.open hydration', () => {
|
|
88
|
+
it('@crdt-only: fetches the parent row from remote when local is empty', async () => {
|
|
89
|
+
const remoteSnapshot = buildSnapshot('hello world');
|
|
90
|
+
|
|
91
|
+
const local = {
|
|
92
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
93
|
+
if (sql.includes('SELECT VALUE body FROM ONLY')) return [null];
|
|
94
|
+
return [];
|
|
95
|
+
}),
|
|
96
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
97
|
+
|
|
98
|
+
const remote = makeRemote({
|
|
99
|
+
selectRow: () => ({ id: 'thread:abc', body: remoteSnapshot }),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const manager = new CrdtManager(singleTableSchema(), local, remote, silentLogger());
|
|
103
|
+
manager.setSessionId('session-under-test');
|
|
104
|
+
|
|
105
|
+
const field = await manager.open('thread', 'thread:abc', 'body');
|
|
106
|
+
|
|
107
|
+
await vi.waitFor(() => {
|
|
108
|
+
expect(field.getDoc().getText('text').toString()).toBe('hello world');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const sqls = (remote.query as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
112
|
+
(c) => c[0] as string
|
|
113
|
+
);
|
|
114
|
+
expect(sqls.some((s) => s.includes('SELECT * FROM ONLY'))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('@crdt @cursor: extracts the snapshot from the object shape', async () => {
|
|
118
|
+
const remoteSnapshot = buildSnapshot('object shape');
|
|
119
|
+
|
|
120
|
+
const local = {
|
|
121
|
+
query: vi.fn().mockImplementation(async () => [null]),
|
|
122
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
123
|
+
|
|
124
|
+
// Cursor-enabled field stores `{ state, cursors }`. The remote row
|
|
125
|
+
// returns this shape; the manager must drill into `.state` for the
|
|
126
|
+
// snapshot bytes.
|
|
127
|
+
const remote = makeRemote({
|
|
128
|
+
selectRow: () => ({
|
|
129
|
+
id: 'thread:abc',
|
|
130
|
+
body: { state: remoteSnapshot, cursors: {} },
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const manager = new CrdtManager(
|
|
135
|
+
singleTableSchema({ cursor: true }),
|
|
136
|
+
local,
|
|
137
|
+
remote,
|
|
138
|
+
silentLogger()
|
|
139
|
+
);
|
|
140
|
+
manager.setSessionId('session-under-test');
|
|
141
|
+
|
|
142
|
+
const field = await manager.open('thread', 'thread:abc', 'body');
|
|
143
|
+
|
|
144
|
+
await vi.waitFor(() => {
|
|
145
|
+
expect(field.getDoc().getText('text').toString()).toBe('object shape');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('skips the remote fetch when a local snapshot already exists', async () => {
|
|
150
|
+
const localSnapshot = buildSnapshot('cached');
|
|
151
|
+
|
|
152
|
+
const local = {
|
|
153
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
154
|
+
if (sql.includes('SELECT VALUE body FROM ONLY')) return [localSnapshot];
|
|
155
|
+
return [];
|
|
156
|
+
}),
|
|
157
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
158
|
+
|
|
159
|
+
const remote = makeRemote({});
|
|
160
|
+
|
|
161
|
+
const manager = new CrdtManager(singleTableSchema(), local, remote, silentLogger());
|
|
162
|
+
manager.setSessionId('session-under-test');
|
|
163
|
+
|
|
164
|
+
const field = await manager.open('thread', 'thread:def', 'body');
|
|
165
|
+
|
|
166
|
+
expect(field.getDoc().getText('text').toString()).toBe('cached');
|
|
167
|
+
|
|
168
|
+
// Give any stray async work a tick to land before asserting absence.
|
|
169
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
170
|
+
|
|
171
|
+
const sqls = (remote.query as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
172
|
+
(c) => c[0] as string
|
|
173
|
+
);
|
|
174
|
+
expect(sqls.some((s) => s.includes('SELECT * FROM ONLY'))).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Regression: SurrealDB returns bytes nested inside a FLEXIBLE object as a
|
|
178
|
+
// plain `number[]` from the local WASM engine (e.g. `state: [108, 111, 114,
|
|
179
|
+
// 111, ...]` — the literal LoroDoc magic header bytes). The cursor-enabled
|
|
180
|
+
// extractSnapshot path used to only accept Uint8Array and would silently
|
|
181
|
+
// return undefined for the array form, leaving every editor empty and
|
|
182
|
+
// killing realtime updates for `@crdt @cursor` fields.
|
|
183
|
+
it('@crdt @cursor: accepts number[] as the state shape (local WASM transport)', async () => {
|
|
184
|
+
const remoteSnapshot = buildSnapshot('from local wasm');
|
|
185
|
+
const remoteAsArray = Array.from(remoteSnapshot) as unknown as number[];
|
|
186
|
+
|
|
187
|
+
const local = {
|
|
188
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
189
|
+
if (sql.includes('SELECT VALUE body FROM ONLY')) {
|
|
190
|
+
return [{ state: remoteAsArray, cursors: {} }];
|
|
191
|
+
}
|
|
192
|
+
return [];
|
|
193
|
+
}),
|
|
194
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
195
|
+
|
|
196
|
+
const remote = makeRemote({});
|
|
197
|
+
|
|
198
|
+
const manager = new CrdtManager(
|
|
199
|
+
singleTableSchema({ cursor: true }),
|
|
200
|
+
local,
|
|
201
|
+
remote,
|
|
202
|
+
silentLogger()
|
|
203
|
+
);
|
|
204
|
+
manager.setSessionId('session-under-test');
|
|
205
|
+
|
|
206
|
+
const field = await manager.open('thread', 'thread:wasm', 'body');
|
|
207
|
+
|
|
208
|
+
expect(field.getDoc().getText('text').toString()).toBe('from local wasm');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import { CrdtManager } from './index';
|
|
4
|
+
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
5
|
+
import type { Logger } from '../../services/logger/index';
|
|
6
|
+
|
|
7
|
+
// A LIVE subscription lives and dies with its WebSocket session, and
|
|
8
|
+
// `ensureTableSubscription` is memoized on `liveByTable`. Without a reconnect
|
|
9
|
+
// hook, the first dropped socket kills CRDT realtime for good: the map still
|
|
10
|
+
// holds a uuid for a subscription the server has forgotten, so every later
|
|
11
|
+
// `open()` short-circuits and no LIVE is ever re-issued.
|
|
12
|
+
|
|
13
|
+
const noop = () => {};
|
|
14
|
+
|
|
15
|
+
/** A promise plus its resolver, so a test can hold an RPC open on demand. */
|
|
16
|
+
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
17
|
+
let resolve!: (v: T) => void;
|
|
18
|
+
const promise = new Promise<T>((r) => {
|
|
19
|
+
resolve = r;
|
|
20
|
+
});
|
|
21
|
+
return { promise, resolve };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function silentLogger(): Logger {
|
|
25
|
+
const fake: any = {};
|
|
26
|
+
fake.info = noop;
|
|
27
|
+
fake.warn = noop;
|
|
28
|
+
fake.debug = noop;
|
|
29
|
+
fake.error = noop;
|
|
30
|
+
fake.trace = noop;
|
|
31
|
+
fake.child = () => fake;
|
|
32
|
+
return fake as Logger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function singleTableSchema(): SchemaStructure {
|
|
36
|
+
return {
|
|
37
|
+
tables: [
|
|
38
|
+
{
|
|
39
|
+
name: 'thread',
|
|
40
|
+
columns: { body: { type: 'string', optional: false, crdt: 'text' } },
|
|
41
|
+
primaryKey: ['id'],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
relationships: [],
|
|
45
|
+
backends: {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeRemote() {
|
|
50
|
+
const handlers = new Map<string, Array<(...a: any[]) => void>>();
|
|
51
|
+
const remote = {
|
|
52
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
53
|
+
if (sql.includes('LIVE SELECT')) return ['live-uuid'];
|
|
54
|
+
if (sql.includes('SELECT * FROM ONLY')) return [null];
|
|
55
|
+
return [];
|
|
56
|
+
}),
|
|
57
|
+
getClient: () =>
|
|
58
|
+
({ liveOf: async () => ({ subscribe: () => () => {} }) }) as any,
|
|
59
|
+
getStatus: () => 'connected' as const,
|
|
60
|
+
subscribeConnection: (event: string, cb: (...a: any[]) => void) => {
|
|
61
|
+
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
|
62
|
+
return () => {
|
|
63
|
+
handlers.set(event, (handlers.get(event) ?? []).filter((h) => h !== cb));
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
} satisfies Partial<RemoteDatabaseService> as unknown as RemoteDatabaseService;
|
|
67
|
+
const emit = (event: string) => {
|
|
68
|
+
for (const cb of Array.from(handlers.get(event) ?? [])) cb();
|
|
69
|
+
};
|
|
70
|
+
return { remote, emit };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeLocal(): LocalStore {
|
|
74
|
+
return {
|
|
75
|
+
query: vi.fn().mockResolvedValue([null]),
|
|
76
|
+
} satisfies Partial<LocalStore> as unknown as LocalStore;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const liveSelects = (remote: RemoteDatabaseService) =>
|
|
80
|
+
(remote.query as ReturnType<typeof vi.fn>).mock.calls
|
|
81
|
+
.map((c) => c[0] as string)
|
|
82
|
+
.filter((s) => s.includes('LIVE SELECT'));
|
|
83
|
+
|
|
84
|
+
describe('CrdtManager reconnect', () => {
|
|
85
|
+
it('re-issues the table LIVE after a recovered drop', async () => {
|
|
86
|
+
const { remote, emit } = makeRemote();
|
|
87
|
+
const manager = new CrdtManager(singleTableSchema(), makeLocal(), remote, silentLogger());
|
|
88
|
+
manager.setSessionId('s1');
|
|
89
|
+
|
|
90
|
+
await manager.open('thread', 'thread:abc', 'body');
|
|
91
|
+
await vi.waitFor(() => expect(liveSelects(remote)).toHaveLength(1));
|
|
92
|
+
|
|
93
|
+
// The SDK's own reconnect path: `reconnecting` then `connected`, never
|
|
94
|
+
// `disconnected`.
|
|
95
|
+
emit('reconnecting');
|
|
96
|
+
emit('connected');
|
|
97
|
+
|
|
98
|
+
await vi.waitFor(() => expect(liveSelects(remote)).toHaveLength(2));
|
|
99
|
+
// No KILL: that uuid belonged to a session that no longer exists.
|
|
100
|
+
const sqls = (remote.query as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
|
|
101
|
+
expect(sqls.some((s) => s.includes('KILL'))).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('re-issues the table LIVE after the SDK gives up', async () => {
|
|
105
|
+
const { remote, emit } = makeRemote();
|
|
106
|
+
const manager = new CrdtManager(singleTableSchema(), makeLocal(), remote, silentLogger());
|
|
107
|
+
manager.setSessionId('s1');
|
|
108
|
+
|
|
109
|
+
await manager.open('thread', 'thread:abc', 'body');
|
|
110
|
+
await vi.waitFor(() => expect(liveSelects(remote)).toHaveLength(1));
|
|
111
|
+
|
|
112
|
+
emit('disconnected');
|
|
113
|
+
emit('connected');
|
|
114
|
+
|
|
115
|
+
await vi.waitFor(() => expect(liveSelects(remote)).toHaveLength(2));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not resurrect a LIVE for a table whose fields were all closed', async () => {
|
|
119
|
+
const { remote, emit } = makeRemote();
|
|
120
|
+
const manager = new CrdtManager(singleTableSchema(), makeLocal(), remote, silentLogger());
|
|
121
|
+
manager.setSessionId('s1');
|
|
122
|
+
|
|
123
|
+
await manager.open('thread', 'thread:abc', 'body');
|
|
124
|
+
await vi.waitFor(() => expect(liveSelects(remote)).toHaveLength(1));
|
|
125
|
+
|
|
126
|
+
// A drop can outlive the editor that needed the feed.
|
|
127
|
+
emit('reconnecting');
|
|
128
|
+
manager.close('thread', 'thread:abc', 'body');
|
|
129
|
+
emit('connected');
|
|
130
|
+
|
|
131
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
132
|
+
expect(liveSelects(remote)).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('discards and re-registers a LIVE that was mid-flight when the socket dropped', async () => {
|
|
136
|
+
const handlers = new Map<string, Array<() => void>>();
|
|
137
|
+
const firstLive = deferred<void>();
|
|
138
|
+
let liveCalls = 0;
|
|
139
|
+
const remote = {
|
|
140
|
+
query: vi.fn().mockImplementation(async (sql: string) => {
|
|
141
|
+
if (sql.includes('LIVE SELECT')) {
|
|
142
|
+
liveCalls++;
|
|
143
|
+
// Hold the FIRST registration open so the drop lands mid-flight.
|
|
144
|
+
if (liveCalls === 1) {
|
|
145
|
+
await firstLive.promise;
|
|
146
|
+
return ['dead-session-uuid'];
|
|
147
|
+
}
|
|
148
|
+
return ['fresh-uuid'];
|
|
149
|
+
}
|
|
150
|
+
return [null];
|
|
151
|
+
}),
|
|
152
|
+
getClient: () => ({ liveOf: async () => ({ subscribe: () => () => {} }) }) as any,
|
|
153
|
+
getStatus: () => 'connected' as const,
|
|
154
|
+
subscribeConnection: (event: string, cb: () => void) => {
|
|
155
|
+
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
|
156
|
+
return () => {};
|
|
157
|
+
},
|
|
158
|
+
} satisfies Partial<RemoteDatabaseService> as unknown as RemoteDatabaseService;
|
|
159
|
+
const emit = (event: string) => {
|
|
160
|
+
for (const cb of Array.from(handlers.get(event) ?? [])) cb();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const manager = new CrdtManager(singleTableSchema(), makeLocal(), remote, silentLogger());
|
|
164
|
+
manager.setSessionId('s1');
|
|
165
|
+
await manager.open('thread', 'thread:abc', 'body');
|
|
166
|
+
await vi.waitFor(() => expect(liveCalls).toBe(1));
|
|
167
|
+
|
|
168
|
+
// Drop + recover while the first registration is still awaiting its uuid.
|
|
169
|
+
emit('reconnecting');
|
|
170
|
+
emit('connected');
|
|
171
|
+
firstLive.resolve();
|
|
172
|
+
|
|
173
|
+
// The doomed uuid must never be recorded — if it were, `liveByTable` would
|
|
174
|
+
// short-circuit every future restart and CRDT realtime would be dead for
|
|
175
|
+
// good. A fresh registration must take its place.
|
|
176
|
+
await vi.waitFor(() => expect(liveCalls).toBe(2));
|
|
177
|
+
expect((manager as any).liveByTable.get('thread')).toBe('fresh-uuid');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('stops reacting to transport events after dispose', async () => {
|
|
181
|
+
const { remote, emit } = makeRemote();
|
|
182
|
+
const manager = new CrdtManager(singleTableSchema(), makeLocal(), remote, silentLogger());
|
|
183
|
+
manager.setSessionId('s1');
|
|
184
|
+
|
|
185
|
+
await manager.open('thread', 'thread:abc', 'body');
|
|
186
|
+
await vi.waitFor(() => expect(liveSelects(remote)).toHaveLength(1));
|
|
187
|
+
|
|
188
|
+
manager.dispose();
|
|
189
|
+
emit('reconnecting');
|
|
190
|
+
emit('connected');
|
|
191
|
+
|
|
192
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
193
|
+
expect(liveSelects(remote)).toHaveLength(1);
|
|
194
|
+
});
|
|
195
|
+
});
|