@spooky-sync/core 0.0.1-canary.65 → 0.0.1-canary.67

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.
@@ -1,5 +1,5 @@
1
1
  import type { SchemaStructure } from '@spooky-sync/query-builder';
2
- import type { RemoteDatabaseService } from '../../services/database/index';
2
+ import type { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
3
3
  import type { Logger } from '../../services/logger/index';
4
4
  import type { Uuid } from 'surrealdb';
5
5
  import { CrdtField } from './crdt-field';
@@ -10,22 +10,52 @@ export { CrdtField, cursorColorFromName, CURSOR_COLORS } from './crdt-field';
10
10
  /**
11
11
  * CrdtManager manages active CrdtField instances and their sync channels.
12
12
  *
13
- * Each open record gets a LIVE SELECT on _00_crdt that delivers remote
14
- * changes in real time.
13
+ * Collaborative state lives in two dedicated tables (defined in
14
+ * `apps/cli/src/meta_tables_remote.surql`):
15
+ * - `_00_crdt` { record_id, field, state } — one row per (record, field)
16
+ * - `_00_cursor` { record_id, session_id, field, state } — one row per
17
+ * (record, session, field)
18
+ *
19
+ * Splitting them off the parent row is what makes offline edits mergeable:
20
+ * each (record, field) gets its own row, so concurrent offline writes don't
21
+ * collide on the parent's last-write-wins semantics.
22
+ *
23
+ * Cross-browser delivery still rides the parent table's existing LIVE feed
24
+ * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
25
+ * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
26
+ * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
27
+ * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
28
+ * via subquery. Permission inheritance happens server-side via
29
+ * `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
15
30
  */
16
31
  export class CrdtManager {
17
32
  private fields = new Map<string, CrdtField>();
18
- private liveQueries = new Map<string, { uuid: Uuid; table: string }>();
33
+ // One LIVE subscription per parent table (e.g. "thread" uuid).
34
+ private liveByTable = new Map<string, Uuid>();
35
+ // Coalesces concurrent first-time subscribes for the same table.
36
+ private pendingLive = new Map<string, Promise<void>>();
19
37
  private logger: Logger;
38
+ // SurrealDB session id, used as the per-session key inside `_00_cursor`.
39
+ private sessionId: string = '';
20
40
 
21
41
  constructor(
22
42
  private schema: SchemaStructure,
43
+ private local: LocalDatabaseService,
23
44
  private remote: RemoteDatabaseService,
24
- logger: Logger
45
+ logger: Logger,
46
+ private debounceMs: number = 500,
25
47
  ) {
26
48
  this.logger = logger.child({ service: 'CrdtManager' });
27
49
  }
28
50
 
51
+ /** Set the session id that scopes this client's cursor entries. Must be
52
+ * called before `open()` for cursors to be pushed under a stable key.
53
+ * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
54
+ * for the data-module salt). */
55
+ setSessionId(sessionId: string): void {
56
+ this.sessionId = sessionId;
57
+ }
58
+
29
59
  /**
30
60
  * Open a CRDT field for collaborative editing.
31
61
  *
@@ -42,6 +72,7 @@ export class CrdtManager {
42
72
  fallbackText?: string,
43
73
  ): Promise<CrdtField> {
44
74
  this.assertCrdtField(table, field);
75
+ const cursorsEnabled = this.fieldHasCursor(table, field);
45
76
  const key = this.makeKey(table, recordId, field);
46
77
  let crdtField = this.fields.get(key);
47
78
 
@@ -49,25 +80,29 @@ export class CrdtManager {
49
80
  return crdtField;
50
81
  }
51
82
 
52
- // Load saved CRDT state from remote _00_crdt table
53
- let initialCrdtState: string | undefined;
83
+ // Read the snapshot directly off the parent row. `@crdt`-only fields
84
+ // hold the base64 snapshot inline; `@crdt @cursor` fields hold a
85
+ // `{ state, cursors }` object so we drill into `.state`. The query
86
+ // always selects the local row — sync-down already populated it
87
+ // (the snapshot is a column on the parent, not a sidecar table) so
88
+ // there is no separate fetch on the happy path.
89
+ let initialCrdtState: Uint8Array | undefined;
54
90
  try {
55
- const [result] = await this.remote.query<[string[]]>(
56
- 'SELECT VALUE state FROM _00_crdt WHERE record_id = $rid AND field = $field LIMIT 1',
57
- { rid: parseRecordIdString(recordId), field }
91
+ const [result] = await this.local.query<[unknown]>(
92
+ `SELECT VALUE ${field} FROM ONLY $id`,
93
+ { id: parseRecordIdString(recordId) },
58
94
  );
59
- if (result && result.length > 0 && result[0]) {
60
- initialCrdtState = result[0];
61
- }
95
+ const snapshot = this.extractSnapshot(result, cursorsEnabled);
96
+ if (snapshot) initialCrdtState = snapshot;
62
97
  } catch (e) {
63
- this.logger.debug(
64
- { error: e, Category: 'sp00ky-client::CrdtManager::open' },
65
- 'No existing CRDT state found'
98
+ this.logger.info(
99
+ { error: String(e), recordId, field, Category: 'sp00ky-client::CrdtManager::open' },
100
+ 'No existing CRDT state found in local cache (continuing with empty doc)'
66
101
  );
67
102
  }
68
103
 
69
- crdtField = new CrdtField(field, initialCrdtState, this.logger);
70
- crdtField.startSync(this.remote, recordId);
104
+ crdtField = new CrdtField(field, cursorsEnabled, initialCrdtState, this.logger);
105
+ crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
71
106
  this.fields.set(key, crdtField);
72
107
 
73
108
  this.logger.info(
@@ -75,7 +110,18 @@ export class CrdtManager {
75
110
  'CrdtField opened'
76
111
  );
77
112
 
78
- await this.ensureLiveSelect(table, recordId);
113
+ // Fire-and-forget: the LIVE subscription receives *future* updates;
114
+ // the initial snapshot is already in hand. `ensureTableSubscription`
115
+ // coalesces concurrent calls via `pendingLive`, so this is safe.
116
+ void this.ensureTableSubscription(table);
117
+
118
+ // Local was empty — a fresh device, a memory-backed local DB after
119
+ // reload, or a record that hasn't been sync'd locally yet. Pull the
120
+ // parent row from remote and dispatch its CRDT field; otherwise the
121
+ // editor sits empty until the parent's LIVE feed happens to fire.
122
+ if (!initialCrdtState) {
123
+ void this.fetchAndDispatchRow(table, recordId);
124
+ }
79
125
 
80
126
  return crdtField;
81
127
  }
@@ -88,12 +134,11 @@ export class CrdtManager {
88
134
  this.fields.delete(key);
89
135
  }
90
136
 
91
- const prefix = `${table}:${recordId}:`;
92
- const hasOtherFields = Array.from(this.fields.keys()).some(
93
- (k) => k !== key && k.startsWith(prefix)
94
- );
95
- if (!hasOtherFields) {
96
- this.killLiveSelect(recordId);
137
+ // If no fields on this table remain open, tear down the table-wide LIVE.
138
+ const tablePrefix = `${table}:`;
139
+ const stillOpen = Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix));
140
+ if (!stillOpen) {
141
+ this.killTableSubscription(table);
97
142
  }
98
143
 
99
144
  this.logger.debug(
@@ -107,69 +152,158 @@ export class CrdtManager {
107
152
  field.stopSync();
108
153
  }
109
154
  this.fields.clear();
110
- for (const recordId of this.liveQueries.keys()) {
111
- this.killLiveSelect(recordId);
155
+ for (const table of Array.from(this.liveByTable.keys())) {
156
+ this.killTableSubscription(table);
112
157
  }
113
158
  }
114
159
 
115
- private async ensureLiveSelect(table: string, recordId: string): Promise<void> {
116
- if (this.liveQueries.has(recordId)) return;
160
+ /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
161
+ * every open CrdtField on `table`. */
162
+ private async ensureTableSubscription(table: string): Promise<void> {
163
+ if (this.liveByTable.has(table)) return;
117
164
 
118
- try {
119
- const [uuid] = await this.remote.query<[Uuid]>(
120
- `LIVE SELECT * FROM _00_crdt WHERE record_id = $rid`,
121
- { rid: parseRecordIdString(recordId) },
122
- );
165
+ const pending = this.pendingLive.get(table);
166
+ if (pending) return pending;
167
+
168
+ const start = (async () => {
169
+ try {
170
+ const [uuid] = await this.remote.query<[Uuid]>(
171
+ `LIVE SELECT * FROM ${table}`,
172
+ );
123
173
 
124
- this.liveQueries.set(recordId, { uuid, table });
174
+ const subscription = await this.remote.getClient().liveOf(uuid);
175
+ subscription.subscribe((message) => {
176
+ if (message.action === 'KILLED') return;
177
+ if (message.action !== 'CREATE' && message.action !== 'UPDATE') return;
178
+ this.dispatchRow(table, message.value as Record<string, unknown>);
179
+ });
125
180
 
126
- const subscription = await this.remote.getClient().liveOf(uuid);
127
- subscription.subscribe((message) => {
128
- if (message.action === 'KILLED') return;
181
+ this.liveByTable.set(table, uuid);
182
+ this.logger.info(
183
+ { table, Category: 'sp00ky-client::CrdtManager::ensureTableSubscription' },
184
+ 'LIVE SELECT started'
185
+ );
186
+ } catch (e) {
187
+ this.logger.warn(
188
+ { error: e, table, Category: 'sp00ky-client::CrdtManager::ensureTableSubscription' },
189
+ 'Failed to start LIVE SELECT'
190
+ );
191
+ }
192
+ })();
193
+
194
+ this.pendingLive.set(table, start);
195
+ try {
196
+ await start;
197
+ } finally {
198
+ this.pendingLive.delete(table);
199
+ }
200
+ }
129
201
 
130
- if (message.action === 'CREATE' || message.action === 'UPDATE') {
131
- const fieldName = message.value.field as string;
132
- const state = message.value.state as string;
202
+ /** Apply a parent-row payload from a non-LIVE source (e.g. the
203
+ * list_ref-driven sync engine, when the cross-user LIVE on the
204
+ * parent table is filtered out by the SurrealDB cross-session
205
+ * permission gap). Same semantics as the internal `dispatchRow`. */
206
+ applyRow(table: string, row: Record<string, unknown>): void {
207
+ this.dispatchRow(table, row);
208
+ }
133
209
 
134
- if (!fieldName || !state) return;
210
+ /** Dispatch a parent-row LIVE event to every open CrdtField on that
211
+ * record. Each open field reads its slice of the row directly — the
212
+ * CRDT snapshot is a column on the parent now, so there is no
213
+ * follow-up subquery. */
214
+ private dispatchRow(table: string, row: Record<string, unknown>): void {
215
+ const id = row.id != null ? String(row.id) : '';
216
+ if (!id) return;
135
217
 
136
- // Route cursor updates to the corresponding CrdtField
137
- if (fieldName.startsWith('_cursor_')) {
138
- const actualField = fieldName.slice('_cursor_'.length);
139
- const key = this.makeKey(table, recordId, actualField);
140
- const crdtField = this.fields.get(key);
141
- if (crdtField) {
142
- crdtField.importRemoteCursor(state);
143
- }
144
- return;
145
- }
218
+ const rowKeyPrefix = `${table}:${id}:`;
219
+ for (const [key, crdtField] of this.fields) {
220
+ if (!key.startsWith(rowKeyPrefix)) continue;
221
+ const fieldName = key.slice(rowKeyPrefix.length);
222
+ const cursorsEnabled = this.fieldHasCursor(table, fieldName);
223
+ const slice = row[fieldName];
224
+ const snapshot = this.extractSnapshot(slice, cursorsEnabled);
225
+ if (snapshot) crdtField.importRemote(snapshot);
146
226
 
147
- // Route document updates
148
- const key = this.makeKey(table, recordId, fieldName);
149
- const crdtField = this.fields.get(key);
150
- if (crdtField) {
151
- crdtField.importRemote(state);
227
+ if (cursorsEnabled && slice && typeof slice === 'object') {
228
+ const cursors = (slice as { cursors?: unknown }).cursors;
229
+ if (cursors && typeof cursors === 'object') {
230
+ for (const [sid, blob] of Object.entries(cursors as Record<string, unknown>)) {
231
+ if (sid === this.sessionId) continue;
232
+ if (typeof blob === 'string' && blob.length > 0) {
233
+ crdtField.importRemoteCursor(blob);
234
+ }
152
235
  }
153
236
  }
154
- });
237
+ }
238
+ }
239
+ }
155
240
 
156
- this.logger.info(
157
- { recordId, Category: 'sp00ky-client::CrdtManager::ensureLiveSelect' },
158
- 'LIVE SELECT on _00_crdt started'
241
+ /** One-shot remote fetch for a row whose CRDT field hasn't synced
242
+ * locally yet (fresh device, memory-backed local DB after reload, …).
243
+ * Used by `open()` when the local read came up empty. Subsequent
244
+ * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
245
+ private async fetchAndDispatchRow(table: string, id: string): Promise<void> {
246
+ try {
247
+ const recordId = parseRecordIdString(id);
248
+ const [row] = await this.remote.query<[Record<string, unknown> | null]>(
249
+ `SELECT * FROM ONLY $id`,
250
+ { id: recordId },
159
251
  );
252
+ if (!row || typeof row !== 'object') return;
253
+ this.dispatchRow(table, row as Record<string, unknown>);
160
254
  } catch (e) {
161
255
  this.logger.warn(
162
- { error: e, recordId, Category: 'sp00ky-client::CrdtManager::ensureLiveSelect' },
163
- 'Failed to start LIVE SELECT on _00_crdt'
256
+ { error: e, table, id, Category: 'sp00ky-client::CrdtManager::fetchAndDispatchRow' },
257
+ 'Failed to fetch parent row for CRDT hydration'
164
258
  );
165
259
  }
166
260
  }
167
261
 
168
- private killLiveSelect(recordId: string): void {
169
- const entry = this.liveQueries.get(recordId);
170
- if (entry) {
171
- this.remote.query('KILL $uuid', { uuid: entry.uuid }).catch(() => {});
172
- this.liveQueries.delete(recordId);
262
+ /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
263
+ * Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
264
+ private fieldHasCursor(table: string, field: string): boolean {
265
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
266
+ return !!tableSchema?.columns[field]?.cursor;
267
+ }
268
+
269
+ /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
270
+ * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
271
+ * `{ state, cursors }` where `state` carries the snapshot bytes. */
272
+ private extractSnapshot(value: unknown, cursorsEnabled: boolean): Uint8Array | undefined {
273
+ const asBytes = (v: unknown): Uint8Array | undefined => {
274
+ if (v instanceof Uint8Array) return v.length > 0 ? v : undefined;
275
+ // SurrealDB ferries bytes through several shapes depending on
276
+ // transport and on whether the field is a top-level `bytes` column
277
+ // or bytes nested inside a FLEXIBLE object. Round-tripping bytes
278
+ // through `option<object> FLEXIBLE` (the `@crdt @cursor` shape) in
279
+ // particular comes back as a plain `number[]` from the local WASM
280
+ // DB and as `Uint8Array` from the remote WS engine. Normalize all
281
+ // recognized variants here so the receiving CrdtField doesn't care.
282
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
283
+ if (ArrayBuffer.isView(v)) {
284
+ const view = v as ArrayBufferView;
285
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
286
+ }
287
+ if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === 'number')) {
288
+ return Uint8Array.from(v as number[]);
289
+ }
290
+ return undefined;
291
+ };
292
+
293
+ if (cursorsEnabled) {
294
+ if (value && typeof value === 'object' && !(value instanceof Uint8Array)) {
295
+ return asBytes((value as { state?: unknown }).state);
296
+ }
297
+ return undefined;
298
+ }
299
+ return asBytes(value);
300
+ }
301
+
302
+ private killTableSubscription(table: string): void {
303
+ const uuid = this.liveByTable.get(table);
304
+ if (uuid) {
305
+ this.remote.query('KILL $uuid', { uuid }).catch(() => {});
306
+ this.liveByTable.delete(table);
173
307
  }
174
308
  }
175
309
 
@@ -183,6 +317,11 @@ export class CrdtManager {
183
317
  * of silently producing a non-CRDT writer.
184
318
  */
185
319
  private assertCrdtField(table: string, field: string): void {
320
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
321
+ throw new Error(
322
+ `openCrdtField: refusing unsafe field identifier '${field}' — must match [a-zA-Z_][a-zA-Z0-9_]*`
323
+ );
324
+ }
186
325
  const tableSchema = this.schema.tables.find((t) => t.name === table);
187
326
  if (!tableSchema) {
188
327
  throw new Error(
@@ -21,6 +21,7 @@ import type {
21
21
  QueryConfigRecord,
22
22
  UpdateOptions,
23
23
  RunOptions} from '../../types';
24
+ import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
24
25
  import {
25
26
  parseRecordIdString,
26
27
  extractIdPart,
@@ -48,6 +49,17 @@ export class DataModule<S extends SchemaStructure> {
48
49
  private mutationCallbacks: Set<MutationCallback> = new Set();
49
50
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
50
51
  private logger: Logger;
52
+ // Salt for query-id hashing. Set from SurrealDB's session::id() so two
53
+ // browser sessions registering the same logical query (same surql + params)
54
+ // don't collide on the same `_00_query` row — each session gets its own.
55
+ // Empty string until init(sessionId) is called.
56
+ private sessionId: string = '';
57
+ // Authenticated user record id (e.g. `"user:abc"`). Updated by
58
+ // `setCurrentUserId` from the auth subscription. null when
59
+ // unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
60
+ // poll and LIVE subscription target the same per-user
61
+ // `_00_list_ref_user_<id>` table the sync engine writes to.
62
+ private currentUserId: string | null = null;
51
63
 
52
64
  constructor(
53
65
  private cache: CacheModule,
@@ -59,8 +71,38 @@ export class DataModule<S extends SchemaStructure> {
59
71
  this.logger = logger.child({ service: 'DataModule' });
60
72
  }
61
73
 
62
- async init(): Promise<void> {
63
- this.logger.info({ Category: 'sp00ky-client::DataModule::init' }, 'DataModule initialized');
74
+ async init(sessionId: string): Promise<void> {
75
+ this.sessionId = sessionId;
76
+ this.logger.info(
77
+ { sessionId, Category: 'sp00ky-client::DataModule::init' },
78
+ 'DataModule initialized'
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Update the session salt used in query-id hashing. Call this when the
84
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
85
+ * registered queries will get fresh, session-scoped IDs.
86
+ */
87
+ setSessionId(sessionId: string): void {
88
+ this.sessionId = sessionId;
89
+ }
90
+
91
+ /**
92
+ * Update the authenticated user record id. Pass `null` on sign-out.
93
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
94
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
95
+ * SSP writes to.
96
+ */
97
+ setCurrentUserId(userId: string | null): void {
98
+ this.currentUserId = userId;
99
+ }
100
+
101
+ /** Read-only view of the authenticated user id used for per-user
102
+ * `_00_list_ref` routing. Other modules consult this so they pick the
103
+ * same table name DataModule does. */
104
+ getCurrentUserId(): string | null {
105
+ return this.currentUserId;
64
106
  }
65
107
 
66
108
  // ==================== QUERY MANAGEMENT ====================
@@ -80,6 +122,8 @@ export class DataModule<S extends SchemaStructure> {
80
122
  'Query Initialization: started'
81
123
  );
82
124
 
125
+ // `_00_query` stays the single shared registration table in both
126
+ // ref-modes; the per-user split happens only on `_00_list_ref`.
83
127
  const recordId = new RecordId('_00_query', hash);
84
128
 
85
129
  if (this.activeQueries.has(hash)) {
@@ -189,7 +233,7 @@ export class DataModule<S extends SchemaStructure> {
189
233
  }
190
234
 
191
235
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
192
- const { queryHash, localArray } = update;
236
+ const { queryHash, localArray, materializationTimeMs } = update;
193
237
  const queryState = this.activeQueries.get(queryHash);
194
238
  if (!queryState) {
195
239
  this.logger.warn(
@@ -199,6 +243,18 @@ export class DataModule<S extends SchemaStructure> {
199
243
  return;
200
244
  }
201
245
 
246
+ // Update the rolling materialization-sample window before the work that
247
+ // could throw, so the percentiles still move when the downstream local
248
+ // query fails (the materialization step itself ran).
249
+ if (typeof materializationTimeMs === 'number') {
250
+ queryState.materializationSamples.push(materializationTimeMs);
251
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
252
+ queryState.materializationSamples.shift();
253
+ }
254
+ queryState.lastIngestLatencyMs = materializationTimeMs;
255
+ }
256
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
257
+
202
258
  try {
203
259
  // Fetch updated records
204
260
  const [records] = await this.local.query<[Record<string, any>[]]>(
@@ -209,16 +265,44 @@ export class DataModule<S extends SchemaStructure> {
209
265
  // Update state
210
266
  const newRecords = records || [];
211
267
  queryState.config.localArray = localArray;
212
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
213
- id: queryState.config.id,
214
- localArray,
215
- });
216
268
 
217
- // Skip notification if records haven't changed
218
269
  const prevJson = JSON.stringify(queryState.records);
219
270
  const newJson = JSON.stringify(newRecords);
220
271
  queryState.records = newRecords;
221
- if (prevJson === newJson) {
272
+ const recordsChanged = prevJson !== newJson;
273
+
274
+ // updateCount counts user-visible updates (matches the prior semantic),
275
+ // while the materialization sample/percentiles already moved above for
276
+ // every observed engine step.
277
+ if (recordsChanged) {
278
+ queryState.updateCount++;
279
+ }
280
+
281
+ await this.local.query(
282
+ surql.seal(
283
+ surql.updateSet('id', [
284
+ 'localArray',
285
+ 'rowCount',
286
+ 'updateCount',
287
+ 'lastIngestLatency',
288
+ 'materializationP55',
289
+ 'materializationP90',
290
+ 'materializationP99',
291
+ ])
292
+ ),
293
+ {
294
+ id: queryState.config.id,
295
+ localArray,
296
+ rowCount: localArray.length,
297
+ updateCount: queryState.updateCount,
298
+ lastIngestLatency: queryState.lastIngestLatencyMs,
299
+ materializationP55: percentiles.p55,
300
+ materializationP90: percentiles.p90,
301
+ materializationP99: percentiles.p99,
302
+ }
303
+ );
304
+
305
+ if (!recordsChanged) {
222
306
  this.logger.debug(
223
307
  { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
224
308
  'Query records unchanged, skipping notification'
@@ -226,8 +310,6 @@ export class DataModule<S extends SchemaStructure> {
226
310
  return;
227
311
  }
228
312
 
229
- queryState.updateCount++;
230
-
231
313
  // Notify subscribers
232
314
  const subscribers = this.subscriptions.get(queryHash);
233
315
  if (subscribers) {
@@ -245,11 +327,50 @@ export class DataModule<S extends SchemaStructure> {
245
327
  'Query updated from stream'
246
328
  );
247
329
  } catch (err) {
330
+ queryState.errorCount++;
248
331
  this.logger.error(
249
332
  { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
250
333
  'Failed to fetch records for stream update'
251
334
  );
335
+ // Best-effort persist of the bumped errorCount; swallow secondary
336
+ // failures to avoid masking the original error in logs.
337
+ try {
338
+ await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
339
+ id: queryState.config.id,
340
+ errorCount: queryState.errorCount,
341
+ });
342
+ } catch (persistErr) {
343
+ this.logger.warn(
344
+ {
345
+ err: persistErr,
346
+ queryHash,
347
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
348
+ },
349
+ 'Failed to persist incremented errorCount'
350
+ );
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
357
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
358
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
359
+ */
360
+ private computeMaterializationPercentiles(samples: number[]): {
361
+ p55: number | null;
362
+ p90: number | null;
363
+ p99: number | null;
364
+ } {
365
+ if (samples.length === 0) {
366
+ return { p55: null, p90: null, p99: null };
252
367
  }
368
+ const sorted = [...samples].sort((a, b) => a - b);
369
+ const pick = (q: number) => {
370
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
371
+ return sorted[idx]!;
372
+ };
373
+ return { p55: pick(0.55), p90: pick(0.90), p99: pick(0.99) };
253
374
  }
254
375
 
255
376
  /**
@@ -273,6 +394,10 @@ export class DataModule<S extends SchemaStructure> {
273
394
  return Array.from(this.activeQueries.values());
274
395
  }
275
396
 
397
+ getActiveQueryHashes(): QueryHash[] {
398
+ return Array.from(this.activeQueries.keys());
399
+ }
400
+
276
401
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
277
402
  const queryState = this.activeQueries.get(id);
278
403
  if (!queryState) {
@@ -709,6 +834,7 @@ export class DataModule<S extends SchemaStructure> {
709
834
  tableName,
710
835
  });
711
836
 
837
+ const t0 = performance.now();
712
838
  const { localArray } = this.cache.registerQuery({
713
839
  queryHash: hash,
714
840
  surql: surqlString,
@@ -716,12 +842,18 @@ export class DataModule<S extends SchemaStructure> {
716
842
  ttl: new Duration(ttl),
717
843
  lastActiveAt: new Date(),
718
844
  });
845
+ const registrationTime = performance.now() - t0;
719
846
 
720
847
  await withRetry(this.logger, () =>
721
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
722
- id: recordId,
723
- localArray,
724
- })
848
+ this.local.query(
849
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
850
+ {
851
+ id: recordId,
852
+ localArray,
853
+ registrationTime,
854
+ rowCount: localArray.length,
855
+ }
856
+ )
725
857
  );
726
858
 
727
859
  this.activeQueries.set(hash, queryState);
@@ -773,8 +905,12 @@ export class DataModule<S extends SchemaStructure> {
773
905
  localArray: [],
774
906
  remoteArray: [],
775
907
  lastActiveAt: new Date(),
908
+ createdAt: new Date(),
776
909
  ttl,
777
910
  tableName,
911
+ updateCount: 0,
912
+ rowCount: 0,
913
+ errorCount: 0,
778
914
  },
779
915
  })
780
916
  );
@@ -798,17 +934,34 @@ export class DataModule<S extends SchemaStructure> {
798
934
  );
799
935
  }
800
936
 
937
+ // Persisted counters survive a restart even though the rolling
938
+ // sample window is rebuilt from scratch in memory.
939
+ const persistedUpdateCount =
940
+ typeof (configRecord as any)?.updateCount === 'number'
941
+ ? (configRecord as any).updateCount
942
+ : 0;
943
+ const persistedErrorCount =
944
+ typeof (configRecord as any)?.errorCount === 'number'
945
+ ? (configRecord as any).errorCount
946
+ : 0;
947
+
801
948
  return {
802
949
  config,
803
950
  records,
804
951
  ttlTimer: null,
805
952
  ttlDurationMs: parseDuration(ttl),
806
- updateCount: 0,
953
+ updateCount: persistedUpdateCount,
954
+ materializationSamples: [],
955
+ lastIngestLatencyMs: null,
956
+ errorCount: persistedErrorCount,
807
957
  };
808
958
  }
809
959
 
810
960
  private async calculateHash(data: any): Promise<string> {
811
- const content = JSON.stringify(data);
961
+ // sessionId is part of the hash so the same logical query from two
962
+ // sessions (e.g. two browser tabs of the same user) lands on different
963
+ // `_00_query` rows and doesn't fight over a shared one.
964
+ const content = JSON.stringify({ ...data, sessionId: this.sessionId });
812
965
  const msgBuffer = new TextEncoder().encode(content);
813
966
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
814
967
  const hashArray = Array.from(new Uint8Array(hashBuffer));