@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 +105 -17
- package/dist/index.js +754 -133
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +29 -3
- package/package.json +2 -2
- package/src/modules/cache/index.ts +6 -1
- package/src/modules/crdt/crdt-field.ts +115 -23
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +207 -68
- package/src/modules/data/index.ts +170 -17
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +47 -11
- package/src/modules/sync/sync.ts +304 -13
- package/src/modules/sync/utils.test.ts +157 -0
- package/src/modules/sync/utils.ts +79 -0
- package/src/services/stream-processor/index.ts +25 -0
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +112 -31
- package/src/types.ts +29 -2
- package/src/utils/surql.ts +9 -0
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
53
|
-
|
|
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.
|
|
56
|
-
|
|
57
|
-
{
|
|
91
|
+
const [result] = await this.local.query<[unknown]>(
|
|
92
|
+
`SELECT VALUE ${field} FROM ONLY $id`,
|
|
93
|
+
{ id: parseRecordIdString(recordId) },
|
|
58
94
|
);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
95
|
+
const snapshot = this.extractSnapshot(result, cursorsEnabled);
|
|
96
|
+
if (snapshot) initialCrdtState = snapshot;
|
|
62
97
|
} catch (e) {
|
|
63
|
-
this.logger.
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
|
|
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
|
|
111
|
-
this.
|
|
155
|
+
for (const table of Array.from(this.liveByTable.keys())) {
|
|
156
|
+
this.killTableSubscription(table);
|
|
112
157
|
}
|
|
113
158
|
}
|
|
114
159
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,
|
|
163
|
-
'Failed to
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
722
|
-
id
|
|
723
|
-
|
|
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:
|
|
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
|
-
|
|
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));
|