@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.161
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 +1406 -364
- package/dist/index.js +7773 -1391
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +420 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +874 -0
- package/package.json +20 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- 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 +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +361 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1151 -129
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +325 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +143 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +216 -56
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +77 -9
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1333 -92
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +160 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +464 -0
- package/src/services/database/sqlite-cache-engine.ts +1154 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +113 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +6 -109
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- 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.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1225 -0
- package/src/types.ts +342 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
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 }
|
|
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.
|
|
@@ -62,25 +68,43 @@ export type QueryTimeToLive =
|
|
|
62
68
|
| '12h'
|
|
63
69
|
| '1d';
|
|
64
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Refresh behavior for `preload` when the data is already cached locally (warm).
|
|
73
|
+
* The FIRST load (cold) always fetches + blocks regardless.
|
|
74
|
+
* - `onUse` (default): do nothing when warm — the data freshens on use, when the
|
|
75
|
+
* real `useQuery` mounts and registers its live view. No network on load.
|
|
76
|
+
* - `background`: return instantly, but kick a one-time silent refetch.
|
|
77
|
+
* - `stale`: like `background`, but only if the cached copy is older than
|
|
78
|
+
* `staleTime`.
|
|
79
|
+
*/
|
|
80
|
+
export type PreloadRefresh = 'onUse' | 'background' | 'stale';
|
|
81
|
+
|
|
82
|
+
export interface PreloadOptions {
|
|
83
|
+
/** How to refresh when the query is already cached locally. Default `onUse`. */
|
|
84
|
+
refresh?: PreloadRefresh;
|
|
85
|
+
/** For `refresh: 'stale'` — max age before a warm copy is refetched. Default `1h`. */
|
|
86
|
+
staleTime?: QueryTimeToLive;
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
/**
|
|
66
90
|
* Result object returned when a query is registered or executed.
|
|
67
91
|
*/
|
|
68
|
-
export interface
|
|
92
|
+
export interface Sp00kyQueryResult {
|
|
69
93
|
/** The unique hash identifier for the query. */
|
|
70
94
|
hash: string;
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
export type
|
|
97
|
+
export type Sp00kyQueryResultPromise = Promise<Sp00kyQueryResult>;
|
|
74
98
|
|
|
75
99
|
export interface EventSubscriptionOptions {
|
|
76
100
|
priority?: number;
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
/**
|
|
80
|
-
* Configuration options for the
|
|
104
|
+
* Configuration options for the Sp00ky client.
|
|
81
105
|
* @template S The schema structure type.
|
|
82
106
|
*/
|
|
83
|
-
export interface
|
|
107
|
+
export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
84
108
|
/** Database connection configuration. */
|
|
85
109
|
database: {
|
|
86
110
|
/** The SurrealDB endpoint URL. */
|
|
@@ -93,9 +117,14 @@ export interface SpookyConfig<S extends SchemaStructure> {
|
|
|
93
117
|
store?: StoreType;
|
|
94
118
|
/** Authentication token. */
|
|
95
119
|
token?: string;
|
|
120
|
+
/**
|
|
121
|
+
* SQLite engine only: execute `select` plans (base rows + relation tree +
|
|
122
|
+
* row parsing) inside the worker as ONE round-trip instead of one hop per
|
|
123
|
+
* table/relation level. Defaults to true; set false to force the legacy
|
|
124
|
+
* multi-hop path (escape hatch while the worker-side path beds in).
|
|
125
|
+
*/
|
|
126
|
+
workerSelect?: boolean;
|
|
96
127
|
};
|
|
97
|
-
/** Unique client identifier. If not provided, one will be generated. */
|
|
98
|
-
clientId?: string;
|
|
99
128
|
/** The schema definition. */
|
|
100
129
|
schema: S;
|
|
101
130
|
/** The compiled SURQL schema string. */
|
|
@@ -107,13 +136,190 @@ export interface SpookyConfig<S extends SchemaStructure> {
|
|
|
107
136
|
* Can be a custom implementation, 'surrealdb' (default), or 'localstorage'.
|
|
108
137
|
*/
|
|
109
138
|
persistenceClient?: PersistenceClient | 'surrealdb' | 'localstorage';
|
|
110
|
-
/** OpenTelemetry collector endpoint for telemetry data. */
|
|
111
|
-
otelEndpoint?: string;
|
|
112
139
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
140
|
+
* Local cache engine backend. `'surrealdb'` (default) uses the in-browser
|
|
141
|
+
* SurrealDB-WASM store; `'sqlite'` uses official SQLite-WASM in a Worker with
|
|
142
|
+
* OPFS persistence; or pass a custom {@link LocalCacheEngine}. The local cache
|
|
143
|
+
* is a passive queryable store — reactivity is driven by the remote SSP, not
|
|
144
|
+
* this engine. See `services/database/cache-engine.ts`.
|
|
145
|
+
*/
|
|
146
|
+
localEngine?: LocalEngineChoice;
|
|
147
|
+
/**
|
|
148
|
+
* Share ONE durable local store across all tabs of this origin (default
|
|
149
|
+
* `false`). Requires `localEngine: 'sqlite'`. A SharedWorker broker elects a
|
|
150
|
+
* leader tab per bucket via Web Locks; the leader owns the OPFS SQLite
|
|
151
|
+
* worker and the sync loop, and follower tabs read/write the same store over
|
|
152
|
+
* MessagePorts, so every tab is durable instead of only the first one.
|
|
153
|
+
*
|
|
154
|
+
* Falls back to solo mode (exactly the flag-off behavior, including the
|
|
155
|
+
* later-tabs in-memory fallback reported via {@link StorageHealth}) whenever
|
|
156
|
+
* SharedWorker, Web Locks, or MessageChannel are unavailable, the engine is
|
|
157
|
+
* not sqlite, or the broker rejects the tab (mixed app versions).
|
|
158
|
+
*
|
|
159
|
+
* Failover: when the leader tab closes or freezes, a follower is promoted
|
|
160
|
+
* within seconds; queries briefly refetch and mutations are never lost once
|
|
161
|
+
* their local write resolved (the shared outbox survives in the store).
|
|
162
|
+
* Inspect via `window.__00__.getState().database.tabs` and `__sqliteStats`.
|
|
163
|
+
*/
|
|
164
|
+
sharedTabs?: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* Persist the in-browser SSP circuit (store + view caches) as a snapshot so a
|
|
167
|
+
* reload can restore it instead of re-materializing. Default `false`, and
|
|
168
|
+
* that default is deliberate.
|
|
169
|
+
*
|
|
170
|
+
* The circuit is DERIVED state: the durable local store (OPFS SQLite) is the
|
|
171
|
+
* source of truth, and every first paint already reads row bodies from it
|
|
172
|
+
* (`DataManager.createNewQuery` / `materializeRecords`) using the circuit only
|
|
173
|
+
* for row identity and ordering. A snapshot buys nothing on reload while
|
|
174
|
+
* costing a full deep clone of every row of every ingested table plus a JSON
|
|
175
|
+
* encode of the result, `Circuit::save` in the Rust core, mirroring the
|
|
176
|
+
* server's rule in `ssp-node`: *never per-ingest*.
|
|
177
|
+
*
|
|
178
|
+
* When enabled, snapshots are written on a checkpoint interval
|
|
179
|
+
* ({@link circuitCheckpointMs}) and on `pagehide`, never per ingest or per
|
|
180
|
+
* query registration. Enable only for a workload that has measured a win.
|
|
181
|
+
*/
|
|
182
|
+
persistCircuit?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Checkpoint interval in milliseconds for {@link persistCircuit}. Defaults to
|
|
185
|
+
* 30000. Ignored when `persistCircuit` is off.
|
|
186
|
+
*/
|
|
187
|
+
circuitCheckpointMs?: number;
|
|
188
|
+
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
|
|
189
|
+
otelTransmit?: PinoTransmit;
|
|
190
|
+
/**
|
|
191
|
+
* Debounce time in milliseconds for stream updates (the client-side SSP
|
|
192
|
+
* aggregation throttle — coalesces the in-browser StreamProcessor's
|
|
193
|
+
* per-record updates per query before notifying readers).
|
|
194
|
+
* Defaults to 50ms.
|
|
115
195
|
*/
|
|
116
196
|
streamDebounceTime?: number;
|
|
197
|
+
/**
|
|
198
|
+
* Debounce time in milliseconds for syncing collaborative (CRDT) field
|
|
199
|
+
* changes to the remote database. Local writes happen immediately on
|
|
200
|
+
* every keystroke (so reload/offline works), but the remote UPSERT is
|
|
201
|
+
* coalesced over this window. Lower = snappier remote propagation +
|
|
202
|
+
* more network traffic; higher = less traffic + more lag for other
|
|
203
|
+
* collaborators. Defaults to 500ms.
|
|
204
|
+
*/
|
|
205
|
+
crdtDebounceMs?: number;
|
|
206
|
+
/**
|
|
207
|
+
* Enable collaborative CRDT fields. When `true`, the `loro-crdt` engine is
|
|
208
|
+
* preloaded at client startup (fetched as a separate chunk on page load) so
|
|
209
|
+
* the first `openCrdtField` is instant. When omitted/`false`, loro is never
|
|
210
|
+
* loaded unless a CRDT field is explicitly opened — keeping the loro chunk
|
|
211
|
+
* out of apps that don't use collaboration. Defaults to `false`.
|
|
212
|
+
*/
|
|
213
|
+
crdt?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Cadence (ms) for the `_00_list_ref` poll that catches cross-session
|
|
216
|
+
* UPDATEs the SurrealDB v3 LIVE-permission gap drops. Lower = faster
|
|
217
|
+
* convergence + more query load; higher = the inverse. Non-positive
|
|
218
|
+
* values fall back to the default (500ms).
|
|
219
|
+
*/
|
|
220
|
+
refSyncIntervalMs?: number;
|
|
221
|
+
/**
|
|
222
|
+
* OPT-IN instant-hydrate for cold queries: when enabled and a query is
|
|
223
|
+
* registered with no server result yet, its surql also runs directly on the
|
|
224
|
+
* remote (one-shot, in the background, OFF the paint path) so rows can land
|
|
225
|
+
* before the full register lifecycle completes. Hydrated rows carry their
|
|
226
|
+
* `_00_rv` versions so the registration's `syncRecords` skips re-pulling
|
|
227
|
+
* unchanged bodies. Regardless of this flag, `useQuery` always resolves and
|
|
228
|
+
* paints from the local cache immediately — however the rows got there
|
|
229
|
+
* (preload, prior sync). Default `false`: the register lifecycle
|
|
230
|
+
* (`fn::query::register` → `_00_list_ref` → record sync) is the single
|
|
231
|
+
* freshness path and no duplicate one-shot fetches are made.
|
|
232
|
+
*/
|
|
233
|
+
instantHydrate?: boolean;
|
|
234
|
+
/**
|
|
235
|
+
* Enable realtime sync while signed out. When `true`, the client starts its
|
|
236
|
+
* `_00_list_ref` poll (and a LIVE subscription) against the shared
|
|
237
|
+
* `_00_list_ref_anon` table even with no authenticated user, so a logged-out
|
|
238
|
+
* page gets live `useQuery` updates over world-readable tables. Requires the
|
|
239
|
+
* server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
|
|
240
|
+
* (this flag must match it). Defaults to `false`: anonymous clients can read
|
|
241
|
+
* one-shot but never sync live.
|
|
242
|
+
*/
|
|
243
|
+
enableAnonymousLiveQueries?: boolean;
|
|
244
|
+
/**
|
|
245
|
+
* Surface sustained sync failures as a "degraded" health status that the app
|
|
246
|
+
* can observe via `subscribeToSyncHealth` (or the client-solid
|
|
247
|
+
* `useSyncStatus` hook) to render a "can't reach the server" banner.
|
|
248
|
+
*
|
|
249
|
+
* Individual failures — a transient remote 500 on query registration, a
|
|
250
|
+
* dropped WebSocket, etc. — are always swallowed and retried; they never
|
|
251
|
+
* throw at the app. This only controls when a *run* of consecutive failures
|
|
252
|
+
* is reported. Status flips back to `healthy` on the next successful sync
|
|
253
|
+
* round. Defaults to `{ degradeAfterConsecutiveFailures: 3 }`; pass `false`
|
|
254
|
+
* (or `degradeAfterConsecutiveFailures: 0`) to never report degraded.
|
|
255
|
+
*/
|
|
256
|
+
syncHealth?: SyncHealthConfig | false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Tunables for sync-health reporting. See {@link Sp00kyConfig.syncHealth}. */
|
|
260
|
+
export interface SyncHealthConfig {
|
|
261
|
+
/**
|
|
262
|
+
* Number of consecutive failed sync rounds (up or down) before the status
|
|
263
|
+
* flips from `healthy` to `degraded`. A single transient failure is absorbed
|
|
264
|
+
* by the retry; only a sustained run trips the banner. Defaults to `3`. `0`
|
|
265
|
+
* disables degraded reporting entirely.
|
|
266
|
+
*/
|
|
267
|
+
degradeAfterConsecutiveFailures?: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type SyncHealthStatus = 'healthy' | 'degraded';
|
|
271
|
+
|
|
272
|
+
/** Snapshot of sync health delivered to `subscribeToSyncHealth` subscribers. */
|
|
273
|
+
export interface SyncHealth {
|
|
274
|
+
/** `'degraded'` once consecutive failures cross the configured threshold. */
|
|
275
|
+
status: SyncHealthStatus;
|
|
276
|
+
/** Consecutive failed sync rounds at the moment of this report. */
|
|
277
|
+
consecutiveFailures: number;
|
|
278
|
+
/** Classification of the most recent failure (only set while `degraded`). */
|
|
279
|
+
kind?: 'network' | 'application';
|
|
280
|
+
/** Message of the most recent failure (only set while `degraded`). */
|
|
281
|
+
error?: string;
|
|
282
|
+
/**
|
|
283
|
+
* `true` once at least one sync round has succeeded this session. Lets a UI
|
|
284
|
+
* distinguish a first-time "connecting" phase (never reached the server yet,
|
|
285
|
+
* so a cold-start failure run is expected) from a real lost connection after
|
|
286
|
+
* a working session. Never resets back to `false` once set.
|
|
287
|
+
*/
|
|
288
|
+
everConnected: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type StorageHealthStatus = 'unknown' | 'persistent' | 'memory';
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Durability of the LOCAL cache, delivered to `subscribeToStorageHealth`
|
|
295
|
+
* subscribers. Separate from {@link SyncHealth}: that one is about reaching the
|
|
296
|
+
* server, this one is about whether the local store survives a reload.
|
|
297
|
+
*
|
|
298
|
+
* Under `localEngine: 'sqlite'` the durable store is the OPFS SAHPool VFS,
|
|
299
|
+
* which only one client per bucket can hold open. When it can't be opened (a
|
|
300
|
+
* second tab of the app already has it, an insecure context, a full pool) the
|
|
301
|
+
* engine keeps working against an in-memory DB, which holds the whole dataset
|
|
302
|
+
* in RAM and loses local writes on reload. `fallback` marks exactly that case,
|
|
303
|
+
* so a UI can warn about it.
|
|
304
|
+
*/
|
|
305
|
+
export interface StorageHealth {
|
|
306
|
+
/** `'unknown'` until the local cache has opened, or for engines that don't report. */
|
|
307
|
+
status: StorageHealthStatus;
|
|
308
|
+
/**
|
|
309
|
+
* `true` only when durable storage was REQUESTED and could not be opened.
|
|
310
|
+
* Stays `false` for a configured-in-memory store (`store: 'memory'`), which
|
|
311
|
+
* is a choice rather than a failure, so a UI can key off this alone.
|
|
312
|
+
*/
|
|
313
|
+
fallback: boolean;
|
|
314
|
+
/** Reason durable storage failed (only set while `fallback` is `true`). */
|
|
315
|
+
error?: string;
|
|
316
|
+
/**
|
|
317
|
+
* Shared-tabs role, set only when `sharedTabs` is active: `'leader'` owns
|
|
318
|
+
* the OPFS worker, `'follower'` shares it over a MessagePort (its data IS
|
|
319
|
+
* durable, hence `status: 'persistent'`), `'solo'` fell back to the
|
|
320
|
+
* single-tab behavior. Absent entirely when the feature is off.
|
|
321
|
+
*/
|
|
322
|
+
role?: 'leader' | 'follower' | 'solo';
|
|
117
323
|
}
|
|
118
324
|
|
|
119
325
|
export type QueryHash = string;
|
|
@@ -143,12 +349,27 @@ export interface QueryConfig {
|
|
|
143
349
|
id: RecordId<string>;
|
|
144
350
|
/** The SURQL query string. */
|
|
145
351
|
surql: string;
|
|
352
|
+
/**
|
|
353
|
+
* Engine-neutral plan for `surql` (in-memory only; not persisted to
|
|
354
|
+
* `_00_query`). Present when the query came from the query-builder. Non-
|
|
355
|
+
* SurrealQL local engines (SQLite) materialize via `engine.select(plan)`
|
|
356
|
+
* instead of re-running `surql`, which they cannot parse.
|
|
357
|
+
*/
|
|
358
|
+
plan?: QueryPlan;
|
|
146
359
|
/** Parameters used in the query. */
|
|
147
360
|
params: Record<string, any>;
|
|
148
361
|
/** The version array representing the local state of results. */
|
|
149
362
|
localArray: RecordVersionArray;
|
|
150
363
|
/** The version array representing the remote (server) state of results. */
|
|
151
364
|
remoteArray: RecordVersionArray;
|
|
365
|
+
/**
|
|
366
|
+
* In-memory only (never persisted to `_00_query`): version array of the
|
|
367
|
+
* subquery CHILD rows pulled via `parent IS NOT NONE` edges, so the
|
|
368
|
+
* child-body sync is idempotent across polls. Kept separate from
|
|
369
|
+
* `remoteArray` so related child rows never enter the primary window /
|
|
370
|
+
* `rowCount` / `localArray`.
|
|
371
|
+
*/
|
|
372
|
+
subqueryRemoteArray?: RecordVersionArray;
|
|
152
373
|
/** Time-To-Live for this query. */
|
|
153
374
|
ttl: QueryTimeToLive;
|
|
154
375
|
/** Timestamp when the query was last accessed/active. */
|
|
@@ -159,6 +380,18 @@ export interface QueryConfig {
|
|
|
159
380
|
|
|
160
381
|
export type QueryConfigRecord = QueryConfig & { id: string };
|
|
161
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Runtime fetch status of a live query.
|
|
385
|
+
* - `idle`: registered, initial sync completed, and not currently fetching
|
|
386
|
+
* missing records — the materialized rows are authoritative (a windowed
|
|
387
|
+
* query's short result really is the end of the list).
|
|
388
|
+
* - `fetching`: the query is registering (a query is born `fetching` until its
|
|
389
|
+
* initial remote sync completes) or the sync engine is fetching/ingesting
|
|
390
|
+
* missing records for it. Any pending debounced result is flushed BEFORE the
|
|
391
|
+
* flip back to `idle`, so idle status never races ahead of the rows.
|
|
392
|
+
*/
|
|
393
|
+
export type QueryStatus = 'idle' | 'fetching';
|
|
394
|
+
|
|
162
395
|
/**
|
|
163
396
|
* Internal state of a live query.
|
|
164
397
|
*/
|
|
@@ -167,16 +400,103 @@ export interface QueryState {
|
|
|
167
400
|
config: QueryConfig;
|
|
168
401
|
/** The current cached records for this query. */
|
|
169
402
|
records: Record<string, any>[];
|
|
403
|
+
/** Set once `applyHydration` has run for this query, so the cold instant-hydrate
|
|
404
|
+
* path fires at most once per query (see DataModule.isCold/applyHydration). */
|
|
405
|
+
hydrated?: boolean;
|
|
406
|
+
/** Set once `notifyQuerySynced` has emitted for this registration lifetime.
|
|
407
|
+
* Ephemeral (unlike the persisted `updateCount`), so a re-registered query
|
|
408
|
+
* always emits at least once even when its records are unchanged — otherwise
|
|
409
|
+
* an empty re-registered window would never notify and stay "loading". */
|
|
410
|
+
syncNotified?: boolean;
|
|
170
411
|
/** Timer for TTL expiration. */
|
|
171
412
|
ttlTimer: NodeJS.Timeout | null;
|
|
172
413
|
/** TTL duration in milliseconds. */
|
|
173
414
|
ttlDurationMs: number;
|
|
174
415
|
/** Number of times the query has been updated. */
|
|
175
416
|
updateCount: number;
|
|
417
|
+
/** Timestamp (ms) of the last user-visible update, or null before the first
|
|
418
|
+
* one. Surfaced to DevTools as `lastUpdate` — must NOT be stamped on read. */
|
|
419
|
+
lastUpdatedAt: number | null;
|
|
420
|
+
/**
|
|
421
|
+
* Rolling window of the most recent materialization-step latencies (ms).
|
|
422
|
+
* Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
|
|
423
|
+
* before each persist to `_00_query`. Samples themselves are not persisted.
|
|
424
|
+
*/
|
|
425
|
+
materializationSamples: number[];
|
|
426
|
+
/** Most recent end-to-end ingest latency in ms, or null until the first ingest. */
|
|
427
|
+
lastIngestLatencyMs: number | null;
|
|
428
|
+
/** Cumulative count of ingest/materialization errors observed for this query. */
|
|
429
|
+
errorCount: number;
|
|
430
|
+
/**
|
|
431
|
+
* Ephemeral runtime fetch status. Not persisted to `_00_query`; observable
|
|
432
|
+
* via DevTools and the `useQuery` hook. `fetching` while the sync engine is
|
|
433
|
+
* pulling missing records for this query, otherwise `idle`.
|
|
434
|
+
*/
|
|
435
|
+
status: QueryStatus;
|
|
436
|
+
/**
|
|
437
|
+
* Rolling per-phase timing samples (ms), in addition to `materializationSamples`
|
|
438
|
+
* (which holds the SSP whole-ingest wall time). Keyed by `TimingPhase` minus
|
|
439
|
+
* `ssp`. Not persisted — surfaced live to DevTools + MCP via `phaseTimings`.
|
|
440
|
+
*/
|
|
441
|
+
phaseSamples: Record<string, number[]>;
|
|
442
|
+
/** Most recent sample (ms) per phase, or null. */
|
|
443
|
+
phaseLast: Record<string, number | null>;
|
|
444
|
+
/** One-shot SSP registration timings (ms). */
|
|
445
|
+
registrationTimings: RegistrationTimings;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Cap on the rolling materialization-sample window kept per query in memory. */
|
|
449
|
+
export const MATERIALIZATION_SAMPLE_WINDOW = 100;
|
|
450
|
+
|
|
451
|
+
/** Timed processing phases surfaced per query. `ssp` is the WASM-ingest wall
|
|
452
|
+
* time; the `ssp*` phases are its internal breakdown from the SSP binding. */
|
|
453
|
+
export type TimingPhase =
|
|
454
|
+
| 'ssp'
|
|
455
|
+
| 'sspStoreApply'
|
|
456
|
+
| 'sspCircuitStep'
|
|
457
|
+
| 'sspTransform'
|
|
458
|
+
| 'localFetch'
|
|
459
|
+
| 'remoteFetch'
|
|
460
|
+
| 'frontend';
|
|
461
|
+
|
|
462
|
+
/** One-shot registration timings (ms), captured once when a query registers. */
|
|
463
|
+
export interface RegistrationTimings {
|
|
464
|
+
/** SSP surql→plan parse + permission injection. */
|
|
465
|
+
parseMs: number | null;
|
|
466
|
+
/** SSP operator-DAG build. */
|
|
467
|
+
planMs: number | null;
|
|
468
|
+
/** SSP initial snapshot evaluation. */
|
|
469
|
+
snapshotMs: number | null;
|
|
470
|
+
/** Wall time of `cache.registerQuery` (register_view round-trip). */
|
|
471
|
+
wallMs: number | null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Percentile summary for one timed phase, surfaced to DevTools + MCP. */
|
|
475
|
+
export interface PhaseStat {
|
|
476
|
+
lastMs: number | null;
|
|
477
|
+
p50: number | null;
|
|
478
|
+
p90: number | null;
|
|
479
|
+
p99: number | null;
|
|
480
|
+
count: number;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Per-query processing-time breakdown surfaced via DevTools panel + MCP. */
|
|
484
|
+
export interface QueryTimings {
|
|
485
|
+
ssp: PhaseStat;
|
|
486
|
+
sspStoreApply: PhaseStat;
|
|
487
|
+
sspCircuitStep: PhaseStat;
|
|
488
|
+
sspTransform: PhaseStat;
|
|
489
|
+
localFetch: PhaseStat;
|
|
490
|
+
remoteFetch: PhaseStat;
|
|
491
|
+
frontend: PhaseStat;
|
|
492
|
+
registration: RegistrationTimings;
|
|
493
|
+
updateCount: number;
|
|
494
|
+
errorCount: number;
|
|
176
495
|
}
|
|
177
496
|
|
|
178
497
|
// Callback types
|
|
179
498
|
export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
|
|
499
|
+
export type QueryStatusCallback = (status: QueryStatus) => void;
|
|
180
500
|
export type MutationCallback = (mutations: UpEvent[]) => void;
|
|
181
501
|
|
|
182
502
|
export type MutationEventType = 'create' | 'update' | 'delete';
|
|
@@ -209,6 +529,13 @@ export interface RunOptions {
|
|
|
209
529
|
assignedTo?: string;
|
|
210
530
|
max_retries?: number;
|
|
211
531
|
retry_strategy?: 'linear' | 'exponential';
|
|
532
|
+
/** Timeout in seconds for the backend HTTP call. Only used if the backend allows timeout override. */
|
|
533
|
+
timeout?: number;
|
|
534
|
+
/**
|
|
535
|
+
* Minimum delay in milliseconds before the job is eligible to run. While
|
|
536
|
+
* delayed the job stays pending (enqueued) and can still be killed.
|
|
537
|
+
*/
|
|
538
|
+
delay?: number;
|
|
212
539
|
}
|
|
213
540
|
|
|
214
541
|
/**
|
|
@@ -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',
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
|
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]
|
|
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]
|
|
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
|
-
|
|
86
|
+
decodeFromSp00ky(schema, relation.to, item)
|
|
87
87
|
);
|
|
88
88
|
} else {
|
|
89
|
-
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
|
|
106
|
-
if (ms) return
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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: '
|
|
190
|
+
Category: 'sp00ky-client::utils::withRetry',
|
|
169
191
|
},
|
|
170
192
|
'Retrying DB operation'
|
|
171
193
|
);
|
package/src/utils/parser.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ColumnSchema
|
|
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
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { semverGt } from './semver';
|
|
3
|
+
|
|
4
|
+
describe('semverGt', () => {
|
|
5
|
+
it('compares strictly greater', () => {
|
|
6
|
+
expect(semverGt('1.0.1', '1.0.0')).toBe(true);
|
|
7
|
+
expect(semverGt('1.1.0', '1.0.9')).toBe(true);
|
|
8
|
+
expect(semverGt('2.0.0', '1.9.9')).toBe(true);
|
|
9
|
+
expect(semverGt('1.10.0', '1.9.0')).toBe(true); // numeric, not lexicographic
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('equal and lower are not greater', () => {
|
|
13
|
+
expect(semverGt('1.0.0', '1.0.0')).toBe(false);
|
|
14
|
+
expect(semverGt('1.0.0', '1.0.1')).toBe(false);
|
|
15
|
+
expect(semverGt('0.9.9', '1.0.0')).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('missing parts default to zero', () => {
|
|
19
|
+
expect(semverGt('1.2', '1.2.0')).toBe(false);
|
|
20
|
+
expect(semverGt('1.2.1', '1.2')).toBe(true);
|
|
21
|
+
expect(semverGt('2', '1.9.9')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('junk never compares greater', () => {
|
|
25
|
+
expect(semverGt('abc', '1.0.0')).toBe(false);
|
|
26
|
+
expect(semverGt('1.0.0', 'abc')).toBe(false);
|
|
27
|
+
expect(semverGt('', '0.0.0')).toBe(false);
|
|
28
|
+
expect(semverGt(null, undefined)).toBe(false);
|
|
29
|
+
expect(semverGt('1.0.0-beta', '0.9.0')).toBe(false); // prerelease unsupported
|
|
30
|
+
expect(semverGt('1.2.3.4', '1.0.0')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|