@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +299 -369
- package/dist/index.js +2275 -399
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +460 -0
- package/package.json +37 -7
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +6 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +3 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +17 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +662 -108
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +77 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +97 -37
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +553 -58
- package/src/modules/sync/utils.test.ts +239 -2
- package/src/modules/sync/utils.ts +166 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +205 -36
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +582 -0
- package/src/types.ts +132 -13
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +55 -1
- package/src/spooky.ts +0 -392
package/src/sp00ky.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { DataModule } from './modules/data/index';
|
|
2
|
+
import type {
|
|
3
|
+
Sp00kyConfig,
|
|
4
|
+
QueryTimeToLive,
|
|
5
|
+
QueryStatusCallback,
|
|
6
|
+
Sp00kyQueryResultPromise,
|
|
7
|
+
PersistenceClient,
|
|
8
|
+
UpdateOptions,
|
|
9
|
+
RunOptions} from './types';
|
|
10
|
+
import {
|
|
11
|
+
LocalDatabaseService,
|
|
12
|
+
LocalMigrator,
|
|
13
|
+
RemoteDatabaseService,
|
|
14
|
+
} from './services/database/index';
|
|
15
|
+
import type { UpEvent } from './modules/sync/index';
|
|
16
|
+
import { Sp00kySync } from './modules/sync/index';
|
|
17
|
+
import type {
|
|
18
|
+
GetTable,
|
|
19
|
+
InnerQuery,
|
|
20
|
+
QueryOptions,
|
|
21
|
+
SchemaStructure,
|
|
22
|
+
TableModel,
|
|
23
|
+
TableNames,
|
|
24
|
+
BucketNames,
|
|
25
|
+
BackendNames,
|
|
26
|
+
BackendRoutes,
|
|
27
|
+
RoutePayload} from '@spooky-sync/query-builder';
|
|
28
|
+
import {
|
|
29
|
+
QueryBuilder
|
|
30
|
+
} from '@spooky-sync/query-builder';
|
|
31
|
+
|
|
32
|
+
import { DevToolsService } from './modules/devtools/index';
|
|
33
|
+
import { createLogger } from './services/logger/index';
|
|
34
|
+
import { AuthService } from './modules/auth/index';
|
|
35
|
+
import { StreamProcessorService } from './services/stream-processor/index';
|
|
36
|
+
import { extractSelectPermissions } from './services/stream-processor/permissions';
|
|
37
|
+
import { EventSystem } from './events/index';
|
|
38
|
+
import { CacheModule } from './modules/cache/index';
|
|
39
|
+
import type { RecordWithId } from './modules/cache/index';
|
|
40
|
+
import { CrdtManager, CrdtField } from './modules/crdt/index';
|
|
41
|
+
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
42
|
+
import { parseParams } from './utils/index';
|
|
43
|
+
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
44
|
+
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
45
|
+
|
|
46
|
+
export class BucketHandle {
|
|
47
|
+
constructor(private bucketName: string, private remote: RemoteDatabaseService) {}
|
|
48
|
+
|
|
49
|
+
async put(path: string, content: string | Uint8Array | Blob): Promise<void> {
|
|
50
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async get(path: string): Promise<unknown> {
|
|
54
|
+
const [result] = await this.remote.query<[unknown]>(`RETURN f"${this.bucketName}:/${path}".get();`);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async delete(path: string): Promise<void> {
|
|
59
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async exists(path: string): Promise<boolean> {
|
|
63
|
+
const [result] = await this.remote.query<[boolean]>(`RETURN f"${this.bucketName}:/${path}".exists();`);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async head(path: string): Promise<Record<string, unknown>> {
|
|
68
|
+
const [result] = await this.remote.query<[Record<string, unknown>]>(`RETURN f"${this.bucketName}:/${path}".head();`);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async copy(sourcePath: string, targetPath: string): Promise<void> {
|
|
73
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async rename(sourcePath: string, targetPath: string): Promise<void> {
|
|
77
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async list(prefix?: string): Promise<string[]> {
|
|
81
|
+
const p = prefix ?? '';
|
|
82
|
+
const [result] = await this.remote.query<[string[]]>(`RETURN f"${this.bucketName}:/${p}".list();`);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class Sp00kyClient<S extends SchemaStructure> {
|
|
88
|
+
private local: LocalDatabaseService;
|
|
89
|
+
private remote: RemoteDatabaseService;
|
|
90
|
+
private persistenceClient: PersistenceClient;
|
|
91
|
+
|
|
92
|
+
private migrator: LocalMigrator;
|
|
93
|
+
private cache: CacheModule;
|
|
94
|
+
private dataModule: DataModule<S>;
|
|
95
|
+
private sync: Sp00kySync<S>;
|
|
96
|
+
private devTools: DevToolsService;
|
|
97
|
+
private crdtManager: CrdtManager;
|
|
98
|
+
|
|
99
|
+
private logger: ReturnType<typeof createLogger>;
|
|
100
|
+
public auth: AuthService<S>;
|
|
101
|
+
public streamProcessor: StreamProcessorService;
|
|
102
|
+
|
|
103
|
+
get remoteClient() {
|
|
104
|
+
return this.remote.getClient();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get localClient() {
|
|
108
|
+
return this.local.getClient();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get pendingMutationCount(): number {
|
|
112
|
+
return this.sync.pendingMutationCount;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Number of times the initial list_ref LIVE subscription retried on
|
|
116
|
+
* the most recent `setCurrentUserId` call. 0 when the SSP's
|
|
117
|
+
* pre-emptive user-table creation got there first; >0 when LIVE
|
|
118
|
+
* registration hit a "table not found" race. Exposed so the e2e
|
|
119
|
+
* suite can guard the pre-emptive path against regression. */
|
|
120
|
+
get liveRetryCount(): number {
|
|
121
|
+
return this.sync.liveRetryCount;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
125
|
+
return this.sync.subscribeToPendingMutations(cb);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
constructor(private config: Sp00kyConfig<S>) {
|
|
129
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
|
|
130
|
+
this.logger = logger.child({ service: 'Sp00kyClient' });
|
|
131
|
+
|
|
132
|
+
this.logger.info(
|
|
133
|
+
{
|
|
134
|
+
config: { ...config, schema: '[SchemaStructure]' },
|
|
135
|
+
Category: 'sp00ky-client::Sp00kyClient::constructor',
|
|
136
|
+
},
|
|
137
|
+
'Sp00kyClient initialized'
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
this.local = new LocalDatabaseService(this.config.database, logger);
|
|
141
|
+
this.remote = new RemoteDatabaseService(this.config.database, logger);
|
|
142
|
+
|
|
143
|
+
if (config.persistenceClient === 'surrealdb') {
|
|
144
|
+
this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
|
|
145
|
+
} else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) {
|
|
146
|
+
this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
147
|
+
} else {
|
|
148
|
+
this.persistenceClient = config.persistenceClient;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
152
|
+
|
|
153
|
+
this.streamProcessor = new StreamProcessorService(
|
|
154
|
+
new EventSystem(['stream_update']),
|
|
155
|
+
this.local,
|
|
156
|
+
this.persistenceClient,
|
|
157
|
+
logger
|
|
158
|
+
);
|
|
159
|
+
this.migrator = new LocalMigrator(this.local, logger);
|
|
160
|
+
|
|
161
|
+
this.cache = new CacheModule(
|
|
162
|
+
this.local,
|
|
163
|
+
this.streamProcessor,
|
|
164
|
+
(update) => {
|
|
165
|
+
// Direct callback from cache to data module
|
|
166
|
+
this.dataModule.onStreamUpdate(update);
|
|
167
|
+
},
|
|
168
|
+
logger
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Initialize CRDT Manager. `local` is used to read the initial
|
|
172
|
+
// `_00_crdt` snapshot when a field opens AND to mirror every local
|
|
173
|
+
// edit (so reload/offline see the freshest state); `remote` is used
|
|
174
|
+
// for the debounced outgoing UPSERTs and the parent-table LIVE feed.
|
|
175
|
+
// The debounce window is configurable via `crdtDebounceMs`.
|
|
176
|
+
this.crdtManager = new CrdtManager(
|
|
177
|
+
this.config.schema,
|
|
178
|
+
this.local,
|
|
179
|
+
this.remote,
|
|
180
|
+
logger,
|
|
181
|
+
config.crdtDebounceMs ?? 500,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
this.dataModule = new DataModule(
|
|
185
|
+
this.cache,
|
|
186
|
+
this.local,
|
|
187
|
+
this.config.schema,
|
|
188
|
+
logger,
|
|
189
|
+
this.config.streamDebounceTime
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Initialize Auth
|
|
193
|
+
this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
|
|
194
|
+
|
|
195
|
+
// Initialize Sync
|
|
196
|
+
this.sync = new Sp00kySync(
|
|
197
|
+
this.local,
|
|
198
|
+
this.remote,
|
|
199
|
+
this.cache,
|
|
200
|
+
this.dataModule,
|
|
201
|
+
this.config.schema,
|
|
202
|
+
this.logger,
|
|
203
|
+
{ refSyncIntervalMs: this.config.refSyncIntervalMs }
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Initialize DevTools
|
|
207
|
+
this.devTools = new DevToolsService(
|
|
208
|
+
this.local,
|
|
209
|
+
this.remote,
|
|
210
|
+
logger,
|
|
211
|
+
this.config.schema,
|
|
212
|
+
this.auth,
|
|
213
|
+
this.dataModule
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Register DevTools as a receiver for stream updates
|
|
217
|
+
this.streamProcessor.addReceiver(this.devTools);
|
|
218
|
+
|
|
219
|
+
// Wire up callbacks instead of events
|
|
220
|
+
this.setupCallbacks();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Setup direct callbacks instead of event subscriptions
|
|
225
|
+
*/
|
|
226
|
+
private setupCallbacks() {
|
|
227
|
+
// Surface query fetch-status changes (idle/fetching) in DevTools. Logs a
|
|
228
|
+
// discrete event and triggers a state push so the active-queries panel
|
|
229
|
+
// reflects the flip immediately.
|
|
230
|
+
this.dataModule.onQueryStatusChange = (queryHash, status) => {
|
|
231
|
+
this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash, status });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Keep an actively-watched query's remote `_00_query.lastActiveAt` fresh so
|
|
235
|
+
// the server TTL sweep doesn't expire it out from under live subscribers.
|
|
236
|
+
// DataModule fires this only while the query still has ≥1 subscriber.
|
|
237
|
+
this.dataModule.onHeartbeat = (queryHash) => {
|
|
238
|
+
void this.sync.heartbeatQuery(queryHash).catch((err) => {
|
|
239
|
+
this.logger.warn(
|
|
240
|
+
{ err, queryHash, Category: 'sp00ky-client::Sp00kyClient::onHeartbeat' },
|
|
241
|
+
'TTL heartbeat failed'
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Eager teardown of an opt-in deregistered query: enqueue a `cleanup`
|
|
247
|
+
// down-event so it's serialized after any in-flight register/sync for the
|
|
248
|
+
// same query (avoids out-of-order delete-before-create).
|
|
249
|
+
this.dataModule.onDeregister = (queryHash) => {
|
|
250
|
+
this.sync.enqueueDownEvent({ type: 'cleanup', payload: { hash: queryHash } });
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Mutation callback for sync
|
|
254
|
+
this.dataModule.onMutation((mutations: UpEvent[]) => {
|
|
255
|
+
// Notify DevTools
|
|
256
|
+
this.devTools.onMutation(mutations);
|
|
257
|
+
|
|
258
|
+
// Enqueue in Sync
|
|
259
|
+
if (mutations.length > 0) {
|
|
260
|
+
this.sync.enqueueMutation(mutations);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Sync events for incoming updates
|
|
265
|
+
this.sync.events.subscribe('SYNC_QUERY_UPDATED', (event: any) => {
|
|
266
|
+
this.devTools.logEvent('SYNC_QUERY_UPDATED', event.payload);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Hand list_ref-driven row ingests to the CrdtManager so CRDT body
|
|
270
|
+
// / cursor updates reach the receiver even when the cross-session
|
|
271
|
+
// LIVE on the parent table is filtered out by the SurrealDB
|
|
272
|
+
// permission-LIVE gap. Same-user clients receive these rows via
|
|
273
|
+
// CrdtManager's own `LIVE SELECT * FROM <table>`; this hook is the
|
|
274
|
+
// redundant path that fires when only the list_ref bumped.
|
|
275
|
+
this.sync.engineEvents.subscribe('SYNC_REMOTE_DATA_INGESTED', (event: any) => {
|
|
276
|
+
try {
|
|
277
|
+
const records: Array<Record<string, any>> = event.payload?.records ?? [];
|
|
278
|
+
for (const row of records) {
|
|
279
|
+
const id = row?.id;
|
|
280
|
+
const table =
|
|
281
|
+
id && typeof id === 'object' && id.table !== undefined
|
|
282
|
+
? String(id.table)
|
|
283
|
+
: undefined;
|
|
284
|
+
if (!table) continue;
|
|
285
|
+
this.crdtManager.applyRow(table, row);
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
this.logger.debug(
|
|
289
|
+
{ err, Category: 'sp00ky-client::engineEvents::ingested' },
|
|
290
|
+
'applyRow forwarding from sync ingest failed'
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Database events for DevTools
|
|
296
|
+
this.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => {
|
|
297
|
+
this.devTools.logEvent('LOCAL_QUERY', event.payload);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
this.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => {
|
|
301
|
+
this.devTools.logEvent('REMOTE_QUERY', event.payload);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async init() {
|
|
306
|
+
this.logger.info(
|
|
307
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
308
|
+
'Sp00kyClient initialization started'
|
|
309
|
+
);
|
|
310
|
+
try {
|
|
311
|
+
await this.local.connect();
|
|
312
|
+
this.logger.debug(
|
|
313
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
314
|
+
'Local database connected'
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
318
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Schema provisioned');
|
|
319
|
+
|
|
320
|
+
await this.remote.connect();
|
|
321
|
+
this.logger.debug(
|
|
322
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
323
|
+
'Remote database connected'
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
await this.streamProcessor.init();
|
|
327
|
+
// Seed table `select` permissions from the schema before any query is
|
|
328
|
+
// registered — otherwise the SSP default-denies every non-`_00_` table.
|
|
329
|
+
this.streamProcessor.setPermissions(
|
|
330
|
+
extractSelectPermissions(this.config.schemaSurql)
|
|
331
|
+
);
|
|
332
|
+
this.logger.debug(
|
|
333
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
334
|
+
'StreamProcessor initialized'
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await this.auth.init();
|
|
338
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Auth initialized');
|
|
339
|
+
|
|
340
|
+
// Salt query-id hashing with the SurrealDB session id so two browsers
|
|
341
|
+
// for the same user don't collide on shared `_00_query` rows. The same
|
|
342
|
+
// session id is the `session_id` key in `_00_cursor` rows, so the
|
|
343
|
+
// CrdtManager needs it too.
|
|
344
|
+
const sessionId = await this.fetchSessionId();
|
|
345
|
+
await this.dataModule.init(sessionId);
|
|
346
|
+
this.crdtManager.setSessionId(sessionId);
|
|
347
|
+
this.logger.debug(
|
|
348
|
+
{ sessionId, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
349
|
+
'DataModule initialized'
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// Refresh the salt whenever auth state flips (sign-in, sign-out).
|
|
353
|
+
// session::id() changes per WebSocket session, and a sign-in spawns
|
|
354
|
+
// a new authenticated session, so the salt must follow. Also
|
|
355
|
+
// forward the user id into `DataModule` and `Sp00kySync` so they
|
|
356
|
+
// can route to per-user `_00_query_user_<id>` /
|
|
357
|
+
// `_00_list_ref_user_<id>` tables in `RefMode.Dedicated` — the
|
|
358
|
+
// LIVE subscription on `_00_list_ref_user_<id>` is restarted
|
|
359
|
+
// under the new auth context inside `Sp00kySync.setCurrentUserId`
|
|
360
|
+
// since SurrealDB binds the LIVE permission at registration time.
|
|
361
|
+
//
|
|
362
|
+
// Sync prefix BEFORE the first `await`: setting `currentUserId`
|
|
363
|
+
// synchronously here is critical because the AuthProvider's own
|
|
364
|
+
// subscribe callback runs right after ours and immediately enables
|
|
365
|
+
// queries that depend on the user id. Any `await` before
|
|
366
|
+
// `setCurrentUserId` would let those queries register against the
|
|
367
|
+
// stale (null) user id and hit the wrong `_00_query[_user_*]`
|
|
368
|
+
// table.
|
|
369
|
+
this.auth.subscribe(async (userId) => {
|
|
370
|
+
this.dataModule.setCurrentUserId(userId);
|
|
371
|
+
const next = await this.fetchSessionId();
|
|
372
|
+
this.dataModule.setSessionId(next);
|
|
373
|
+
this.crdtManager.setSessionId(next);
|
|
374
|
+
try {
|
|
375
|
+
await this.sync.setCurrentUserId(userId);
|
|
376
|
+
} catch (e) {
|
|
377
|
+
this.logger.error(
|
|
378
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::authChange' },
|
|
379
|
+
'sync.setCurrentUserId failed'
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await this.sync.init();
|
|
385
|
+
this.logger.debug({ Category: 'sp00ky-client::Sp00kyClient::init' }, 'Sync initialized');
|
|
386
|
+
|
|
387
|
+
this.logger.info(
|
|
388
|
+
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
389
|
+
'Sp00kyClient initialization completed successfully'
|
|
390
|
+
);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
this.logger.error(
|
|
393
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
394
|
+
'Sp00kyClient initialization failed'
|
|
395
|
+
);
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async close() {
|
|
401
|
+
this.crdtManager.closeAll();
|
|
402
|
+
await this.local.close();
|
|
403
|
+
await this.remote.close();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
authenticate(token: string) {
|
|
407
|
+
return this.remote.getClient().authenticate(token);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Open a CRDT field for collaborative editing.
|
|
412
|
+
* Returns a CrdtField with a LoroDoc that can be bound to any editor.
|
|
413
|
+
* Also starts a LIVE SELECT on the parent table for real-time sync;
|
|
414
|
+
* incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
|
|
415
|
+
*/
|
|
416
|
+
async openCrdtField(
|
|
417
|
+
table: string,
|
|
418
|
+
recordId: string,
|
|
419
|
+
field: string,
|
|
420
|
+
fallbackText?: string,
|
|
421
|
+
): Promise<CrdtField> {
|
|
422
|
+
return this.crdtManager.open(table, recordId, field, fallbackText);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Close a CRDT field when editing is done.
|
|
427
|
+
*/
|
|
428
|
+
closeCrdtField(table: string, recordId: string, field: string): void {
|
|
429
|
+
this.crdtManager.close(table, recordId, field);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
deauthenticate() {
|
|
433
|
+
return this.remote.getClient().invalidate();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
query<Table extends TableNames<S>>(
|
|
437
|
+
table: Table,
|
|
438
|
+
options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
|
|
439
|
+
ttl: QueryTimeToLive = '10m'
|
|
440
|
+
): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
|
|
441
|
+
return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(
|
|
442
|
+
this.config.schema,
|
|
443
|
+
table,
|
|
444
|
+
async (q) => ({
|
|
445
|
+
hash: await this.initQuery(table, q, ttl),
|
|
446
|
+
}),
|
|
447
|
+
options
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async initQuery<Table extends TableNames<S>>(
|
|
452
|
+
table: Table,
|
|
453
|
+
q: InnerQuery<any, any, any>,
|
|
454
|
+
ttl: QueryTimeToLive
|
|
455
|
+
) {
|
|
456
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
457
|
+
if (!tableSchema) {
|
|
458
|
+
throw new Error(`Table ${table} not found`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
|
|
462
|
+
const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
|
|
463
|
+
|
|
464
|
+
// Instant-hydrate: for a cold query, fetch its rows one-shot from the remote
|
|
465
|
+
// (its own surql, run directly like useRemote) and display them NOW; the full
|
|
466
|
+
// realtime registration proceeds below. Hydrated rows carry their `_00_rv`
|
|
467
|
+
// versions so the registration's syncRecords skips re-pulling unchanged bodies.
|
|
468
|
+
// Best-effort: any failure (e.g. offline) just falls through to registration.
|
|
469
|
+
if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) {
|
|
470
|
+
try {
|
|
471
|
+
const [rows] = await this.remote.query<[RecordWithId[]]>(q.selectQuery.query, params);
|
|
472
|
+
await this.dataModule.applyHydration(hash, rows ?? []);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
this.logger.warn(
|
|
475
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kyClient::instantHydrate' },
|
|
476
|
+
'Instant hydrate failed; proceeding with registration'
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
await this.sync.enqueueDownEvent({
|
|
482
|
+
type: 'register',
|
|
483
|
+
payload: {
|
|
484
|
+
hash,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return hash;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive) {
|
|
492
|
+
const tableName = sql.split('FROM ')[1].split(' ')[0];
|
|
493
|
+
return this.dataModule.query(tableName, sql, params, ttl);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async subscribe(
|
|
497
|
+
queryHash: string,
|
|
498
|
+
callback: (records: Record<string, any>[]) => void,
|
|
499
|
+
options?: { immediate?: boolean }
|
|
500
|
+
): Promise<() => void> {
|
|
501
|
+
return this.dataModule.subscribe(queryHash, callback, options);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Opt-in eager teardown for a query whose last subscriber has gone away
|
|
506
|
+
* (e.g. a viewport-windowed list cancelling an off-screen window). No-op
|
|
507
|
+
* while any subscriber remains. Tears down the remote `_00_query` view +
|
|
508
|
+
* local WASM view instead of waiting for the TTL sweep. Default behavior
|
|
509
|
+
* (no call here) keeps the view resident for cheap re-subscription.
|
|
510
|
+
*/
|
|
511
|
+
deregisterQuery(queryHash: string): void {
|
|
512
|
+
this.dataModule.deregisterQuery(queryHash);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Subscribe to a query's fetch-status changes (idle/fetching). With
|
|
517
|
+
* `{ immediate: true }` the callback fires synchronously with the current
|
|
518
|
+
* status. Powers the `useQuery` hook's `isFetching()` accessor.
|
|
519
|
+
*/
|
|
520
|
+
subscribeQueryStatus(
|
|
521
|
+
queryHash: string,
|
|
522
|
+
callback: QueryStatusCallback,
|
|
523
|
+
options?: { immediate?: boolean }
|
|
524
|
+
): () => void {
|
|
525
|
+
return this.dataModule.subscribeStatus(queryHash, callback, options);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Report the frontend processing time (ms) a client framework spent applying
|
|
530
|
+
* an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
|
|
531
|
+
* surface the "frontend" phase of the per-query timing breakdown.
|
|
532
|
+
*/
|
|
533
|
+
reportFrontendTiming(queryHash: string, ms: number): void {
|
|
534
|
+
this.dataModule.recordFrontendTiming(queryHash, ms);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
run<
|
|
538
|
+
B extends BackendNames<S>,
|
|
539
|
+
R extends BackendRoutes<S, B>,
|
|
540
|
+
>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions) {
|
|
541
|
+
return this.dataModule.run(backend, path, payload, options);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
545
|
+
return new BucketHandle(name, this.remote);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
create(id: string, data: Record<string, unknown>) {
|
|
549
|
+
return this.dataModule.create(id, data);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions) {
|
|
553
|
+
return this.dataModule.update(table, id, data, options);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
delete(table: string, id: string) {
|
|
557
|
+
return this.dataModule.delete(table, id);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
|
|
561
|
+
return fn(this.remote.getClient());
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Fetch SurrealDB's `session::id()` as a string. Used as a salt for
|
|
566
|
+
* query-id hashing so two sessions for the same user get distinct
|
|
567
|
+
* `_00_query` rows. Returns empty string if the query fails (we still
|
|
568
|
+
* boot, just without session scoping for IDs).
|
|
569
|
+
*/
|
|
570
|
+
private async fetchSessionId(): Promise<string> {
|
|
571
|
+
try {
|
|
572
|
+
const [sid] = await this.remote.query<[string]>('RETURN <string>session::id()');
|
|
573
|
+
return typeof sid === 'string' ? sid : '';
|
|
574
|
+
} catch (e) {
|
|
575
|
+
this.logger.warn(
|
|
576
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::fetchSessionId' },
|
|
577
|
+
'Failed to fetch session::id() — proceeding with empty salt'
|
|
578
|
+
);
|
|
579
|
+
return '';
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|