@spooky-sync/core 0.0.1-canary.11 → 0.0.1-canary.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1033 -372
  3. package/dist/index.js +5490 -1331
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +721 -0
  9. package/package.json +40 -9
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.rebind.test.ts +147 -0
  25. package/src/modules/data/data.status.test.ts +249 -0
  26. package/src/modules/data/index.ts +996 -125
  27. package/src/modules/data/window-query.test.ts +52 -0
  28. package/src/modules/data/window-query.ts +154 -0
  29. package/src/modules/devtools/index.ts +180 -28
  30. package/src/modules/devtools/versions.test.ts +74 -0
  31. package/src/modules/devtools/versions.ts +81 -0
  32. package/src/modules/feature-flag/index.test.ts +120 -0
  33. package/src/modules/feature-flag/index.ts +209 -0
  34. package/src/modules/ref-tables.test.ts +91 -0
  35. package/src/modules/ref-tables.ts +88 -0
  36. package/src/modules/sync/engine.ts +101 -37
  37. package/src/modules/sync/events/index.ts +9 -2
  38. package/src/modules/sync/queue/queue-down.ts +12 -5
  39. package/src/modules/sync/queue/queue-up.ts +29 -14
  40. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  41. package/src/modules/sync/scheduler.ts +73 -7
  42. package/src/modules/sync/sync.health.test.ts +149 -0
  43. package/src/modules/sync/sync.ts +1017 -62
  44. package/src/modules/sync/utils.test.ts +269 -2
  45. package/src/modules/sync/utils.ts +182 -17
  46. package/src/otel/index.ts +127 -0
  47. package/src/services/database/cache-engine.ts +143 -0
  48. package/src/services/database/database.ts +11 -11
  49. package/src/services/database/engine-factory.ts +32 -0
  50. package/src/services/database/events/index.ts +2 -1
  51. package/src/services/database/index.ts +6 -0
  52. package/src/services/database/local-migrator.ts +27 -27
  53. package/src/services/database/local.test.ts +64 -0
  54. package/src/services/database/local.ts +452 -66
  55. package/src/services/database/plan-render.test.ts +85 -0
  56. package/src/services/database/plan-render.ts +86 -0
  57. package/src/services/database/relation-resolver.test.ts +413 -0
  58. package/src/services/database/relation-resolver.ts +0 -0
  59. package/src/services/database/remote.ts +13 -13
  60. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  61. package/src/services/database/sqlite-cache-engine.ts +693 -0
  62. package/src/services/database/sqlite-worker.ts +116 -0
  63. package/src/services/database/surql-translate.ts +291 -0
  64. package/src/services/database/surreal-cache-engine.ts +122 -0
  65. package/src/services/logger/index.ts +6 -101
  66. package/src/services/persistence/localstorage.ts +2 -2
  67. package/src/services/persistence/resilient.ts +41 -0
  68. package/src/services/persistence/surrealdb.ts +10 -10
  69. package/src/services/stream-processor/index.ts +295 -38
  70. package/src/services/stream-processor/permissions.test.ts +47 -0
  71. package/src/services/stream-processor/permissions.ts +53 -0
  72. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  73. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  74. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  75. package/src/services/stream-processor/wasm-types.ts +18 -2
  76. package/src/sp00ky.auth-order.test.ts +92 -0
  77. package/src/sp00ky.ts +795 -0
  78. package/src/types.ts +231 -15
  79. package/src/utils/error-classification.test.ts +44 -0
  80. package/src/utils/error-classification.ts +7 -0
  81. package/src/utils/index.ts +35 -13
  82. package/src/utils/parser.ts +3 -2
  83. package/src/utils/surql.ts +24 -15
  84. package/src/utils/withRetry.test.ts +1 -1
  85. package/tsdown.config.ts +64 -1
  86. package/src/spooky.ts +0 -392
package/src/types.ts CHANGED
@@ -1,9 +1,15 @@
1
- import { RecordId, SchemaStructure } from '@spooky-sync/query-builder';
2
- import { Level } from 'pino';
3
- import { PushEventOptions } from './events/index';
4
- import { UpEvent } from './modules/sync/index';
1
+ import type { RecordId, SchemaStructure, QueryPlan } from '@spooky-sync/query-builder';
2
+ import type { Level, LoggerOptions } from 'pino';
3
+ import type { PushEventOptions } from './events/index';
4
+ import type { UpEvent } from './modules/sync/index';
5
+ import type { LocalEngineChoice } from './services/database/cache-engine';
5
6
 
6
- export type { Level } from 'pino';
7
+ export type { Level };
8
+
9
+ /**
10
+ * A pino browser transmit object for forwarding logs to an external sink (e.g. OpenTelemetry).
11
+ */
12
+ export type PinoTransmit = NonNullable<NonNullable<LoggerOptions['browser']>['transmit']>;
7
13
 
8
14
  /**
9
15
  * The type of storage backend to use for the local database.
@@ -65,22 +71,22 @@ export type QueryTimeToLive =
65
71
  /**
66
72
  * Result object returned when a query is registered or executed.
67
73
  */
68
- export interface SpookyQueryResult {
74
+ export interface Sp00kyQueryResult {
69
75
  /** The unique hash identifier for the query. */
70
76
  hash: string;
71
77
  }
72
78
 
73
- export type SpookyQueryResultPromise = Promise<SpookyQueryResult>;
79
+ export type Sp00kyQueryResultPromise = Promise<Sp00kyQueryResult>;
74
80
 
75
81
  export interface EventSubscriptionOptions {
76
82
  priority?: number;
77
83
  }
78
84
 
79
85
  /**
80
- * Configuration options for the Spooky client.
86
+ * Configuration options for the Sp00ky client.
81
87
  * @template S The schema structure type.
82
88
  */
83
- export interface SpookyConfig<S extends SchemaStructure> {
89
+ export interface Sp00kyConfig<S extends SchemaStructure> {
84
90
  /** Database connection configuration. */
85
91
  database: {
86
92
  /** The SurrealDB endpoint URL. */
@@ -94,8 +100,6 @@ export interface SpookyConfig<S extends SchemaStructure> {
94
100
  /** Authentication token. */
95
101
  token?: string;
96
102
  };
97
- /** Unique client identifier. If not provided, one will be generated. */
98
- clientId?: string;
99
103
  /** The schema definition. */
100
104
  schema: S;
101
105
  /** The compiled SURQL schema string. */
@@ -107,13 +111,104 @@ export interface SpookyConfig<S extends SchemaStructure> {
107
111
  * Can be a custom implementation, 'surrealdb' (default), or 'localstorage'.
108
112
  */
109
113
  persistenceClient?: PersistenceClient | 'surrealdb' | 'localstorage';
110
- /** OpenTelemetry collector endpoint for telemetry data. */
111
- otelEndpoint?: string;
112
114
  /**
113
- * Debounce time in milliseconds for stream updates.
114
- * Defaults to 100ms.
115
+ * Local cache engine backend. `'surrealdb'` (default) uses the in-browser
116
+ * SurrealDB-WASM store; `'sqlite'` uses official SQLite-WASM in a Worker with
117
+ * OPFS persistence; or pass a custom {@link LocalCacheEngine}. The local cache
118
+ * is a passive queryable store — reactivity is driven by the remote SSP, not
119
+ * this engine. See `services/database/cache-engine.ts`.
120
+ */
121
+ localEngine?: LocalEngineChoice;
122
+ /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
123
+ otelTransmit?: PinoTransmit;
124
+ /**
125
+ * Debounce time in milliseconds for stream updates (the client-side SSP
126
+ * aggregation throttle — coalesces the in-browser StreamProcessor's
127
+ * per-record updates per query before notifying readers).
128
+ * Defaults to 50ms.
115
129
  */
116
130
  streamDebounceTime?: number;
131
+ /**
132
+ * Debounce time in milliseconds for syncing collaborative (CRDT) field
133
+ * changes to the remote database. Local writes happen immediately on
134
+ * every keystroke (so reload/offline works), but the remote UPSERT is
135
+ * coalesced over this window. Lower = snappier remote propagation +
136
+ * more network traffic; higher = less traffic + more lag for other
137
+ * collaborators. Defaults to 500ms.
138
+ */
139
+ crdtDebounceMs?: number;
140
+ /**
141
+ * Cadence (ms) for the `_00_list_ref` poll that catches cross-session
142
+ * UPDATEs the SurrealDB v3 LIVE-permission gap drops. Lower = faster
143
+ * convergence + more query load; higher = the inverse. Non-positive
144
+ * values fall back to the default (500ms).
145
+ */
146
+ refSyncIntervalMs?: number;
147
+ /**
148
+ * Instant-hydrate cold queries: when a query is registered with no local
149
+ * data yet, first run its surql directly on the remote (one-shot) and display
150
+ * the result immediately, THEN do the full realtime registration in the
151
+ * background. The hydrated rows are ingested with their versions so the
152
+ * registration's `syncRecords` skips re-pulling unchanged bodies. Cuts cold
153
+ * first-paint from ~one full registration round-trip to ~one query.
154
+ * Defaults to `true`; set `false` to keep the old wait-for-registration path.
155
+ */
156
+ instantHydrate?: boolean;
157
+ /**
158
+ * Enable realtime sync while signed out. When `true`, the client starts its
159
+ * `_00_list_ref` poll (and a LIVE subscription) against the shared
160
+ * `_00_list_ref_anon` table even with no authenticated user, so a logged-out
161
+ * page gets live `useQuery` updates over world-readable tables. Requires the
162
+ * server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
163
+ * (this flag must match it). Defaults to `false`: anonymous clients can read
164
+ * one-shot but never sync live.
165
+ */
166
+ enableAnonymousLiveQueries?: boolean;
167
+ /**
168
+ * Surface sustained sync failures as a "degraded" health status that the app
169
+ * can observe via `subscribeToSyncHealth` (or the client-solid
170
+ * `useSyncStatus` hook) to render a "can't reach the server" banner.
171
+ *
172
+ * Individual failures — a transient remote 500 on query registration, a
173
+ * dropped WebSocket, etc. — are always swallowed and retried; they never
174
+ * throw at the app. This only controls when a *run* of consecutive failures
175
+ * is reported. Status flips back to `healthy` on the next successful sync
176
+ * round. Defaults to `{ degradeAfterConsecutiveFailures: 3 }`; pass `false`
177
+ * (or `degradeAfterConsecutiveFailures: 0`) to never report degraded.
178
+ */
179
+ syncHealth?: SyncHealthConfig | false;
180
+ }
181
+
182
+ /** Tunables for sync-health reporting. See {@link Sp00kyConfig.syncHealth}. */
183
+ export interface SyncHealthConfig {
184
+ /**
185
+ * Number of consecutive failed sync rounds (up or down) before the status
186
+ * flips from `healthy` to `degraded`. A single transient failure is absorbed
187
+ * by the retry; only a sustained run trips the banner. Defaults to `3`. `0`
188
+ * disables degraded reporting entirely.
189
+ */
190
+ degradeAfterConsecutiveFailures?: number;
191
+ }
192
+
193
+ export type SyncHealthStatus = 'healthy' | 'degraded';
194
+
195
+ /** Snapshot of sync health delivered to `subscribeToSyncHealth` subscribers. */
196
+ export interface SyncHealth {
197
+ /** `'degraded'` once consecutive failures cross the configured threshold. */
198
+ status: SyncHealthStatus;
199
+ /** Consecutive failed sync rounds at the moment of this report. */
200
+ consecutiveFailures: number;
201
+ /** Classification of the most recent failure (only set while `degraded`). */
202
+ kind?: 'network' | 'application';
203
+ /** Message of the most recent failure (only set while `degraded`). */
204
+ error?: string;
205
+ /**
206
+ * `true` once at least one sync round has succeeded this session. Lets a UI
207
+ * distinguish a first-time "connecting" phase (never reached the server yet,
208
+ * so a cold-start failure run is expected) from a real lost connection after
209
+ * a working session. Never resets back to `false` once set.
210
+ */
211
+ everConnected: boolean;
117
212
  }
118
213
 
119
214
  export type QueryHash = string;
@@ -143,12 +238,27 @@ export interface QueryConfig {
143
238
  id: RecordId<string>;
144
239
  /** The SURQL query string. */
145
240
  surql: string;
241
+ /**
242
+ * Engine-neutral plan for `surql` (in-memory only; not persisted to
243
+ * `_00_query`). Present when the query came from the query-builder. Non-
244
+ * SurrealQL local engines (SQLite) materialize via `engine.select(plan)`
245
+ * instead of re-running `surql`, which they cannot parse.
246
+ */
247
+ plan?: QueryPlan;
146
248
  /** Parameters used in the query. */
147
249
  params: Record<string, any>;
148
250
  /** The version array representing the local state of results. */
149
251
  localArray: RecordVersionArray;
150
252
  /** The version array representing the remote (server) state of results. */
151
253
  remoteArray: RecordVersionArray;
254
+ /**
255
+ * In-memory only (never persisted to `_00_query`): version array of the
256
+ * subquery CHILD rows pulled via `parent IS NOT NONE` edges, so the
257
+ * child-body sync is idempotent across polls. Kept separate from
258
+ * `remoteArray` so related child rows never enter the primary window /
259
+ * `rowCount` / `localArray`.
260
+ */
261
+ subqueryRemoteArray?: RecordVersionArray;
152
262
  /** Time-To-Live for this query. */
153
263
  ttl: QueryTimeToLive;
154
264
  /** Timestamp when the query was last accessed/active. */
@@ -159,6 +269,18 @@ export interface QueryConfig {
159
269
 
160
270
  export type QueryConfigRecord = QueryConfig & { id: string };
161
271
 
272
+ /**
273
+ * Runtime fetch status of a live query.
274
+ * - `idle`: registered, initial sync completed, and not currently fetching
275
+ * missing records — the materialized rows are authoritative (a windowed
276
+ * query's short result really is the end of the list).
277
+ * - `fetching`: the query is registering (a query is born `fetching` until its
278
+ * initial remote sync completes) or the sync engine is fetching/ingesting
279
+ * missing records for it. Any pending debounced result is flushed BEFORE the
280
+ * flip back to `idle`, so idle status never races ahead of the rows.
281
+ */
282
+ export type QueryStatus = 'idle' | 'fetching';
283
+
162
284
  /**
163
285
  * Internal state of a live query.
164
286
  */
@@ -167,16 +289,103 @@ export interface QueryState {
167
289
  config: QueryConfig;
168
290
  /** The current cached records for this query. */
169
291
  records: Record<string, any>[];
292
+ /** Set once `applyHydration` has run for this query, so the cold instant-hydrate
293
+ * path fires at most once per query (see DataModule.isCold/applyHydration). */
294
+ hydrated?: boolean;
295
+ /** Set once `notifyQuerySynced` has emitted for this registration lifetime.
296
+ * Ephemeral (unlike the persisted `updateCount`), so a re-registered query
297
+ * always emits at least once even when its records are unchanged — otherwise
298
+ * an empty re-registered window would never notify and stay "loading". */
299
+ syncNotified?: boolean;
170
300
  /** Timer for TTL expiration. */
171
301
  ttlTimer: NodeJS.Timeout | null;
172
302
  /** TTL duration in milliseconds. */
173
303
  ttlDurationMs: number;
174
304
  /** Number of times the query has been updated. */
175
305
  updateCount: number;
306
+ /** Timestamp (ms) of the last user-visible update, or null before the first
307
+ * one. Surfaced to DevTools as `lastUpdate` — must NOT be stamped on read. */
308
+ lastUpdatedAt: number | null;
309
+ /**
310
+ * Rolling window of the most recent materialization-step latencies (ms).
311
+ * Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
312
+ * before each persist to `_00_query`. Samples themselves are not persisted.
313
+ */
314
+ materializationSamples: number[];
315
+ /** Most recent end-to-end ingest latency in ms, or null until the first ingest. */
316
+ lastIngestLatencyMs: number | null;
317
+ /** Cumulative count of ingest/materialization errors observed for this query. */
318
+ errorCount: number;
319
+ /**
320
+ * Ephemeral runtime fetch status. Not persisted to `_00_query`; observable
321
+ * via DevTools and the `useQuery` hook. `fetching` while the sync engine is
322
+ * pulling missing records for this query, otherwise `idle`.
323
+ */
324
+ status: QueryStatus;
325
+ /**
326
+ * Rolling per-phase timing samples (ms), in addition to `materializationSamples`
327
+ * (which holds the SSP whole-ingest wall time). Keyed by `TimingPhase` minus
328
+ * `ssp`. Not persisted — surfaced live to DevTools + MCP via `phaseTimings`.
329
+ */
330
+ phaseSamples: Record<string, number[]>;
331
+ /** Most recent sample (ms) per phase, or null. */
332
+ phaseLast: Record<string, number | null>;
333
+ /** One-shot SSP registration timings (ms). */
334
+ registrationTimings: RegistrationTimings;
335
+ }
336
+
337
+ /** Cap on the rolling materialization-sample window kept per query in memory. */
338
+ export const MATERIALIZATION_SAMPLE_WINDOW = 100;
339
+
340
+ /** Timed processing phases surfaced per query. `ssp` is the WASM-ingest wall
341
+ * time; the `ssp*` phases are its internal breakdown from the SSP binding. */
342
+ export type TimingPhase =
343
+ | 'ssp'
344
+ | 'sspStoreApply'
345
+ | 'sspCircuitStep'
346
+ | 'sspTransform'
347
+ | 'localFetch'
348
+ | 'remoteFetch'
349
+ | 'frontend';
350
+
351
+ /** One-shot registration timings (ms), captured once when a query registers. */
352
+ export interface RegistrationTimings {
353
+ /** SSP surql→plan parse + permission injection. */
354
+ parseMs: number | null;
355
+ /** SSP operator-DAG build. */
356
+ planMs: number | null;
357
+ /** SSP initial snapshot evaluation. */
358
+ snapshotMs: number | null;
359
+ /** Wall time of `cache.registerQuery` (register_view round-trip). */
360
+ wallMs: number | null;
361
+ }
362
+
363
+ /** Percentile summary for one timed phase, surfaced to DevTools + MCP. */
364
+ export interface PhaseStat {
365
+ lastMs: number | null;
366
+ p50: number | null;
367
+ p90: number | null;
368
+ p99: number | null;
369
+ count: number;
370
+ }
371
+
372
+ /** Per-query processing-time breakdown surfaced via DevTools panel + MCP. */
373
+ export interface QueryTimings {
374
+ ssp: PhaseStat;
375
+ sspStoreApply: PhaseStat;
376
+ sspCircuitStep: PhaseStat;
377
+ sspTransform: PhaseStat;
378
+ localFetch: PhaseStat;
379
+ remoteFetch: PhaseStat;
380
+ frontend: PhaseStat;
381
+ registration: RegistrationTimings;
382
+ updateCount: number;
383
+ errorCount: number;
176
384
  }
177
385
 
178
386
  // Callback types
179
387
  export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
388
+ export type QueryStatusCallback = (status: QueryStatus) => void;
180
389
  export type MutationCallback = (mutations: UpEvent[]) => void;
181
390
 
182
391
  export type MutationEventType = 'create' | 'update' | 'delete';
@@ -209,6 +418,13 @@ export interface RunOptions {
209
418
  assignedTo?: string;
210
419
  max_retries?: number;
211
420
  retry_strategy?: 'linear' | 'exponential';
421
+ /** Timeout in seconds for the backend HTTP call. Only used if the backend allows timeout override. */
422
+ timeout?: number;
423
+ /**
424
+ * Minimum delay in milliseconds before the job is eligible to run. While
425
+ * delayed the job stays pending (enqueued) and can still be killed.
426
+ */
427
+ delay?: number;
212
428
  }
213
429
 
214
430
  /**
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { classifySyncError } from './error-classification';
3
+
4
+ describe('classifySyncError', () => {
5
+ it('classifies surreal ConnectionUnavailableError as network', () => {
6
+ // Thrown synchronously by the WS engine's send() while the socket is down
7
+ // but reconnect hasn't fired. Message contains "connected", not "connection".
8
+ const err = new Error(
9
+ 'You must be connected to a SurrealDB instance before performing this operation'
10
+ );
11
+ expect(classifySyncError(err)).toBe('network');
12
+ });
13
+
14
+ it('classifies surreal CallTerminatedError as network', () => {
15
+ const err = new Error(
16
+ 'The call has been terminated because the connection was closed'
17
+ );
18
+ expect(classifySyncError(err)).toBe('network');
19
+ });
20
+
21
+ it.each([
22
+ 'WebSocket connection failed',
23
+ 'fetch failed',
24
+ 'connect ECONNREFUSED 127.0.0.1:8666',
25
+ 'request timed out',
26
+ 'The operation was aborted',
27
+ 'socket hang up',
28
+ ])('classifies %j as network', (message) => {
29
+ expect(classifySyncError(new Error(message))).toBe('network');
30
+ });
31
+
32
+ it.each([
33
+ 'Permission denied',
34
+ 'There was a problem with the database: record already exists',
35
+ 'Parse error: unexpected token',
36
+ ])('classifies %j as application', (message) => {
37
+ expect(classifySyncError(new Error(message))).toBe('application');
38
+ });
39
+
40
+ it('handles non-Error values', () => {
41
+ expect(classifySyncError('connection reset')).toBe('network');
42
+ expect(classifySyncError('boom')).toBe('application');
43
+ });
44
+ });
@@ -1,5 +1,12 @@
1
1
  const NETWORK_ERROR_PATTERNS = [
2
2
  'connection',
3
+ // surreal's ConnectionUnavailableError reads "You must be connected to a
4
+ // SurrealDB instance..." — it contains "connected", not "connection", so it
5
+ // slips past the pattern above. The WS engine throws it synchronously from
6
+ // `send()` while the socket is down but reconnect hasn't fired yet, so it's the
7
+ // canonical error on an idle-dropped socket; classify it as network.
8
+ 'must be connected',
9
+ 'connectionunavailable',
3
10
  'timeout',
4
11
  'timed out',
5
12
  'websocket',
@@ -1,7 +1,7 @@
1
- import { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
1
+ import type { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
2
2
  import { Uuid, RecordId, Duration } from 'surrealdb';
3
- import { Logger } from '../services/logger/index';
4
- import { QueryTimeToLive } from '../types';
3
+ import type { Logger } from '../services/logger/index';
4
+ import type { QueryTimeToLive } from '../types';
5
5
 
6
6
  export * from './surql';
7
7
  export * from './parser';
@@ -59,7 +59,7 @@ export function generateNewTableId<S extends SchemaStructure, T extends TableNam
59
59
 
60
60
  // ==================== SCHEMA ENCODING/DECODING ====================
61
61
 
62
- export function decodeFromSpooky<S extends SchemaStructure, T extends TableNames<S>>(
62
+ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames<S>>(
63
63
  schema: S,
64
64
  tableName: T,
65
65
  record: TableModel<GetTable<S, T>>
@@ -74,19 +74,19 @@ export function decodeFromSpooky<S extends SchemaStructure, T extends TableNames
74
74
  for (const field of Object.keys(table.columns)) {
75
75
  const column = table.columns[field] as any;
76
76
  const relation = schema.relationships.find((r) => r.from === tableName && r.field === field);
77
- if ((column.recordId || relation) && encoded[field] != null) {
77
+ if ((column.recordId || relation) && encoded[field] !== null && encoded[field] !== undefined) {
78
78
  if (encoded[field] instanceof RecordId) {
79
79
  encoded[field] = `${encoded[field].table.toString()}:${encoded[field].id}`;
80
80
  } else if (
81
81
  relation &&
82
- (encoded[field] instanceof Object || encoded[field] instanceof Array)
82
+ (encoded[field] instanceof Object || Array.isArray(encoded[field]))
83
83
  ) {
84
84
  if (Array.isArray(encoded[field])) {
85
85
  encoded[field] = encoded[field].map((item) =>
86
- decodeFromSpooky(schema, relation.to, item)
86
+ decodeFromSp00ky(schema, relation.to, item)
87
87
  );
88
88
  } else {
89
- encoded[field] = decodeFromSpooky(schema, relation.to, encoded[field]);
89
+ encoded[field] = decodeFromSp00ky(schema, relation.to, encoded[field]);
90
90
  }
91
91
  }
92
92
  }
@@ -97,15 +97,25 @@ export function decodeFromSpooky<S extends SchemaStructure, T extends TableNames
97
97
 
98
98
  // ==================== TIME/DURATION UTILITIES ====================
99
99
 
100
+ /**
101
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
102
+ * releases expose it under `milliseconds` (current) or the older private
103
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
104
+ */
105
+ function durationMillis(duration: Duration): number {
106
+ const d = duration as { milliseconds?: number | bigint; _milliseconds?: number | bigint };
107
+ return Number(d.milliseconds || d._milliseconds || 0);
108
+ }
109
+
100
110
  /**
101
111
  * Parse duration string or Duration object to milliseconds
102
112
  */
103
113
  export function parseDuration(duration: QueryTimeToLive | Duration): number {
104
114
  if (duration instanceof Duration) {
105
- const ms = (duration as any).milliseconds || (duration as any)._milliseconds;
106
- if (ms) return Number(ms);
115
+ const ms = durationMillis(duration);
116
+ if (ms) return ms;
107
117
  const str = duration.toString();
108
- if (str !== '[object Object]') return parseDuration(str as any);
118
+ if (str !== '[object Object]') return parseDuration(str as QueryTimeToLive);
109
119
  return 600000;
110
120
  }
111
121
 
@@ -117,7 +127,7 @@ export function parseDuration(duration: QueryTimeToLive | Duration): number {
117
127
 
118
128
  const match = duration.match(/^(\d+)([smh])$/);
119
129
  if (!match) return 600000;
120
- const val = parseInt(match[1], 10);
130
+ const val = Number.parseInt(match[1], 10);
121
131
  const unit = match[2];
122
132
  switch (unit) {
123
133
  case 's':
@@ -137,6 +147,18 @@ export async function fileToUint8Array(file: File | Blob): Promise<Uint8Array> {
137
147
  return new Uint8Array(buffer);
138
148
  }
139
149
 
150
+ // ==================== TEXT UTILITIES ====================
151
+
152
+ /**
153
+ * Convert plain text to simple HTML paragraphs.
154
+ * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
155
+ */
156
+ export function textToHtml(text: string): string {
157
+ return text
158
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
159
+ .split('\n').map((l) => `<p>${l || '<br>'}</p>`).join('');
160
+ }
161
+
140
162
  // ==================== DATABASE UTILITIES ====================
141
163
 
142
164
  /**
@@ -165,7 +187,7 @@ export async function withRetry<T>(
165
187
  attempt: i + 1,
166
188
  retries,
167
189
  error: msg,
168
- Category: 'spooky-client::utils::withRetry',
190
+ Category: 'sp00ky-client::utils::withRetry',
169
191
  },
170
192
  'Retrying DB operation'
171
193
  );
@@ -1,4 +1,5 @@
1
- import { ColumnSchema, RecordId } from '@spooky-sync/query-builder';
1
+ import type { ColumnSchema} from '@spooky-sync/query-builder';
2
+ import { RecordId } from '@spooky-sync/query-builder';
2
3
  import { parseRecordIdString } from './index';
3
4
  import { DateTime } from 'surrealdb';
4
5
 
@@ -8,7 +9,7 @@ export function cleanRecord(
8
9
  ): Record<string, any> {
9
10
  const cleaned: Record<string, any> = {};
10
11
  for (const [key, value] of Object.entries(record)) {
11
- if (key === 'id' || key in tableSchema) {
12
+ if (key === 'id' || key.startsWith('_00_') || key in tableSchema) {
12
13
  cleaned[key] = value;
13
14
  }
14
15
  }
@@ -1,4 +1,4 @@
1
- import { MutationEventType } from '../types';
1
+ import type { MutationEventType } from '../types';
2
2
 
3
3
  // ==================== TYPES ====================
4
4
 
@@ -34,6 +34,7 @@ export interface SurqlHelper {
34
34
  keyDataVars: ({ key: string; variable: string } | { statement: string } | string)[]
35
35
  ): string;
36
36
  upsert(idVar: string, dataVar: string): string;
37
+ upsertMerge(idVar: string, dataVar: string): string;
37
38
  updateMerge(idVar: string, dataVar: string): string;
38
39
  updateSet(
39
40
  idVar: string,
@@ -90,16 +91,16 @@ export const surql: SurqlHelper = {
90
91
  returnValues: ({ field: string; alias: string } | string)[]
91
92
  ) {
92
93
  return `SELECT ${returnValues
93
- .map((returnValues) =>
94
- typeof returnValues === 'string'
95
- ? returnValues
96
- : `${returnValues.field} as ${returnValues.alias}`
94
+ .map((rv) =>
95
+ typeof rv === 'string'
96
+ ? rv
97
+ : `${rv.field} as ${rv.alias}`
97
98
  )
98
99
  .join(',')} FROM ${table} WHERE ${whereVar
99
- .map((whereVar) =>
100
- typeof whereVar === 'string'
101
- ? `${whereVar} = $${whereVar}`
102
- : `${whereVar.field} = $${whereVar.variable}`
100
+ .map((wv) =>
101
+ typeof wv === 'string'
102
+ ? `${wv} = $${wv}`
103
+ : `${wv.field} = $${wv.variable}`
103
104
  )
104
105
  .join(' AND ')}`;
105
106
  },
@@ -127,6 +128,14 @@ export const surql: SurqlHelper = {
127
128
  return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
128
129
  },
129
130
 
131
+ // Use this for sync-down ingestion: the remote payload omits local-only
132
+ // fields injected by the CLI's local-schema setup (`_00_crdt`,
133
+ // `_00_cursor`), and REPLACE would wipe them on every round-trip and
134
+ // break offline reload of CRDT state.
135
+ upsertMerge(idVar: string, dataVar: string) {
136
+ return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
137
+ },
138
+
130
139
  updateMerge(idVar: string, dataVar: string) {
131
140
  return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
132
141
  },
@@ -136,12 +145,12 @@ export const surql: SurqlHelper = {
136
145
  keyDataVar: ({ key: string; variable: string } | { statement: string } | string)[]
137
146
  ) {
138
147
  return `UPDATE $${idVar} SET ${keyDataVar
139
- .map((keyDataVar) =>
140
- typeof keyDataVar === 'string'
141
- ? `${keyDataVar} = $${keyDataVar}`
142
- : 'statement' in keyDataVar
143
- ? keyDataVar.statement
144
- : `${keyDataVar.key} = $${keyDataVar.variable}`
148
+ .map((kdv) =>
149
+ typeof kdv === 'string'
150
+ ? `${kdv} = $${kdv}`
151
+ : 'statement' in kdv
152
+ ? kdv.statement
153
+ : `${kdv.key} = $${kdv.variable}`
145
154
  )
146
155
  .join(', ')}`;
147
156
  },
@@ -145,7 +145,7 @@ describe('withRetry', () => {
145
145
  attempt: 1,
146
146
  retries: 3,
147
147
  error: 'Can not open transaction',
148
- Category: 'spooky-client::utils::withRetry',
148
+ Category: 'sp00ky-client::utils::withRetry',
149
149
  }),
150
150
  'Retrying DB operation'
151
151
  );
package/tsdown.config.ts CHANGED
@@ -1,9 +1,72 @@
1
1
  import { defineConfig } from 'tsdown';
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+
6
+ // Resolve real package versions at build time so the DevTools can report the
7
+ // actual bundled frontend versions (used to detect frontend/backend drift).
8
+ const corePkg = require('./package.json');
9
+ const coreVersion: string = corePkg.version;
10
+ // ssp-wasm has no `exports` map; resolve its package.json directly, falling
11
+ // back to the relative workspace path if module resolution can't find it.
12
+ let wasmVersion: string;
13
+ try {
14
+ wasmVersion = require('@spooky-sync/ssp-wasm/package.json').version;
15
+ } catch {
16
+ wasmVersion = require('../ssp-wasm/package.json').version;
17
+ }
18
+ // Report the SurrealDB WASM ENGINE version (`@surrealdb/wasm`) — the engine the
19
+ // in-browser local DB actually runs — NOT the `surrealdb` JS client library
20
+ // version. The WASM engine is the one that must stay compatible with the server
21
+ // engine, so it's the meaningful number to compare in DevTools. Prefer the
22
+ // actually-installed version; fall back to our pinned range if its `exports`
23
+ // map blocks importing its package.json.
24
+ let surrealVersion: string;
25
+ try {
26
+ surrealVersion = require('@surrealdb/wasm/package.json').version;
27
+ } catch {
28
+ surrealVersion = String(corePkg.dependencies?.['@surrealdb/wasm'] ?? 'unknown').replace(
29
+ /^[\^~]/,
30
+ ''
31
+ );
32
+ }
33
+
34
+ // tsdown 0.12.x forwards a top-level `define` to rolldown's inputOptions, which
35
+ // rejects it — so we do the substitution ourselves with a tiny transform
36
+ // plugin. Only our own modules reference the `__SP00KY_*__` identifiers, and
37
+ // the early `includes` check skips everything else.
38
+ const replacements: Record<string, string> = {
39
+ __SP00KY_CORE_VERSION__: JSON.stringify(coreVersion),
40
+ __SP00KY_WASM_VERSION__: JSON.stringify(wasmVersion),
41
+ __SP00KY_SURREAL_VERSION__: JSON.stringify(surrealVersion),
42
+ };
43
+
44
+ const versionDefinePlugin = {
45
+ name: 'sp00ky-version-define',
46
+ transform(code: string) {
47
+ if (!code.includes('__SP00KY_')) return null;
48
+ let out = code;
49
+ for (const [token, value] of Object.entries(replacements)) {
50
+ out = out.split(token).join(value);
51
+ }
52
+ return out;
53
+ },
54
+ };
2
55
 
3
56
  export default defineConfig({
4
- entry: ['src/index.ts'],
57
+ // `sqlite-worker` is emitted at `dist/sqlite-worker.js` (top level, NOT under
58
+ // services/database) so the `new URL('./sqlite-worker.js', import.meta.url)`
59
+ // in `SqliteCacheEngine` — which gets bundled into the flat `dist/index.js` —
60
+ // resolves correctly. It (and `@sqlite.org/sqlite-wasm`) load lazily in the
61
+ // Worker; never pulled into the main bundle unless `localEngine: 'sqlite'`.
62
+ entry: {
63
+ index: 'src/index.ts',
64
+ 'otel/index': 'src/otel/index.ts',
65
+ 'sqlite-worker': 'src/services/database/sqlite-worker.ts',
66
+ },
5
67
  format: ['esm'],
6
68
  dts: true,
7
69
  clean: true,
8
70
  hash: false,
71
+ plugins: [versionDefinePlugin],
9
72
  });