@spooky-sync/core 0.0.1-canary.323 → 0.0.1-canary.324

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.
@@ -0,0 +1,37 @@
1
+ import type { Container, LoroDoc, LoroList, LoroMap } from 'loro-crdt';
2
+
3
+ /**
4
+ * The plain text a `@crdt` field shows as its column value, rendered the same
5
+ * way the SSP fold renders it (`packages/ssp-node/src/crdt/mirror.rs`).
6
+ */
7
+
8
+ export type CrdtKind = 'text' | 'prosemirror';
9
+
10
+ const PM_ROOT = 'doc';
11
+ const PM_NODE_NAME = 'nodeName';
12
+ const PM_CHILDREN = 'children';
13
+ const PM_LINE_BREAKS = new Set(['hard_break', 'hardBreak']);
14
+
15
+ export function renderText(doc: LoroDoc, field: string, kind: CrdtKind): string {
16
+ return kind === 'text' ? doc.getText(field).toString() : nodeText(doc.getMap(PM_ROOT));
17
+ }
18
+
19
+ /** A node holding text runs is inline content (its children concatenate); a
20
+ * node holding only nodes is a block container (its children are lines). */
21
+ function nodeText(node: LoroMap): string {
22
+ const name = node.get(PM_NODE_NAME);
23
+ if (typeof name === 'string' && PM_LINE_BREAKS.has(name)) return '\n';
24
+ const children = node.get(PM_CHILDREN) as Container | undefined;
25
+ if (!children || children.kind() !== 'List') return '';
26
+ const list = children as LoroList;
27
+ const parts: Array<{ text: boolean; value: string }> = [];
28
+ for (let i = 0; i < list.length; i++) {
29
+ const child = list.get(i) as Container | unknown;
30
+ if (!child || typeof child !== 'object' || !('kind' in child)) continue;
31
+ const c = child as Container;
32
+ if (c.kind() === 'Text') parts.push({ text: true, value: c.toString() });
33
+ else if (c.kind() === 'Map') parts.push({ text: false, value: nodeText(c as LoroMap) });
34
+ }
35
+ const inline = parts.some((p) => p.text);
36
+ return parts.map((p) => p.value).join(inline ? '' : '\n');
37
+ }
@@ -80,6 +80,22 @@ describe('drain', () => {
80
80
  expect(out.dispatched.at(-1)).toEqual({ type: 'SyncOutcome', ok: true, error: undefined });
81
81
  });
82
82
 
83
+ it('a CRDT edit-log CREATE that finds its row already there is acked, any other duplicate rolls back', async () => {
84
+ const logEnv = defaultEnv({ tables: [{ name: 'thing', columns: {} }, { name: 'doc__crdt_log', columns: {} }] } as any, { outboxBatchSize: 2 });
85
+ const out = await runPure(drain(logEnv), {
86
+ state: stateWith([1, 2]),
87
+ handlers: {
88
+ 'local.query': (e: any) =>
89
+ e.sql === 'SELECT * FROM $ids'
90
+ ? [[pendingRow(1, 'create', { tableName: 'doc__crdt_log', recordId: 'doc__crdt_log:1' }), pendingRow(2)]]
91
+ : [],
92
+ 'remote.query': () => [err('Database record `doc__crdt_log:1` already exists'), err('Database record `thing:2` already exists')],
93
+ },
94
+ });
95
+ expect(out.state.outbox.find((i) => i.id === '_00_pending_mutations:m1')?.status).toBe('acked');
96
+ expect(out.state.outbox.some((i) => i.id === '_00_pending_mutations:m2')).toBe(false);
97
+ });
98
+
83
99
  it('transport failures keep the tail queued behind a backoff timer (thrown, or a network ERR mid-batch, or a short result list)', async () => {
84
100
  const thrown = await runPure(drain(env), {
85
101
  state: stateWith([1, 2]),
@@ -67,6 +67,14 @@ export function* refreshFailedCount(): Saga<void> {
67
67
 
68
68
  const ERR = (r: StatementResult | undefined): string | null => (r && r.status === 'ERR' ? r.error : null);
69
69
 
70
+ /**
71
+ * A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
72
+ * so a CREATE retried after its first attempt committed (and only the answer
73
+ * was lost) finds it already there: that is a success, not a rejection.
74
+ */
75
+ const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, err: string): boolean =>
76
+ row.mutationType === 'create' && row.tableName.endsWith('__crdt_log') && /already exists/i.test(err);
77
+
70
78
  /**
71
79
  * Drain the outbox: one serial lane, FIFO, up to `outboxBatchSize` statements
72
80
  * per request, per-statement outcome. Accepted rows are acked (they stay in
@@ -127,7 +135,7 @@ export function* drain(env: SagaEnv): Saga<void> {
127
135
  break;
128
136
  }
129
137
  const err = ERR(r);
130
- if (err === null) {
138
+ if (err === null || isRepeatedLogCreate(row, err)) {
131
139
  const del = deletePendingRow(row.id);
132
140
  try {
133
141
  yield fx.local.query(del.sql, del.vars);
@@ -449,6 +449,37 @@ describe('anonymous _00_query isolation (real SurrealDB)', () => {
449
449
  expect(await heartbeatFound(g, qg)).toBe(true);
450
450
  });
451
451
 
452
+ // Without `anonymousLiveQueries` the SSP stores a signed-out registration
453
+ // with no identity at all (`auth_id = ''`). The rule admitted only `'anon'`
454
+ // rows to their subscribers, so such a page could not read its own row back
455
+ // and its preload never settled.
456
+ it("a signed-out view stored with no identity (anonymous live queries off) answers to its own session, and to nobody else", async (ctx) => {
457
+ if (!reachable) ctx.skip();
458
+ const [a, sidA] = await guest();
459
+ const [b] = await guest();
460
+ const [u] = await member('alice');
461
+ const qa = viewId();
462
+ await register(qa, sidA, '');
463
+ expect((await row(qa))?.auth_id).toBe('');
464
+
465
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
466
+ expect(await heartbeatFound(a, qa)).toBe(true);
467
+
468
+ const before = await row(qa);
469
+ for (const other of [b, u]) {
470
+ expect(await readBack(other, qa)).toBeNull();
471
+ expect(await heartbeatFound(other, qa)).toBe(false);
472
+ expect(await other.query('SELECT id FROM _00_query')).toEqual([[]]);
473
+ await other.query('DELETE $id', { id: qa });
474
+ }
475
+ expect(await row(qa)).toEqual(before);
476
+
477
+ expect(await a.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
478
+ { released: true, remaining: 0 },
479
+ ]);
480
+ expect(await row(qa)).toBeUndefined();
481
+ });
482
+
452
483
  it('the owning guest can still release its view', async (ctx) => {
453
484
  if (!reachable) ctx.skip();
454
485
  const [a, sidA] = await guest();
package/src/query/env.ts CHANGED
@@ -49,6 +49,15 @@ export function listRefTable(env: SagaEnv, s: ClientState): string {
49
49
  return listRefTableFor(env.refMode, userId);
50
50
  }
51
51
 
52
+ /**
53
+ * Whether this session can be sent a view's membership at all. A signed-out
54
+ * session reads edges only from `_00_list_ref_anon`, and only with
55
+ * `enableAnonymousLiveQueries`: without it the SSP files its views under no
56
+ * identity in `_00_list_ref`, whose rule shows those edges to no caller. Such
57
+ * a view can register and report its row count, but never its rows.
58
+ */
59
+ export const receivesMembership = (env: SagaEnv, s: ClientState): boolean => s.userId !== null || env.anonLive;
60
+
52
61
  export function columnsFor(env: SagaEnv, table: string): Record<string, unknown> | null {
53
62
  const found = env.schema.tables.find((t) => t.name === table);
54
63
  if (found) return found.columns as Record<string, unknown>;
@@ -17,10 +17,11 @@ export const sha1 = async (str: string): Promise<string> => {
17
17
 
18
18
  /**
19
19
  * Local-only state a schema change must not throw away: the outbox, the
20
- * failed-mutation tray and debounced patches not yet in the outbox. Cached
21
- * rows refill from the server; these exist nowhere else.
20
+ * failed-mutation tray, debounced patches not yet in the outbox and CRDT
21
+ * documents with edits not yet sent. Cached rows refill from the server;
22
+ * these exist nowhere else.
22
23
  */
23
- const KEPT_TABLES = ['_00_pending_mutations', '_00_failed_mutations', '_00_pending_writes'] as const;
24
+ const KEPT_TABLES = ['_00_pending_mutations', '_00_failed_mutations', '_00_pending_writes', '_00_crdt_local'] as const;
24
25
 
25
26
  export class LocalMigrator {
26
27
  private logger: Logger;
@@ -59,6 +60,7 @@ export class LocalMigrator {
59
60
  DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
60
61
  DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
61
62
  DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
63
+ DEFINE TABLE IF NOT EXISTS _00_crdt_local SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
62
64
  `;
63
65
  const fullSchema = schemaSurql + '\n' + systemSchema;
64
66
 
@@ -73,6 +73,8 @@ const SYSTEM_TABLES = [
73
73
  // asks for the ids it found in OPFS), so it has to exist up front like the
74
74
  // rest — otherwise the very first reconcile throws and the cache runs cold.
75
75
  '_00_blob',
76
+ // CRDT documents per open field, with edits not yet in the outbox.
77
+ '_00_crdt_local',
76
78
  // Server-written, synced-down meta tables (see meta_tables_client.surql).
77
79
  // DEFINE is a noop on this engine, so without seeding them here their synced
78
80
  // rows have no local table to land in: feature flags silently fall back to
@@ -37,7 +37,7 @@ export function fakeServiceBundle<S extends SchemaStructure>(over: Partial<Servi
37
37
  persistence: { set: async () => undefined, get: async () => null, remove: async () => undefined },
38
38
  streamProcessor: { addReceiver: (r: unknown) => void receivers.push(r), checkpoint: async () => undefined, dispose: noop } as any,
39
39
  migrator: {} as any,
40
- crdt: { closeAll: noop, dispose: noop, open: async () => 'field', close: noop } as any,
40
+ crdt: { attach: noop, setSessionId: noop, closeAll: noop, dispose: noop, open: async () => 'field', close: async () => {} } as any,
41
41
  auth: { subscribe: (cb: (u: string | null) => void) => (authListeners.push(cb), noop), subscribeImpersonation: () => noop, onBeforeSignOut: () => noop, currentUser: null, isAuthenticated: false, token: null, impersonation: null, eventSystem: { subscribe: noop } } as any,
42
42
  tabs: null,
43
43
  tabsUnsupportedReason: 'test',
package/src/types.ts CHANGED
@@ -259,11 +259,11 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
259
259
  streamDebounceTime?: number;
260
260
  /**
261
261
  * Debounce time in milliseconds for syncing collaborative (CRDT) field
262
- * changes to the remote database. Local writes happen immediately on
263
- * every keystroke (so reload/offline works), but the remote UPSERT is
264
- * coalesced over this window. Lower = snappier remote propagation +
265
- * more network traffic; higher = less traffic + more lag for other
266
- * collaborators. Defaults to 500ms.
262
+ * edits to the server. Local state is kept on every change (so reload and
263
+ * offline work), but a field's own new ops go out as one edit-log row per
264
+ * pause of this length (at most 2 s apart while typing continues).
265
+ * Lower = snappier propagation and more rows; higher = fewer rows and more
266
+ * lag for other collaborators. Defaults to 300ms.
267
267
  */
268
268
  crdtDebounceMs?: number;
269
269
  /**
@@ -321,7 +321,8 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
321
321
  * page gets live `useQuery` updates over world-readable tables. Requires the
322
322
  * server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
323
323
  * (this flag must match it). Defaults to `false`: anonymous clients can read
324
- * one-shot but never sync live.
324
+ * one-shot but never sync live, and a `preload` made while signed out returns
325
+ * at once rather than wait for membership that never comes.
325
326
  */
326
327
  enableAnonymousLiveQueries?: boolean;
327
328
  /**
@@ -120,10 +120,9 @@ export const surql: SurqlHelper = {
120
120
  return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
121
121
  },
122
122
 
123
- // Use this for sync-down ingestion: the remote payload omits local-only
124
- // fields injected by the CLI's local-schema setup (`_00_crdt`,
125
- // `_00_cursor`), and REPLACE would wipe them on every round-trip and
126
- // break offline reload of CRDT state.
123
+ // Use this for sync-down ingestion: the remote payload can omit local-only
124
+ // fields injected by the CLI's local-schema setup, and REPLACE would wipe
125
+ // them on every round-trip.
127
126
  upsertMerge(idVar: string, dataVar: string) {
128
127
  return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
129
128
  },
@@ -1,210 +0,0 @@
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
- });
@@ -1,195 +0,0 @@
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
- });