@spooky-sync/core 0.0.1-canary.322 → 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 +156 -161
- package/dist/index.js +910 -482
- package/dist/types.d.ts +17 -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 +3 -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/cache-engine.ts +10 -0
- package/src/services/database/local-migrator.ts +5 -3
- package/src/services/database/sqlite-cache-engine.ts +28 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/stream-processor/index.ts +24 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +36 -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
|
@@ -1,463 +1,710 @@
|
|
|
1
1
|
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { LoroDoc } from 'loro-crdt';
|
|
3
|
+
import { RecordId } from 'surrealdb';
|
|
2
4
|
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
3
5
|
import type { Logger } from '../../services/logger/index';
|
|
4
|
-
import
|
|
6
|
+
import { parseRecordIdString } from '../../utils/index';
|
|
7
|
+
import { check } from './acceptance';
|
|
8
|
+
import { asBytes, randomPeer } from './bytes';
|
|
5
9
|
import { CrdtField } from './crdt-field';
|
|
10
|
+
import { stateHash } from './hash';
|
|
6
11
|
import { loadLoro } from './loro-loader';
|
|
7
|
-
import {
|
|
12
|
+
import type { CrdtKind } from './mirror';
|
|
8
13
|
|
|
9
14
|
export { CrdtField, cursorColorFromName, CURSOR_COLORS } from './crdt-field';
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
17
|
+
* Collaborative (`@crdt`) fields.
|
|
13
18
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* Each open field is a Loro document. The client never sends the document,
|
|
20
|
+
* only its own edits: every batch of local edits becomes one row of the
|
|
21
|
+
* generated `<table>__crdt_log` table, written through the ordinary outbox
|
|
22
|
+
* (so it survives offline, reloads and retries), and every client receives
|
|
23
|
+
* the other rows through an ordinary query and merges them. Nobody writes the
|
|
24
|
+
* canonical state but the SSP, which folds the log into `_00_crdt_doc` and the
|
|
25
|
+
* parent field's plain text (see `packages/ssp-node/src/crdt`).
|
|
19
26
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* collide on the parent's last-write-wins semantics.
|
|
27
|
+
* A row is imported only if it passes the acceptance rule the fold applies
|
|
28
|
+
* (`./acceptance`), so this tab and the server always hold the same ops.
|
|
23
29
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* via subquery. Permission inheritance happens server-side via
|
|
30
|
-
* `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
|
|
30
|
+
* Edits carry this tab's Loro peer id, claimed for the signed-in user in
|
|
31
|
+
* `_00_crdt_peer` before the first row is sent; the database refuses a row
|
|
32
|
+
* whose peer its author does not own. A tab picks a fresh peer per session.
|
|
33
|
+
* Offline, edits stay local (and in the local snapshot) until the claim and
|
|
34
|
+
* the outbox can go through.
|
|
31
35
|
*/
|
|
36
|
+
|
|
37
|
+
/** A followed query: stop following it, and know when the server answered. */
|
|
38
|
+
export interface Watch {
|
|
39
|
+
stop: () => void;
|
|
40
|
+
/** Resolves once the server's membership for the query is known and its
|
|
41
|
+
* rows have landed locally (never rejects; may never resolve offline). */
|
|
42
|
+
settled: Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The runtime pieces the manager needs from the client facade. */
|
|
46
|
+
export interface CrdtPorts {
|
|
47
|
+
/** Register a query on `table` filtered by `where` and follow its rows. */
|
|
48
|
+
watch(table: string, where: Record<string, unknown>, onRows: (rows: Record<string, unknown>[]) => void): Promise<Watch>;
|
|
49
|
+
/** Optimistic create through the outbox. */
|
|
50
|
+
create(id: string, data: Record<string, unknown>): Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Local-only state of one field: the merged document and own unsent edits. */
|
|
54
|
+
interface LocalState {
|
|
55
|
+
snapshot?: Uint8Array;
|
|
56
|
+
/** Server fold this snapshot includes (for the canonical refetch). */
|
|
57
|
+
rev: number;
|
|
58
|
+
/** Every peer this device edited the field under, and how far its ops
|
|
59
|
+
* are already in the outbox. */
|
|
60
|
+
peers: Record<string, { enqueued: number }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Session {
|
|
64
|
+
key: string;
|
|
65
|
+
table: string;
|
|
66
|
+
recordId: string;
|
|
67
|
+
field: string;
|
|
68
|
+
kind: CrdtKind;
|
|
69
|
+
cursor: boolean;
|
|
70
|
+
doc: LoroDoc;
|
|
71
|
+
handle: CrdtField;
|
|
72
|
+
refs: number;
|
|
73
|
+
local: LocalState;
|
|
74
|
+
/** Log rows already looked at (imported, refused or own). */
|
|
75
|
+
seen: Set<string>;
|
|
76
|
+
/** Log rows in the latest delivery: when some vanish, the server folded. */
|
|
77
|
+
present: Set<string>;
|
|
78
|
+
/** Highest published rev this device's document was checked against. */
|
|
79
|
+
verifiedRev: number;
|
|
80
|
+
verifyTimer: ReturnType<typeof setTimeout> | null;
|
|
81
|
+
disposers: Array<() => void>;
|
|
82
|
+
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
83
|
+
firstDirtyAt: number;
|
|
84
|
+
persistTimer: ReturnType<typeof setTimeout> | null;
|
|
85
|
+
flushing: Promise<void> | null;
|
|
86
|
+
refetching: boolean;
|
|
87
|
+
presenceTimer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
presencePending: Uint8Array | null;
|
|
89
|
+
/** This session wrote a presence row (removed again on close). */
|
|
90
|
+
presenceSent: boolean;
|
|
91
|
+
/** The latest import of delivered rows (chained, so awaiting it awaits all). */
|
|
92
|
+
ingesting: Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const LOCAL_TABLE = '_00_crdt_local';
|
|
96
|
+
const LOG_SUFFIX = '__crdt_log';
|
|
97
|
+
const PRESENCE_SUFFIX = '__crdt_presence';
|
|
98
|
+
/** Longest a burst of typing waits before its edits go out. */
|
|
99
|
+
const MAX_WAIT_MS = 2000;
|
|
100
|
+
const PERSIST_DEBOUNCE_MS = 400;
|
|
101
|
+
const PRESENCE_THROTTLE_MS = 250;
|
|
102
|
+
const CLAIM_RETRY_MS = 5000;
|
|
103
|
+
/** How long an open of a field with nothing local waits for the server's
|
|
104
|
+
* first answer before handing out the (still empty) document. */
|
|
105
|
+
const FIRST_ANSWER_TIMEOUT_MS = 4000;
|
|
106
|
+
/** Pause after a fold is noticed before checking the published document. */
|
|
107
|
+
const VERIFY_DELAY_MS = 1000;
|
|
108
|
+
|
|
32
109
|
export class CrdtManager {
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
// Coalesces concurrent first-time subscribes for the same table. Stamped with
|
|
37
|
-
// the connection generation it started under, so a restart after a drop can
|
|
38
|
-
// tell a reusable in-flight attempt from a doomed one.
|
|
39
|
-
private pendingLive = new Map<string, { promise: Promise<void>; generation: number }>();
|
|
40
|
-
// Tables whose LIVE died with a dropped socket, awaiting the next `connected`
|
|
41
|
-
// to be re-subscribed. Held separately because `liveByTable` is cleared on the
|
|
42
|
-
// drop (those uuids belong to a session that no longer exists).
|
|
43
|
-
private staleTables = new Set<string>();
|
|
44
|
-
// Bumped on every socket drop. A LIVE registration that started under an
|
|
45
|
-
// older generation is discarded rather than recorded, so a drop landing
|
|
46
|
-
// mid-registration can't leave a uuid from a dead session in `liveByTable`.
|
|
47
|
-
private connectionGeneration = 0;
|
|
48
|
-
private connectionUnsubscribes: Array<() => void> = [];
|
|
110
|
+
private sessions = new Map<string, Session>();
|
|
111
|
+
private opening = new Map<string, Promise<Session>>();
|
|
112
|
+
private ports: CrdtPorts | null = null;
|
|
49
113
|
private logger: Logger;
|
|
50
|
-
|
|
51
|
-
|
|
114
|
+
private peer: `${number}` = randomPeer();
|
|
115
|
+
/** The current peer's claim: settled once the database holds it for us. */
|
|
116
|
+
private claim: Promise<boolean> | null = null;
|
|
117
|
+
private claimRetry: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
private connectionUnsubscribes: Array<() => void> = [];
|
|
52
119
|
|
|
53
120
|
constructor(
|
|
54
121
|
private schema: SchemaStructure,
|
|
55
122
|
private local: LocalStore,
|
|
56
123
|
private remote: RemoteDatabaseService,
|
|
57
124
|
logger: Logger,
|
|
58
|
-
private debounceMs: number =
|
|
125
|
+
private debounceMs: number = 300
|
|
59
126
|
) {
|
|
60
127
|
this.logger = logger.child({ service: 'CrdtManager' });
|
|
61
|
-
this.subscribeToReconnect();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Re-establish table LIVEs after a socket drop.
|
|
66
|
-
*
|
|
67
|
-
* A LIVE subscription lives and dies with its WebSocket session, and
|
|
68
|
-
* `ensureTableSubscription` is memoized on `liveByTable` — so without this,
|
|
69
|
-
* the first reconnect leaves CRDT realtime permanently dead: the map still
|
|
70
|
-
* holds a uuid for a subscription the server has forgotten, so every later
|
|
71
|
-
* `open()` short-circuits and no LIVE is ever re-issued.
|
|
72
|
-
*
|
|
73
|
-
* Both drop events matter: the SDK publishes `reconnecting` (not
|
|
74
|
-
* `disconnected`) when it intends to recover on its own, and `disconnected`
|
|
75
|
-
* only once it has given up.
|
|
76
|
-
*/
|
|
77
|
-
private subscribeToReconnect(): void {
|
|
78
|
-
const onDrop = () => {
|
|
79
|
-
this.connectionGeneration++;
|
|
80
|
-
for (const table of this.liveByTable.keys()) this.staleTables.add(table);
|
|
81
|
-
// In-flight registrations count too: they'd otherwise complete against
|
|
82
|
-
// the dead session and be silently dropped by the generation guard.
|
|
83
|
-
for (const table of this.pendingLive.keys()) this.staleTables.add(table);
|
|
84
|
-
// No KILL: the server-side subscriptions are already gone, and these
|
|
85
|
-
// uuids would resolve against the new session.
|
|
86
|
-
this.liveByTable.clear();
|
|
87
|
-
};
|
|
88
128
|
this.connectionUnsubscribes.push(
|
|
89
|
-
this.remote.subscribeConnection('reconnecting', onDrop),
|
|
90
|
-
this.remote.subscribeConnection('disconnected', onDrop),
|
|
91
129
|
this.remote.subscribeConnection('connected', () => {
|
|
92
|
-
const
|
|
93
|
-
this.staleTables.clear();
|
|
94
|
-
for (const table of tables) {
|
|
95
|
-
// Only resurrect tables that still have an open field — a drop can
|
|
96
|
-
// outlive the editor that needed the feed.
|
|
97
|
-
if (!this.hasOpenFieldFor(table)) continue;
|
|
98
|
-
void this.ensureTableSubscription(table);
|
|
99
|
-
}
|
|
130
|
+
for (const s of this.sessions.values()) this.scheduleFlush(s, 0);
|
|
100
131
|
})
|
|
101
132
|
);
|
|
102
133
|
}
|
|
103
134
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
135
|
+
/** Hand over the runtime ports. Called once by the client facade. */
|
|
136
|
+
attach(ports: CrdtPorts): void {
|
|
137
|
+
this.ports = ports;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A new signed-in identity (boot, sign-in, sign-out): edits from here on
|
|
142
|
+
* belong to it, under a peer it claims. Edits already made stay with the
|
|
143
|
+
* peer they were made under.
|
|
144
|
+
*/
|
|
145
|
+
setSessionId(_salt: string): void {
|
|
146
|
+
this.peer = randomPeer();
|
|
147
|
+
this.claim = null;
|
|
148
|
+
for (const s of this.sessions.values()) s.doc.setPeerId(this.peer);
|
|
149
|
+
}
|
|
150
|
+
|
|
106
151
|
dispose(): void {
|
|
107
|
-
for (const off of this.connectionUnsubscribes) {
|
|
152
|
+
for (const off of this.connectionUnsubscribes.splice(0)) {
|
|
108
153
|
try {
|
|
109
154
|
off();
|
|
110
155
|
} catch {
|
|
111
156
|
/* ignore */
|
|
112
157
|
}
|
|
113
158
|
}
|
|
114
|
-
this.
|
|
115
|
-
this.staleTables.clear();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
private hasOpenFieldFor(table: string): boolean {
|
|
119
|
-
const prefix = `${table}:`;
|
|
120
|
-
return Array.from(this.fields.keys()).some((k) => k.startsWith(prefix));
|
|
159
|
+
if (this.claimRetry) clearTimeout(this.claimRetry);
|
|
121
160
|
}
|
|
122
161
|
|
|
123
|
-
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
|
|
128
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Open a collaborative field. Every open of the same field shares one
|
|
164
|
+
* document; close each open with {@link close}.
|
|
165
|
+
*
|
|
166
|
+
* `draft` (from {@link createDraft}) hands over edits made before the record
|
|
167
|
+
* existed: they are merged in and sent like this tab's own.
|
|
168
|
+
*/
|
|
169
|
+
async open(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
|
|
170
|
+
const { kind, cursor } = this.fieldSpec(table, field);
|
|
171
|
+
const key = `${table}|${recordId}|${field}`;
|
|
172
|
+
let session = this.sessions.get(key);
|
|
173
|
+
if (session) {
|
|
174
|
+
session.refs++;
|
|
175
|
+
} else {
|
|
176
|
+
const inFlight = this.opening.get(key);
|
|
177
|
+
if (inFlight) {
|
|
178
|
+
session = await inFlight;
|
|
179
|
+
session.refs++;
|
|
180
|
+
} else {
|
|
181
|
+
const pending = this.start(key, table, recordId, field, kind, cursor).finally(() => this.opening.delete(key));
|
|
182
|
+
this.opening.set(key, pending);
|
|
183
|
+
session = await pending;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (draft) this.adopt(session, draft.getDoc());
|
|
187
|
+
return session.handle;
|
|
129
188
|
}
|
|
130
189
|
|
|
131
190
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* @param recordId - Full record ID (e.g., "thread:abc")
|
|
136
|
-
* @param field - Field name (e.g., "title", "content")
|
|
137
|
-
* @param fallbackText - Current plain text from the record, used to seed the
|
|
138
|
-
* LoroDoc if no CRDT state exists yet (migration path)
|
|
191
|
+
* A detached document for a field of a record that does not exist yet (a
|
|
192
|
+
* "new thread" form). Nothing is sent; pass it to {@link open} once the
|
|
193
|
+
* record is created and its edits become the record's first ones.
|
|
139
194
|
*/
|
|
140
|
-
async
|
|
195
|
+
async createDraft(table: string, field: string): Promise<CrdtField> {
|
|
196
|
+
const { kind, cursor } = this.fieldSpec(table, field);
|
|
197
|
+
const loro = await loadLoro();
|
|
198
|
+
return new CrdtField(table, '', field, kind, cursor, new loro.LoroDoc(), { pushPresence: () => {} });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private adopt(s: Session, draft: LoroDoc): void {
|
|
202
|
+
const peers = [...draft.oplogVersion().toJSON().keys()];
|
|
203
|
+
if (peers.length === 0) return;
|
|
204
|
+
this.tryImport(s.doc, draft.export({ mode: 'update' }), s.key);
|
|
205
|
+
// The draft's ops carry its own peer: claim and send them under it.
|
|
206
|
+
for (const peer of peers) s.local.peers[String(peer)] ??= { enqueued: 0 };
|
|
207
|
+
this.schedulePersist(s);
|
|
208
|
+
this.scheduleFlush(s, 0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Close one open. The last close of a field resolves once its unsent
|
|
212
|
+
* edits are in the outbox (or are left for a later session, offline). */
|
|
213
|
+
close(table: string, recordId: string, field: string): Promise<void> {
|
|
214
|
+
const s = this.sessions.get(`${table}|${recordId}|${field}`);
|
|
215
|
+
if (!s || --s.refs > 0) return Promise.resolve();
|
|
216
|
+
return this.stop(s, true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Close every field. `flush: false` (a bucket switch) drops unsent edits
|
|
220
|
+
* from this session's memory; they stay in the local snapshot of the
|
|
221
|
+
* bucket they belong to. */
|
|
222
|
+
closeAll(options: { flush?: boolean } = {}): void {
|
|
223
|
+
for (const s of [...this.sessions.values()]) void this.stop(s, options.flush ?? true);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
private fieldSpec(table: string, field: string): { kind: CrdtKind; cursor: boolean } {
|
|
229
|
+
const t = this.schema.tables.find((x) => x.name === table);
|
|
230
|
+
if (!t) throw new Error(`CrdtManager: unknown table '${table}'`);
|
|
231
|
+
const col = (t.columns as Record<string, any>)[field];
|
|
232
|
+
if (!col) throw new Error(`CrdtManager: unknown field '${table}.${field}'`);
|
|
233
|
+
if (!col.crdt) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`CrdtManager: '${table}.${field}' is not a CRDT field. Add \`-- @crdt text\` or \`-- @crdt prosemirror\` above its DEFINE FIELD and re-run \`spky generate\`.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const kind: CrdtKind = col.crdt === 'prosemirror' ? 'prosemirror' : 'text';
|
|
239
|
+
return { kind, cursor: !!col.cursor };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async start(
|
|
243
|
+
key: string,
|
|
141
244
|
table: string,
|
|
142
245
|
recordId: string,
|
|
143
246
|
field: string,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
247
|
+
kind: CrdtKind,
|
|
248
|
+
cursor: boolean
|
|
249
|
+
): Promise<Session> {
|
|
250
|
+
if (!this.ports) throw new Error('CrdtManager: not attached to a client');
|
|
251
|
+
const loro = await loadLoro();
|
|
252
|
+
const doc = new loro.LoroDoc();
|
|
253
|
+
doc.setPeerId(this.peer);
|
|
254
|
+
|
|
255
|
+
const local = await this.readLocal(recordId, field);
|
|
256
|
+
if (local.snapshot) this.tryImport(doc, local.snapshot, key);
|
|
257
|
+
|
|
258
|
+
const session: Session = {
|
|
259
|
+
key,
|
|
260
|
+
table,
|
|
261
|
+
recordId,
|
|
262
|
+
field,
|
|
263
|
+
kind,
|
|
264
|
+
cursor,
|
|
265
|
+
doc,
|
|
266
|
+
handle: undefined as unknown as CrdtField,
|
|
267
|
+
refs: 1,
|
|
268
|
+
local,
|
|
269
|
+
seen: new Set(),
|
|
270
|
+
present: new Set(),
|
|
271
|
+
verifiedRev: 0,
|
|
272
|
+
verifyTimer: null,
|
|
273
|
+
disposers: [],
|
|
274
|
+
flushTimer: null,
|
|
275
|
+
firstDirtyAt: 0,
|
|
276
|
+
persistTimer: null,
|
|
277
|
+
flushing: null,
|
|
278
|
+
refetching: false,
|
|
279
|
+
presenceTimer: null,
|
|
280
|
+
presencePending: null,
|
|
281
|
+
presenceSent: false,
|
|
282
|
+
ingesting: Promise.resolve(),
|
|
283
|
+
};
|
|
284
|
+
session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, {
|
|
285
|
+
pushPresence: (state) => this.pushPresence(session, state),
|
|
286
|
+
});
|
|
287
|
+
this.sessions.set(key, session);
|
|
288
|
+
|
|
289
|
+
// Claimed now, so the first edits do not wait on the round trip.
|
|
290
|
+
void this.ensureClaimed();
|
|
291
|
+
// First open on this device: start from the published document.
|
|
292
|
+
if (!local.snapshot) await this.refetchCanonical(session);
|
|
293
|
+
|
|
294
|
+
session.disposers.push(
|
|
295
|
+
doc.subscribeLocalUpdates(() => {
|
|
296
|
+
// Remember every peer that made edits here: a later identity change
|
|
297
|
+
// swaps the peer, and these ops must still go out under this one.
|
|
298
|
+
session.local.peers[this.peer] ??= { enqueued: 0 };
|
|
299
|
+
this.scheduleFlush(session);
|
|
300
|
+
this.schedulePersist(session);
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
const ports = this.ports;
|
|
304
|
+
const log = await ports.watch(`${table}${LOG_SUFFIX}`, { doc: recordId, field }, (rows) => {
|
|
305
|
+
session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
|
|
306
|
+
});
|
|
307
|
+
session.disposers.push(log.stop);
|
|
308
|
+
if (cursor) {
|
|
309
|
+
const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) =>
|
|
310
|
+
this.ingestPresence(session, rows)
|
|
173
311
|
);
|
|
312
|
+
session.disposers.push(presence.stop);
|
|
313
|
+
}
|
|
314
|
+
// An editor binding an empty document creates its root structure, and
|
|
315
|
+
// two clients doing that at once keep only one of them (with whatever was
|
|
316
|
+
// typed into the other). With nothing local, hand the document out only
|
|
317
|
+
// once the server's rows for it are in.
|
|
318
|
+
if (!session.handle.hasContent()) {
|
|
319
|
+
await Promise.race([log.settled, delay(FIRST_ANSWER_TIMEOUT_MS)]);
|
|
174
320
|
}
|
|
321
|
+
await session.ingesting;
|
|
322
|
+
// Edits a previous session made but never sent (closed tab, offline).
|
|
323
|
+
this.scheduleFlush(session, 0);
|
|
324
|
+
this.logger.debug({ key, peer: this.peer, Category: 'sp00ky-client::CrdtManager::open' }, 'CRDT field opened');
|
|
325
|
+
return session;
|
|
326
|
+
}
|
|
175
327
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
328
|
+
private async stop(s: Session, flush: boolean): Promise<void> {
|
|
329
|
+
this.sessions.delete(s.key);
|
|
330
|
+
for (const off of s.disposers.splice(0)) {
|
|
331
|
+
try {
|
|
332
|
+
off();
|
|
333
|
+
} catch {
|
|
334
|
+
/* ignore */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (s.flushTimer) clearTimeout(s.flushTimer);
|
|
338
|
+
if (s.persistTimer) clearTimeout(s.persistTimer);
|
|
339
|
+
if (s.presenceTimer) clearTimeout(s.presenceTimer);
|
|
340
|
+
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
341
|
+
if (s.presenceSent) void this.dropPresence(s);
|
|
342
|
+
if (flush) {
|
|
343
|
+
await this.persist(s);
|
|
344
|
+
await this.flush(s);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
182
347
|
|
|
183
|
-
|
|
184
|
-
{ key, hasInitialState: !!initialCrdtState, hasFallback: !!fallbackText, Category: 'sp00ky-client::CrdtManager::open' },
|
|
185
|
-
'CrdtField opened'
|
|
186
|
-
);
|
|
348
|
+
// --- receiving ---------------------------------------------------
|
|
187
349
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// editor sits empty until the parent's LIVE feed happens to fire.
|
|
197
|
-
if (!initialCrdtState) {
|
|
198
|
-
void this.fetchAndDispatchRow(table, recordId);
|
|
350
|
+
private async ingest(s: Session, rows: Record<string, unknown>[]): Promise<void> {
|
|
351
|
+
const loro = await loadLoro();
|
|
352
|
+
const ids = new Set(rows.map((row) => String(row.id ?? '')));
|
|
353
|
+
for (const id of s.present) {
|
|
354
|
+
if (!ids.has(id)) {
|
|
355
|
+
this.scheduleVerify(s);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
199
358
|
}
|
|
200
|
-
|
|
201
|
-
|
|
359
|
+
s.present = ids;
|
|
360
|
+
const blobs: Uint8Array[] = [];
|
|
361
|
+
for (const row of rows) {
|
|
362
|
+
const id = String(row.id ?? '');
|
|
363
|
+
if (!id || s.seen.has(id)) continue;
|
|
364
|
+
s.seen.add(id);
|
|
365
|
+
const blob = asBytes(row.data);
|
|
366
|
+
const verdict = check(loro, blob, row.peer == null ? undefined : String(row.peer));
|
|
367
|
+
if (!verdict.ok) {
|
|
368
|
+
this.logger.debug({ row: id, reason: verdict.reason, Category: 'sp00ky-client::CrdtManager::ingest' }, 'CRDT log row refused');
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
blobs.push(blob!);
|
|
372
|
+
}
|
|
373
|
+
if (blobs.length === 0) return;
|
|
374
|
+
let pending = false;
|
|
375
|
+
for (const blob of blobs) {
|
|
376
|
+
const status = this.tryImport(s.doc, blob, s.key);
|
|
377
|
+
if (status?.pending) pending = true;
|
|
378
|
+
}
|
|
379
|
+
this.schedulePersist(s);
|
|
380
|
+
// Ops depending on rows that were folded away before this device saw
|
|
381
|
+
// them: the published snapshot holds those predecessors.
|
|
382
|
+
if (pending) void this.refetchCanonical(s);
|
|
202
383
|
}
|
|
203
384
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.
|
|
385
|
+
private tryImport(doc: LoroDoc, blob: Uint8Array, key: string): { pending: boolean } | null {
|
|
386
|
+
try {
|
|
387
|
+
const status = doc.import(blob);
|
|
388
|
+
return { pending: !!status.pending && status.pending.size > 0 };
|
|
389
|
+
} catch (e) {
|
|
390
|
+
this.logger.warn({ key, error: String(e), Category: 'sp00ky-client::CrdtManager::import' }, 'CRDT import failed');
|
|
391
|
+
return null;
|
|
210
392
|
}
|
|
393
|
+
}
|
|
211
394
|
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
this.
|
|
395
|
+
private async refetchCanonical(s: Session): Promise<void> {
|
|
396
|
+
if (s.refetching) return;
|
|
397
|
+
s.refetching = true;
|
|
398
|
+
try {
|
|
399
|
+
const [row] = await this.remote.query<[{ rev?: number; snapshot?: unknown } | null]>(
|
|
400
|
+
"SELECT rev, snapshot FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])",
|
|
401
|
+
{ doc: s.recordId, field: s.field }
|
|
402
|
+
);
|
|
403
|
+
const snapshot = asBytes(row?.snapshot);
|
|
404
|
+
if (snapshot) {
|
|
405
|
+
this.tryImport(s.doc, snapshot, s.key);
|
|
406
|
+
s.local.rev = Math.max(s.local.rev, row?.rev ?? 0);
|
|
407
|
+
this.schedulePersist(s);
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
// Offline or no published document yet: the log rows still arrive.
|
|
411
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::refetch' }, 'No canonical CRDT document fetched');
|
|
412
|
+
} finally {
|
|
413
|
+
s.refetching = false;
|
|
217
414
|
}
|
|
415
|
+
}
|
|
218
416
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
417
|
+
// --- checking against the published document ---------------------
|
|
418
|
+
|
|
419
|
+
private scheduleVerify(s: Session): void {
|
|
420
|
+
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
421
|
+
s.verifyTimer = setTimeout(() => {
|
|
422
|
+
s.verifyTimer = null;
|
|
423
|
+
void this.verify(s);
|
|
424
|
+
}, VERIFY_DELAY_MS);
|
|
223
425
|
}
|
|
224
426
|
|
|
225
427
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
428
|
+
* After a fold: compare this device's document with the one the server
|
|
429
|
+
* published, at the version the server published. Missing ops (rows this
|
|
430
|
+
* device never got before they were folded away) are repaired by merging
|
|
431
|
+
* the published snapshot in. The same ops giving a different state cannot
|
|
432
|
+
* be merged away; that is logged.
|
|
229
433
|
*/
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
434
|
+
private async verify(s: Session): Promise<void> {
|
|
435
|
+
type Published = {
|
|
436
|
+
rev?: number;
|
|
437
|
+
state_hash?: string;
|
|
438
|
+
upto?: { vv?: Record<string, number>; frontiers?: { peer: `${number}`; counter: number }[] };
|
|
439
|
+
};
|
|
440
|
+
let row: Published | null;
|
|
441
|
+
try {
|
|
442
|
+
[row] = await this.remote.query<[Published | null]>(
|
|
443
|
+
"SELECT rev, upto, state_hash FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])",
|
|
444
|
+
{ doc: s.recordId, field: s.field }
|
|
445
|
+
);
|
|
446
|
+
} catch {
|
|
447
|
+
return; // offline: the next fold checks again
|
|
233
448
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
449
|
+
const rev = row?.rev ?? 0;
|
|
450
|
+
const frontiers = row?.upto?.frontiers;
|
|
451
|
+
if (!row?.state_hash || !frontiers || !row.upto?.vv || rev <= s.verifiedRev || !this.sessions.has(s.key)) return;
|
|
452
|
+
|
|
453
|
+
const have = s.doc.oplogVersion().toJSON();
|
|
454
|
+
const behind = Object.entries(row.upto.vv).some(([peer, end]) => (have.get(peer as `${number}`) ?? 0) < end);
|
|
455
|
+
if (behind) {
|
|
456
|
+
this.logger.info({ key: s.key, rev, Category: 'sp00ky-client::CrdtManager::verify' }, 'CRDT document behind the published one; merging it in');
|
|
457
|
+
await this.refetchCanonical(s);
|
|
458
|
+
return;
|
|
237
459
|
}
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (pending) {
|
|
257
|
-
if (pending.generation === generation) return pending.promise;
|
|
258
|
-
// A registration from before the drop is still settling. It will discard
|
|
259
|
-
// itself; wait it out, then register fresh on the new socket.
|
|
260
|
-
await pending.promise.catch(() => {});
|
|
261
|
-
if (this.liveByTable.has(table)) return;
|
|
262
|
-
if (generation !== this.connectionGeneration) return;
|
|
263
|
-
// Another caller may have won the race to re-register during that await;
|
|
264
|
-
// join it rather than opening a second LIVE on the same table.
|
|
265
|
-
const successor = this.pendingLive.get(table);
|
|
266
|
-
if (successor && successor.generation === generation) return successor.promise;
|
|
460
|
+
s.verifiedRev = rev;
|
|
461
|
+
// WebCrypto is missing on insecure origins: nothing to check with there.
|
|
462
|
+
if (!globalThis.crypto?.subtle) return;
|
|
463
|
+
const loro = await loadLoro();
|
|
464
|
+
let fork: LoroDoc | undefined;
|
|
465
|
+
try {
|
|
466
|
+
fork = s.doc.forkAt(frontiers);
|
|
467
|
+
const hash = await stateHash(loro, fork);
|
|
468
|
+
if (hash !== row.state_hash) {
|
|
469
|
+
this.logger.warn(
|
|
470
|
+
{ key: s.key, rev, hash, published: row.state_hash, Category: 'sp00ky-client::CrdtManager::verify' },
|
|
471
|
+
'CRDT document differs from the published one at the same version'
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
} catch (e) {
|
|
475
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::verify' }, 'CRDT document check failed');
|
|
476
|
+
} finally {
|
|
477
|
+
fork?.free();
|
|
267
478
|
}
|
|
479
|
+
}
|
|
268
480
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
481
|
+
// --- sending -----------------------------------------------------
|
|
482
|
+
|
|
483
|
+
private scheduleFlush(s: Session, delay = this.debounceMs): void {
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
if (!s.flushTimer) s.firstDirtyAt = now;
|
|
486
|
+
else clearTimeout(s.flushTimer);
|
|
487
|
+
const wait = Math.max(0, Math.min(delay, s.firstDirtyAt + MAX_WAIT_MS - now));
|
|
488
|
+
s.flushTimer = setTimeout(() => {
|
|
489
|
+
s.flushTimer = null;
|
|
490
|
+
void this.flush(s);
|
|
491
|
+
}, wait);
|
|
492
|
+
}
|
|
274
493
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
494
|
+
private async flush(s: Session): Promise<void> {
|
|
495
|
+
if (s.flushing) return s.flushing;
|
|
496
|
+
s.flushing = this.sendOwnEdits(s).finally(() => {
|
|
497
|
+
s.flushing = null;
|
|
498
|
+
});
|
|
499
|
+
return s.flushing;
|
|
500
|
+
}
|
|
282
501
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
502
|
+
/** Send every own peer's ops that are not in the outbox yet, one row per
|
|
503
|
+
* peer. Only the peer's own ops travel (`updates-in-range`): a plain
|
|
504
|
+
* `from: version` export would carry ops imported from others too, and
|
|
505
|
+
* the acceptance rule refuses such a row. */
|
|
506
|
+
private async sendOwnEdits(s: Session): Promise<void> {
|
|
507
|
+
const vv = s.doc.oplogVersion();
|
|
508
|
+
const own = new Set([...Object.keys(s.local.peers), this.peer]);
|
|
509
|
+
const due = [...own]
|
|
510
|
+
.map((peer) => ({ peer, end: vv.get(peer as `${number}`) ?? 0, from: s.local.peers[peer]?.enqueued ?? 0 }))
|
|
511
|
+
.filter((p) => p.end > p.from);
|
|
512
|
+
if (due.length === 0 || !this.ports) return;
|
|
513
|
+
if (!(await this.ensureClaimed())) {
|
|
514
|
+
this.retryLater(s);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
for (const { peer, end, from } of due) {
|
|
518
|
+
if (peer !== this.peer && !(await this.claimPeer(peer as `${number}`))) continue;
|
|
519
|
+
const data = s.doc.export({
|
|
520
|
+
mode: 'updates-in-range',
|
|
521
|
+
spans: [{ id: { peer: peer as `${number}`, counter: from }, len: end - from }],
|
|
522
|
+
});
|
|
523
|
+
try {
|
|
524
|
+
await this.ports.create(`${s.table}${LOG_SUFFIX}:${rowKey()}`, {
|
|
525
|
+
doc: s.recordId,
|
|
526
|
+
field: s.field,
|
|
527
|
+
peer,
|
|
528
|
+
data,
|
|
288
529
|
});
|
|
289
|
-
|
|
290
|
-
if (generation !== this.connectionGeneration) return;
|
|
291
|
-
|
|
292
|
-
this.liveByTable.set(table, uuid);
|
|
293
|
-
this.logger.info(
|
|
294
|
-
{ table, Category: 'sp00ky-client::CrdtManager::ensureTableSubscription' },
|
|
295
|
-
'LIVE SELECT started'
|
|
296
|
-
);
|
|
297
530
|
} catch (e) {
|
|
298
|
-
this.logger.warn(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
);
|
|
531
|
+
this.logger.warn({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::flush' }, 'Queueing CRDT edits failed');
|
|
532
|
+
this.retryLater(s);
|
|
533
|
+
return;
|
|
302
534
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
this.pendingLive.set(table, { promise: start, generation });
|
|
306
|
-
try {
|
|
307
|
-
await start;
|
|
308
|
-
} finally {
|
|
309
|
-
this.pendingLive.delete(table);
|
|
535
|
+
s.local.peers[peer] = { enqueued: end };
|
|
310
536
|
}
|
|
537
|
+
await this.persist(s);
|
|
311
538
|
}
|
|
312
539
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
* parent table is filtered out by the SurrealDB cross-session
|
|
316
|
-
* permission gap). Same semantics as the internal `dispatchRow`. */
|
|
317
|
-
applyRow(table: string, row: Record<string, unknown>): void {
|
|
318
|
-
this.dispatchRow(table, row);
|
|
540
|
+
private retryLater(s: Session): void {
|
|
541
|
+
if (this.sessions.get(s.key) === s) this.scheduleFlush(s, CLAIM_RETRY_MS);
|
|
319
542
|
}
|
|
320
543
|
|
|
321
|
-
/**
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const rowKeyPrefix = `${table}:${id}:`;
|
|
330
|
-
for (const [key, crdtField] of this.fields) {
|
|
331
|
-
if (!key.startsWith(rowKeyPrefix)) continue;
|
|
332
|
-
const fieldName = key.slice(rowKeyPrefix.length);
|
|
333
|
-
const cursorsEnabled = this.fieldHasCursor(table, fieldName);
|
|
334
|
-
const slice = row[fieldName];
|
|
335
|
-
const snapshot = this.extractSnapshot(slice, cursorsEnabled);
|
|
336
|
-
if (snapshot) crdtField.importRemote(snapshot);
|
|
337
|
-
|
|
338
|
-
if (cursorsEnabled && slice && typeof slice === 'object') {
|
|
339
|
-
const cursors = (slice as { cursors?: unknown }).cursors;
|
|
340
|
-
if (cursors && typeof cursors === 'object') {
|
|
341
|
-
for (const [sid, blob] of Object.entries(cursors as Record<string, unknown>)) {
|
|
342
|
-
if (sid === this.sessionId) continue;
|
|
343
|
-
if (typeof blob === 'string' && blob.length > 0) {
|
|
344
|
-
crdtField.importRemoteCursor(blob);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
544
|
+
/** Claim this session's peer once; a failure (offline, signed out) is retried. */
|
|
545
|
+
private ensureClaimed(): Promise<boolean> {
|
|
546
|
+
if (!this.claim) {
|
|
547
|
+
const peer = this.peer;
|
|
548
|
+
this.claim = this.claimPeer(peer).then((ok) => {
|
|
549
|
+
if (!ok && this.peer === peer) this.claim = null;
|
|
550
|
+
return ok;
|
|
551
|
+
});
|
|
349
552
|
}
|
|
553
|
+
return this.claim;
|
|
350
554
|
}
|
|
351
555
|
|
|
352
|
-
|
|
353
|
-
* locally yet (fresh device, memory-backed local DB after reload, …).
|
|
354
|
-
* Used by `open()` when the local read came up empty. Subsequent
|
|
355
|
-
* cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
|
|
356
|
-
private async fetchAndDispatchRow(table: string, id: string): Promise<void> {
|
|
556
|
+
private async claimPeer(peer: `${number}`): Promise<boolean> {
|
|
357
557
|
try {
|
|
358
|
-
|
|
359
|
-
const [
|
|
360
|
-
|
|
361
|
-
{ id: recordId },
|
|
362
|
-
);
|
|
363
|
-
if (!row || typeof row !== 'object') return;
|
|
364
|
-
this.dispatchRow(table, row as Record<string, unknown>);
|
|
558
|
+
// A refused CREATE (signed out, impersonating) answers with no row.
|
|
559
|
+
const [created] = await this.remote.query<[unknown[]]>("CREATE type::record('_00_crdt_peer', $peer) RETURN id", { peer });
|
|
560
|
+
return Array.isArray(created) && created.length > 0;
|
|
365
561
|
} catch (e) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
562
|
+
if (!/already exists/i.test(String(e))) return false;
|
|
563
|
+
// Ours from an earlier claim (only its owner can read it), or taken.
|
|
564
|
+
try {
|
|
565
|
+
const [owner] = await this.remote.query<[unknown]>(
|
|
566
|
+
"SELECT VALUE author FROM ONLY type::record('_00_crdt_peer', $peer)",
|
|
567
|
+
{ peer }
|
|
568
|
+
);
|
|
569
|
+
if (owner != null) return true;
|
|
570
|
+
} catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
this.logger.error({ peer, Category: 'sp00ky-client::CrdtManager::claim' }, 'CRDT peer id taken by another user');
|
|
574
|
+
if (peer === this.peer) this.setSessionId('');
|
|
575
|
+
return false;
|
|
370
576
|
}
|
|
371
577
|
}
|
|
372
578
|
|
|
373
|
-
|
|
374
|
-
* Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
|
|
375
|
-
private fieldHasCursor(table: string, field: string): boolean {
|
|
376
|
-
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
377
|
-
return !!tableSchema?.columns[field]?.cursor;
|
|
378
|
-
}
|
|
579
|
+
// --- local state -------------------------------------------------
|
|
379
580
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
private extractSnapshot(value: unknown, cursorsEnabled: boolean): Uint8Array | undefined {
|
|
384
|
-
const asBytes = (v: unknown): Uint8Array | undefined => {
|
|
385
|
-
if (v instanceof Uint8Array) return v.length > 0 ? v : undefined;
|
|
386
|
-
// SurrealDB ferries bytes through several shapes depending on
|
|
387
|
-
// transport and on whether the field is a top-level `bytes` column
|
|
388
|
-
// or bytes nested inside a FLEXIBLE object. Round-tripping bytes
|
|
389
|
-
// through `option<object> FLEXIBLE` (the `@crdt @cursor` shape) in
|
|
390
|
-
// particular comes back as a plain `number[]` from the local WASM
|
|
391
|
-
// DB and as `Uint8Array` from the remote WS engine. Normalize all
|
|
392
|
-
// recognized variants here so the receiving CrdtField doesn't care.
|
|
393
|
-
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
394
|
-
if (ArrayBuffer.isView(v)) {
|
|
395
|
-
const view = v as ArrayBufferView;
|
|
396
|
-
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
397
|
-
}
|
|
398
|
-
if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === 'number')) {
|
|
399
|
-
return Uint8Array.from(v as number[]);
|
|
400
|
-
}
|
|
401
|
-
return undefined;
|
|
402
|
-
};
|
|
581
|
+
private localId(recordId: string, field: string): RecordId {
|
|
582
|
+
return new RecordId(LOCAL_TABLE, `${recordId}|${field}`);
|
|
583
|
+
}
|
|
403
584
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
585
|
+
private async readLocal(recordId: string, field: string): Promise<LocalState> {
|
|
586
|
+
try {
|
|
587
|
+
const row = await this.local.getById(LOCAL_TABLE, this.localId(recordId, field));
|
|
588
|
+
if (row) {
|
|
589
|
+
return {
|
|
590
|
+
snapshot: asBytes(row.snapshot),
|
|
591
|
+
rev: Number(row.rev ?? 0),
|
|
592
|
+
peers: (row.peers as LocalState['peers']) ?? {},
|
|
593
|
+
};
|
|
407
594
|
}
|
|
408
|
-
|
|
595
|
+
} catch (e) {
|
|
596
|
+
this.logger.debug({ error: String(e), Category: 'sp00ky-client::CrdtManager::readLocal' }, 'No local CRDT state');
|
|
409
597
|
}
|
|
410
|
-
return
|
|
598
|
+
return { rev: 0, peers: {} };
|
|
411
599
|
}
|
|
412
600
|
|
|
413
|
-
private
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
601
|
+
private schedulePersist(s: Session): void {
|
|
602
|
+
if (s.persistTimer) return;
|
|
603
|
+
s.persistTimer = setTimeout(() => {
|
|
604
|
+
s.persistTimer = null;
|
|
605
|
+
void this.persist(s);
|
|
606
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Save the merged document. Another tab may have saved too: merge with
|
|
610
|
+
* its snapshot rather than overwrite it, and keep the further of the two
|
|
611
|
+
* outbox marks per peer. */
|
|
612
|
+
private async persist(s: Session): Promise<void> {
|
|
613
|
+
try {
|
|
614
|
+
const stored = await this.readLocal(s.recordId, s.field);
|
|
615
|
+
if (stored.snapshot) this.tryImport(s.doc, stored.snapshot, s.key);
|
|
616
|
+
for (const [peer, mark] of Object.entries(stored.peers)) {
|
|
617
|
+
const mine = s.local.peers[peer]?.enqueued ?? 0;
|
|
618
|
+
if (mark.enqueued > mine) s.local.peers[peer] = { enqueued: mark.enqueued };
|
|
619
|
+
}
|
|
620
|
+
s.local.rev = Math.max(s.local.rev, stored.rev);
|
|
621
|
+
s.local.snapshot = s.doc.export({ mode: 'snapshot' });
|
|
622
|
+
await this.local.upsert(
|
|
623
|
+
LOCAL_TABLE,
|
|
624
|
+
this.localId(s.recordId, s.field),
|
|
625
|
+
{ doc: s.recordId, field: s.field, snapshot: s.local.snapshot, rev: s.local.rev, peers: s.local.peers },
|
|
626
|
+
'replace'
|
|
627
|
+
);
|
|
628
|
+
} catch (e) {
|
|
629
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::persist' }, 'Saving local CRDT state failed');
|
|
426
630
|
}
|
|
427
631
|
}
|
|
428
632
|
|
|
429
|
-
|
|
430
|
-
|
|
633
|
+
// --- presence ----------------------------------------------------
|
|
634
|
+
|
|
635
|
+
private pushPresence(s: Session, state: Uint8Array): void {
|
|
636
|
+
s.presencePending = state;
|
|
637
|
+
if (s.presenceTimer) return;
|
|
638
|
+
s.presenceTimer = setTimeout(() => {
|
|
639
|
+
s.presenceTimer = null;
|
|
640
|
+
const data = s.presencePending;
|
|
641
|
+
s.presencePending = null;
|
|
642
|
+
if (data) void this.sendPresence(s, data);
|
|
643
|
+
}, PRESENCE_THROTTLE_MS);
|
|
431
644
|
}
|
|
432
645
|
|
|
433
|
-
/**
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
`
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
646
|
+
/** Presence is the latest state only: no outbox, no offline queue. */
|
|
647
|
+
private async sendPresence(s: Session, state: Uint8Array): Promise<void> {
|
|
648
|
+
if (!(await this.ensureClaimed())) return;
|
|
649
|
+
try {
|
|
650
|
+
await this.remote.query(
|
|
651
|
+
// One row per session and field, keyed by a plain identifier (an array
|
|
652
|
+
// key does not survive the client's record-id handling). `$session`
|
|
653
|
+
// is a protected SurrealDB variable: the session id binds as `$sid`.
|
|
654
|
+
`UPSERT type::record($table, $key) SET doc = type::record($doc), field = $field, session = $sid, state = $state RETURN NONE`,
|
|
655
|
+
{
|
|
656
|
+
table: `${s.table}${PRESENCE_SUFFIX}`,
|
|
657
|
+
key: presenceKey(this.peer, s.field, s.recordId),
|
|
658
|
+
doc: s.recordId,
|
|
659
|
+
field: s.field,
|
|
660
|
+
sid: this.peer,
|
|
661
|
+
state,
|
|
662
|
+
}
|
|
448
663
|
);
|
|
664
|
+
s.presenceSent = true;
|
|
665
|
+
} catch (e) {
|
|
666
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::presence' }, 'Presence update failed');
|
|
449
667
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Remove this session's cursor when the field closes; the server's TTL
|
|
671
|
+
* sweep covers a tab that never gets here. */
|
|
672
|
+
private async dropPresence(s: Session): Promise<void> {
|
|
673
|
+
try {
|
|
674
|
+
await this.remote.query(`DELETE type::record($table, $key) RETURN NONE`, {
|
|
675
|
+
table: `${s.table}${PRESENCE_SUFFIX}`,
|
|
676
|
+
key: presenceKey(this.peer, s.field, s.recordId),
|
|
677
|
+
});
|
|
678
|
+
} catch (e) {
|
|
679
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::presence' }, 'Presence removal failed');
|
|
455
680
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
private ingestPresence(s: Session, rows: Record<string, unknown>[]): void {
|
|
685
|
+
for (const row of rows) {
|
|
686
|
+
if (String(row.session ?? '') === this.peer) continue;
|
|
687
|
+
const state = asBytes(row.state);
|
|
688
|
+
if (state) s.handle.deliverPresence(state);
|
|
461
689
|
}
|
|
462
690
|
}
|
|
463
691
|
}
|
|
692
|
+
|
|
693
|
+
/** `p<session>_<field>_<record key>`, letters, digits and `_` only. */
|
|
694
|
+
function presenceKey(session: string, field: string, recordId: string): string {
|
|
695
|
+
const key = recordId.slice(recordId.indexOf(':') + 1).replace(/[^A-Za-z0-9_]/g, '_');
|
|
696
|
+
return `p${session}_${field}_${key}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function delay(ms: number): Promise<void> {
|
|
700
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function rowKey(): string {
|
|
704
|
+
const bytes = new Uint8Array(12);
|
|
705
|
+
crypto.getRandomValues(bytes);
|
|
706
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Kept for callers that parse record ids next to CRDT fields.
|
|
710
|
+
export { parseRecordIdString };
|