@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.
- package/AGENTS.md +4 -3
- package/dist/index.d.ts +152 -161
- package/dist/index.js +796 -482
- package/dist/types.d.ts +7 -6
- package/package.json +4 -4
- package/src/boot/preload.saga.test.ts +29 -0
- package/src/boot/preload.saga.ts +11 -2
- package/src/client/services.ts +1 -1
- package/src/client/sp00ky-client.test.ts +4 -1
- package/src/client/sp00ky-client.ts +37 -4
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +48 -255
- package/src/modules/crdt/crdt.test.ts +365 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +610 -363
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/mutation/push.saga.test.ts +16 -0
- package/src/mutation/push.saga.ts +9 -1
- package/src/query/anon-query-isolation.integration.test.ts +31 -0
- package/src/query/env.ts +9 -0
- package/src/services/database/local-migrator.ts +5 -3
- package/src/services/database/sqlite-cache-engine.ts +2 -0
- package/src/testing/fake-services.ts +1 -1
- package/src/types.ts +7 -6
- package/src/utils/surql.ts +3 -4
- package/src/modules/crdt/crdt-hydration.test.ts +0 -210
- package/src/modules/crdt/crdt-reconnect.test.ts +0 -195
|
@@ -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
|
|
21
|
-
* rows refill from the server;
|
|
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:
|
|
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
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* more
|
|
266
|
-
* collaborators. Defaults to
|
|
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
|
/**
|
package/src/utils/surql.ts
CHANGED
|
@@ -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
|
|
124
|
-
// fields injected by the CLI's local-schema setup
|
|
125
|
-
//
|
|
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
|
-
});
|