@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.
Files changed (33) hide show
  1. package/AGENTS.md +4 -3
  2. package/dist/index.d.ts +156 -161
  3. package/dist/index.js +910 -482
  4. package/dist/types.d.ts +17 -6
  5. package/package.json +4 -4
  6. package/src/boot/preload.saga.test.ts +29 -0
  7. package/src/boot/preload.saga.ts +11 -2
  8. package/src/client/services.ts +3 -1
  9. package/src/client/sp00ky-client.test.ts +4 -1
  10. package/src/client/sp00ky-client.ts +37 -4
  11. package/src/modules/crdt/acceptance.ts +75 -0
  12. package/src/modules/crdt/bytes.ts +50 -0
  13. package/src/modules/crdt/crdt-field.ts +48 -255
  14. package/src/modules/crdt/crdt.test.ts +365 -0
  15. package/src/modules/crdt/hash.ts +103 -0
  16. package/src/modules/crdt/index.ts +610 -363
  17. package/src/modules/crdt/mirror.ts +37 -0
  18. package/src/mutation/push.saga.test.ts +16 -0
  19. package/src/mutation/push.saga.ts +9 -1
  20. package/src/query/anon-query-isolation.integration.test.ts +31 -0
  21. package/src/query/env.ts +9 -0
  22. package/src/services/database/cache-engine.ts +10 -0
  23. package/src/services/database/local-migrator.ts +5 -3
  24. package/src/services/database/sqlite-cache-engine.ts +28 -0
  25. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  26. package/src/services/database/sqlite-column-reset.ts +91 -0
  27. package/src/services/stream-processor/index.ts +24 -0
  28. package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
  29. package/src/testing/fake-services.ts +1 -1
  30. package/src/types.ts +7 -6
  31. package/src/utils/surql.ts +3 -4
  32. package/src/modules/crdt/crdt-hydration.test.ts +0 -210
  33. 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 type { Uuid } from 'surrealdb';
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 { parseRecordIdString } from '../../utils/index';
12
+ import type { CrdtKind } from './mirror';
8
13
 
9
14
  export { CrdtField, cursorColorFromName, CURSOR_COLORS } from './crdt-field';
10
15
 
11
16
  /**
12
- * CrdtManager manages active CrdtField instances and their sync channels.
17
+ * Collaborative (`@crdt`) fields.
13
18
  *
14
- * Collaborative state lives in two dedicated tables (defined in
15
- * `apps/cli/src/meta_tables_remote.surql`):
16
- * - `_00_crdt` { record_id, field, state } — one row per (record, field)
17
- * - `_00_cursor` { record_id, session_id, field, state } — one row per
18
- * (record, session, field)
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
- * Splitting them off the parent row is what makes offline edits mergeable:
21
- * each (record, field) gets its own row, so concurrent offline writes don't
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
- * Cross-browser delivery still rides the parent table's existing LIVE feed
25
- * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
26
- * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
27
- * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
28
- * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
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 fields = new Map<string, CrdtField>();
34
- // One LIVE subscription per parent table (e.g. "thread" → uuid).
35
- private liveByTable = new Map<string, Uuid>();
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
- // SurrealDB session id, used as the per-session key inside `_00_cursor`.
51
- private sessionId: string = '';
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 = 500,
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 tables = Array.from(this.staleTables);
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
- /** Stop observing transport events. Separate from {@link closeAll}, which also
105
- * runs on a bucket switch where the manager keeps being used. */
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.connectionUnsubscribes = [];
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
- /** Set the session id that scopes this client's cursor entries. Must be
124
- * called before `open()` for cursors to be pushed under a stable key.
125
- * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
126
- * for the data-module salt). */
127
- setSessionId(sessionId: string): void {
128
- this.sessionId = sessionId;
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
- * Open a CRDT field for collaborative editing.
133
- *
134
- * @param table - Table name
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 open(
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
- fallbackText?: string,
145
- ): Promise<CrdtField> {
146
- this.assertCrdtField(table, field);
147
- const cursorsEnabled = this.fieldHasCursor(table, field);
148
- const key = this.makeKey(table, recordId, field);
149
- let crdtField = this.fields.get(key);
150
-
151
- if (crdtField) {
152
- return crdtField;
153
- }
154
-
155
- // Read the snapshot directly off the parent row. `@crdt`-only fields
156
- // hold the base64 snapshot inline; `@crdt @cursor` fields hold a
157
- // `{ state, cursors }` object so we drill into `.state`. The query
158
- // always selects the local row — sync-down already populated it
159
- // (the snapshot is a column on the parent, not a sidecar table) so
160
- // there is no separate fetch on the happy path.
161
- let initialCrdtState: Uint8Array | undefined;
162
- try {
163
- const [result] = await this.local.query<[unknown]>(
164
- `SELECT VALUE ${field} FROM ONLY $id`,
165
- { id: parseRecordIdString(recordId) },
166
- );
167
- const snapshot = this.extractSnapshot(result, cursorsEnabled);
168
- if (snapshot) initialCrdtState = snapshot;
169
- } catch (e) {
170
- this.logger.info(
171
- { error: String(e), recordId, field, Category: 'sp00ky-client::CrdtManager::open' },
172
- 'No existing CRDT state found in local cache (continuing with empty doc)'
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
- // Load loro lazily (usually already resolved — the client preloads it at
177
- // startup when `config.crdt` is on).
178
- const { LoroDoc } = await loadLoro();
179
- crdtField = new CrdtField(field, cursorsEnabled, LoroDoc, initialCrdtState, this.logger);
180
- crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
181
- this.fields.set(key, crdtField);
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
- this.logger.info(
184
- { key, hasInitialState: !!initialCrdtState, hasFallback: !!fallbackText, Category: 'sp00ky-client::CrdtManager::open' },
185
- 'CrdtField opened'
186
- );
348
+ // --- receiving ---------------------------------------------------
187
349
 
188
- // Fire-and-forget: the LIVE subscription receives *future* updates;
189
- // the initial snapshot is already in hand. `ensureTableSubscription`
190
- // coalesces concurrent calls via `pendingLive`, so this is safe.
191
- void this.ensureTableSubscription(table);
192
-
193
- // Local was empty — a fresh device, a memory-backed local DB after
194
- // reload, or a record that hasn't been sync'd locally yet. Pull the
195
- // parent row from remote and dispatch its CRDT field; otherwise the
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
- return crdtField;
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
- close(table: string, recordId: string, field: string): void {
205
- const key = this.makeKey(table, recordId, field);
206
- const crdtField = this.fields.get(key);
207
- if (crdtField) {
208
- crdtField.stopSync();
209
- this.fields.delete(key);
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
- // If no fields on this table remain open, tear down the table-wide LIVE.
213
- if (!this.hasOpenFieldFor(table)) {
214
- this.killTableSubscription(table);
215
- // Also drop any pending post-reconnect restart for it.
216
- this.staleTables.delete(table);
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
- this.logger.debug(
220
- { key, Category: 'sp00ky-client::CrdtManager::close' },
221
- 'CrdtField closed'
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
- * Close every open field + table LIVE. Fields flush a final remote push by
227
- * default; pass `{ flush: false }` on a bucket switch, where that flush
228
- * would push the previous user's snapshot under the next user's session.
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
- closeAll(options: { flush?: boolean } = {}): void {
231
- for (const [_, field] of this.fields) {
232
- field.stopSync(options);
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
- this.fields.clear();
235
- for (const table of Array.from(this.liveByTable.keys())) {
236
- this.killTableSubscription(table);
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
- // Nothing is open any more, so nothing should be resurrected on the next
239
- // reconnect; `open()` will re-subscribe on demand.
240
- this.staleTables.clear();
241
- }
242
-
243
- /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
244
- * every open CrdtField on `table`. */
245
- private async ensureTableSubscription(table: string): Promise<void> {
246
- if (this.liveByTable.has(table)) return;
247
-
248
- // Registering a LIVE takes two round-trips, so a socket drop can land
249
- // mid-flight. The generation stamp is what keeps that honest: a registration
250
- // that started before the drop must never record its uuid, because this
251
- // method short-circuits on `liveByTable` and that stale entry (pointing at a
252
- // session the server has forgotten) would block every future restart.
253
- const generation = this.connectionGeneration;
254
-
255
- const pending = this.pendingLive.get(table);
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
- const start = (async () => {
270
- try {
271
- const [uuid] = await this.remote.query<[Uuid]>(
272
- `LIVE SELECT * FROM ${table}`,
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
- if (generation !== this.connectionGeneration) {
276
- this.logger.debug(
277
- { table, Category: 'sp00ky-client::CrdtManager::ensureTableSubscription' },
278
- 'Socket dropped while registering LIVE; discarding it'
279
- );
280
- return;
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
- const subscription = await this.remote.getClient().liveOf(uuid);
284
- subscription.subscribe((message) => {
285
- if (message.action === 'KILLED') return;
286
- if (message.action !== 'CREATE' && message.action !== 'UPDATE') return;
287
- this.dispatchRow(table, message.value as Record<string, unknown>);
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
- { error: e, table, Category: 'sp00ky-client::CrdtManager::ensureTableSubscription' },
300
- 'Failed to start LIVE SELECT'
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
- /** Apply a parent-row payload from a non-LIVE source (e.g. the
314
- * list_ref-driven sync engine, when the cross-user LIVE on the
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
- /** Dispatch a parent-row LIVE event to every open CrdtField on that
322
- * record. Each open field reads its slice of the row directly — the
323
- * CRDT snapshot is a column on the parent now, so there is no
324
- * follow-up subquery. */
325
- private dispatchRow(table: string, row: Record<string, unknown>): void {
326
- const id = row.id != null ? String(row.id) : '';
327
- if (!id) return;
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
- /** One-shot remote fetch for a row whose CRDT field hasn't synced
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
- const recordId = parseRecordIdString(id);
359
- const [row] = await this.remote.query<[Record<string, unknown> | null]>(
360
- `SELECT * FROM ONLY $id`,
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
- this.logger.warn(
367
- { error: e, table, id, Category: 'sp00ky-client::CrdtManager::fetchAndDispatchRow' },
368
- 'Failed to fetch parent row for CRDT hydration'
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
- /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
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
- /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
381
- * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
382
- * `{ state, cursors }` where `state` carries the snapshot bytes. */
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
- if (cursorsEnabled) {
405
- if (value && typeof value === 'object' && !(value instanceof Uint8Array)) {
406
- return asBytes((value as { state?: unknown }).state);
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
- return undefined;
595
+ } catch (e) {
596
+ this.logger.debug({ error: String(e), Category: 'sp00ky-client::CrdtManager::readLocal' }, 'No local CRDT state');
409
597
  }
410
- return asBytes(value);
598
+ return { rev: 0, peers: {} };
411
599
  }
412
600
 
413
- private killTableSubscription(table: string): void {
414
- const uuid = this.liveByTable.get(table);
415
- if (uuid) {
416
- // We're tearing down: KILL on an already-dead/closed LIVE throws, and the
417
- // subscription may have ended on its own, so the failure is expected and
418
- // safe to drop.
419
- this.remote.query('KILL $uuid', { uuid }).catch((err) => {
420
- this.logger.debug(
421
- { err, table, Category: 'sp00ky-client::CrdtManager::killTableSubscription' },
422
- 'KILL of table LIVE failed (already closed?)'
423
- );
424
- });
425
- this.liveByTable.delete(table);
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
- private makeKey(table: string, recordId: string, field: string): string {
430
- return `${table}:${recordId}:${field}`;
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
- * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
435
- * typos, removed annotations, and stale schema codegen at the call site instead
436
- * of silently producing a non-CRDT writer.
437
- */
438
- private assertCrdtField(table: string, field: string): void {
439
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
440
- throw new Error(
441
- `openCrdtField: refusing unsafe field identifier '${field}' — must match [a-zA-Z_][a-zA-Z0-9_]*`
442
- );
443
- }
444
- const tableSchema = this.schema.tables.find((t) => t.name === table);
445
- if (!tableSchema) {
446
- throw new Error(
447
- `openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(', ')}`
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
- const column = tableSchema.columns[field];
451
- if (!column) {
452
- throw new Error(
453
- `openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(', ')}`
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
- if (!column.crdt) {
457
- throw new Error(
458
- `openCrdtField: '${table}.${field}' is not annotated '@crdt' in the schema. ` +
459
- `Add '-- @crdt text' above the field's DEFINE FIELD and regenerate the client schema.`
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 };