@spooky-sync/core 0.0.1-canary.66 → 0.0.1-canary.68

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as Logger$1, S as UpdateOptions, T as EventSystem, _ as RunOptions, a as MutationEvent, b as Sp00kyQueryResultPromise, c as PinoTransmit, d as QueryHash, f as QueryState, g as RecordVersionDiff, h as RecordVersionArray, i as MutationCallback, l as QueryConfig, m as QueryUpdateCallback, n as EventSubscriptionOptions, o as MutationEventType, p as QueryTimeToLive, r as Level, s as PersistenceClient, t as DebounceOptions, u as QueryConfigRecord, v as Sp00kyConfig, w as EventDefinition, x as StoreType, y as Sp00kyQueryResult } from "./types.js";
1
+ import { C as UpdateOptions, E as EventSystem, S as StoreType, T as EventDefinition, _ as RecordVersionDiff, a as MutationCallback, b as Sp00kyQueryResult, c as PersistenceClient, d as QueryConfigRecord, f as QueryHash, g as RecordVersionArray, h as QueryUpdateCallback, i as MATERIALIZATION_SAMPLE_WINDOW, l as PinoTransmit, m as QueryTimeToLive, n as EventSubscriptionOptions, o as MutationEvent, p as QueryState, r as Level, s as MutationEventType, t as DebounceOptions, u as QueryConfig, v as RunOptions, w as Logger$1, x as Sp00kyQueryResultPromise, y as Sp00kyConfig } from "./types.js";
2
2
  import * as surrealdb0 from "surrealdb";
3
3
  import { Duration, RecordId, Surreal as Surreal$1, SurrealTransaction } from "surrealdb";
4
4
  import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, GetTable, QueryBuilder, QueryOptions, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
@@ -97,6 +97,12 @@ interface StreamUpdate {
97
97
  queryHash: string;
98
98
  localArray: RecordVersionArray;
99
99
  op?: 'CREATE' | 'UPDATE' | 'DELETE';
100
+ /**
101
+ * End-to-end ingest latency for the WASM call that produced this update,
102
+ * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
103
+ * for the initial register_view snapshot.
104
+ */
105
+ materializationTimeMs?: number;
100
106
  }
101
107
  type StreamProcessorEvents = {
102
108
  stream_update: EventDefinition<'stream_update', StreamUpdate[]>;
@@ -202,33 +208,55 @@ declare class CrdtField {
202
208
  private fieldName;
203
209
  private doc;
204
210
  private pushTimer;
211
+ private local;
205
212
  private remote;
206
213
  private recordId;
214
+ private sessionId;
207
215
  private unsubscribe;
208
216
  private lastPushTime;
209
217
  private lastCursorPushTime;
210
218
  private loadedFromCrdt;
211
219
  private pushRetryCount;
212
220
  private logger;
221
+ private cursorsEnabled;
222
+ /** Remote-push debounce. Local writes happen immediately on every Loro
223
+ * update; the remote UPSERT is coalesced over this window. Configured
224
+ * via `Sp00kyConfig.crdtDebounceMs`, default 500. */
225
+ private remoteDebounceMs;
213
226
  private _onCursorUpdate;
214
227
  private pendingCursorUpdate;
215
228
  /** Callback set by the editor to receive remote cursor updates.
216
229
  * Any cursor data that arrived before this callback was set will be replayed. */
217
230
  set onCursorUpdate(cb: ((data: Uint8Array) => void) | null);
218
231
  get onCursorUpdate(): ((data: Uint8Array) => void) | null;
219
- constructor(fieldName: string, initialState?: string, logger?: Logger$1 | null);
232
+ constructor(fieldName: string, cursorsEnabled: boolean, initialState?: Uint8Array, logger?: Logger$1 | null);
220
233
  getDoc(): LoroDoc;
221
234
  /** Whether the LoroDoc was loaded from saved CRDT state */
222
235
  hasContent(): boolean;
223
- startSync(remote: RemoteDatabaseService, recordId: string): void;
236
+ startSync(local: LocalDatabaseService, remote: RemoteDatabaseService, recordId: string, sessionId: string, debounceMs: number): void;
224
237
  stopSync(): void;
225
- importRemote(base64State: string): void;
226
- exportSnapshot(): string;
227
- /** Push cursor ephemeral state to _00_crdt as a "_cursor_<fieldName>" entry */
238
+ importRemote(state: Uint8Array): void;
239
+ exportSnapshot(): Uint8Array;
240
+ /** Push this session's cursor blob into the parent row at
241
+ * `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
242
+ * field — the editor still calls this method optimistically, but
243
+ * without `@cursor` on the schema there's nowhere to store the blob.
244
+ * The UPDATE itself fires the parent table's LIVE feed, so other
245
+ * browsers receive the cursor change without a separate `_00_rv` bump. */
228
246
  pushCursorState(encoded: Uint8Array): Promise<void>;
229
247
  /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
230
248
  importRemoteCursor(base64State: string): void;
231
- private schedulePush;
249
+ private scheduleRemotePush;
250
+ /** SET path inside a parent row for the current snapshot. `@crdt`-only
251
+ * fields hold the snapshot directly (`<field>`); `@crdt @cursor`
252
+ * fields hold a `{ state, cursors }` object so the snapshot lives at
253
+ * `<field>.state` next to per-session cursor blobs. */
254
+ private statePath;
255
+ /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
256
+ * every local update and every remote import so reloads (online or
257
+ * offline) see the freshest content immediately. Failures are
258
+ * swallowed — a stale local write must never block user input. */
259
+ private persistLocal;
232
260
  private pushToRemote;
233
261
  }
234
262
  //#endregion
@@ -236,16 +264,40 @@ declare class CrdtField {
236
264
  /**
237
265
  * CrdtManager manages active CrdtField instances and their sync channels.
238
266
  *
239
- * Each open record gets a LIVE SELECT on _00_crdt that delivers remote
240
- * changes in real time.
267
+ * Collaborative state lives in two dedicated tables (defined in
268
+ * `apps/cli/src/meta_tables_remote.surql`):
269
+ * - `_00_crdt` { record_id, field, state } — one row per (record, field)
270
+ * - `_00_cursor` { record_id, session_id, field, state } — one row per
271
+ * (record, session, field)
272
+ *
273
+ * Splitting them off the parent row is what makes offline edits mergeable:
274
+ * each (record, field) gets its own row, so concurrent offline writes don't
275
+ * collide on the parent's last-write-wins semantics.
276
+ *
277
+ * Cross-browser delivery still rides the parent table's existing LIVE feed
278
+ * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
279
+ * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
280
+ * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
281
+ * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
282
+ * via subquery. Permission inheritance happens server-side via
283
+ * `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
241
284
  */
242
285
  declare class CrdtManager {
243
286
  private schema;
287
+ private local;
244
288
  private remote;
289
+ private debounceMs;
245
290
  private fields;
246
- private liveQueries;
291
+ private liveByTable;
292
+ private pendingLive;
247
293
  private logger;
248
- constructor(schema: SchemaStructure, remote: RemoteDatabaseService, logger: Logger$1);
294
+ private sessionId;
295
+ constructor(schema: SchemaStructure, local: LocalDatabaseService, remote: RemoteDatabaseService, logger: Logger$1, debounceMs?: number);
296
+ /** Set the session id that scopes this client's cursor entries. Must be
297
+ * called before `open()` for cursors to be pushed under a stable key.
298
+ * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
299
+ * for the data-module salt). */
300
+ setSessionId(sessionId: string): void;
249
301
  /**
250
302
  * Open a CRDT field for collaborative editing.
251
303
  *
@@ -258,8 +310,32 @@ declare class CrdtManager {
258
310
  open(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField>;
259
311
  close(table: string, recordId: string, field: string): void;
260
312
  closeAll(): void;
261
- private ensureLiveSelect;
262
- private killLiveSelect;
313
+ /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
314
+ * every open CrdtField on `table`. */
315
+ private ensureTableSubscription;
316
+ /** Apply a parent-row payload from a non-LIVE source (e.g. the
317
+ * list_ref-driven sync engine, when the cross-user LIVE on the
318
+ * parent table is filtered out by the SurrealDB cross-session
319
+ * permission gap). Same semantics as the internal `dispatchRow`. */
320
+ applyRow(table: string, row: Record<string, unknown>): void;
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;
326
+ /** One-shot remote fetch for a row whose CRDT field hasn't synced
327
+ * locally yet (fresh device, memory-backed local DB after reload, …).
328
+ * Used by `open()` when the local read came up empty. Subsequent
329
+ * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
330
+ private fetchAndDispatchRow;
331
+ /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
332
+ * Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
333
+ private fieldHasCursor;
334
+ /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
335
+ * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
336
+ * `{ state, cursors }` where `state` carries the snapshot bytes. */
337
+ private extractSnapshot;
338
+ private killTableSubscription;
263
339
  private makeKey;
264
340
  /**
265
341
  * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
@@ -300,6 +376,12 @@ declare class Sp00kyClient<S extends SchemaStructure> {
300
376
  get remoteClient(): surrealdb0.Surreal;
301
377
  get localClient(): surrealdb0.Surreal;
302
378
  get pendingMutationCount(): number;
379
+ /** Number of times the initial list_ref LIVE subscription retried on
380
+ * the most recent `setCurrentUserId` call. 0 when the SSP's
381
+ * pre-emptive user-table creation got there first; >0 when LIVE
382
+ * registration hit a "table not found" race. Exposed so the e2e
383
+ * suite can guard the pre-emptive path against regression. */
384
+ get liveRetryCount(): number;
303
385
  subscribeToPendingMutations(cb: (count: number) => void): () => void;
304
386
  constructor(config: Sp00kyConfig<S>);
305
387
  /**
@@ -312,7 +394,8 @@ declare class Sp00kyClient<S extends SchemaStructure> {
312
394
  /**
313
395
  * Open a CRDT field for collaborative editing.
314
396
  * Returns a CrdtField with a LoroDoc that can be bound to any editor.
315
- * Also starts a LIVE SELECT on _00_crdt for real-time sync.
397
+ * Also starts a LIVE SELECT on the parent table for real-time sync;
398
+ * incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
316
399
  */
317
400
  openCrdtField(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField>;
318
401
  /**
@@ -334,8 +417,13 @@ declare class Sp00kyClient<S extends SchemaStructure> {
334
417
  }>;
335
418
  delete(table: string, id: string): Promise<void>;
336
419
  useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
337
- private persistClientId;
338
- private loadOrGenerateClientId;
420
+ /**
421
+ * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
422
+ * query-id hashing so two sessions for the same user get distinct
423
+ * `_00_query` rows. Returns empty string if the query fails (we still
424
+ * boot, just without session scoping for IDs).
425
+ */
426
+ private fetchSessionId;
339
427
  }
340
428
  //#endregion
341
429
  //#region src/utils/index.d.ts
@@ -350,4 +438,4 @@ declare function textToHtml(text: string): string;
350
438
  */
351
439
 
352
440
  //#endregion
353
- export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, Level, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryTimeToLive, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
441
+ export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PinoTransmit, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryTimeToLive, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };