@spooky-sync/core 0.0.1-canary.234 → 0.0.1-canary.236
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 +3 -2
- package/dist/index.d.ts +109 -24
- package/dist/index.js +377 -105
- package/dist/types.d.ts +5 -0
- package/package.json +3 -3
- package/src/modules/data/index.ts +7 -0
- package/src/modules/sync/sync.health.test.ts +34 -26
- package/src/modules/sync/sync.ts +211 -30
- package/src/modules/sync/utils.test.ts +53 -0
- package/src/modules/sync/utils.ts +77 -0
- package/src/services/database/database.query-timeout.test.ts +58 -1
- package/src/services/database/database.ts +106 -67
- package/src/services/database/remote.ts +30 -0
- package/src/sp00ky.init-query.test.ts +10 -10
- package/src/sp00ky.ts +47 -28
- package/src/types.ts +5 -0
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@ Local mutations are applied optimistically, ingested into a DBSP layer that driv
|
|
|
23
23
|
|
|
24
24
|
## Key exports (`src/index.ts`)
|
|
25
25
|
|
|
26
|
-
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, idOrSelector)`, `query(table, opts?)`, `run(backend, route, payload)`, `bucket(name)`, `useRemote(fn)`, `authenticate(token)`, `signOut()`. Plus `pendingMutationCount` and `subscribeToPendingMutations(cb)`.
|
|
26
|
+
- `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, idOrSelector)`, `query(table, opts?)`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `signOut()`. Plus `pendingMutationCount` and `subscribeToPendingMutations(cb)`.
|
|
27
27
|
- `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
|
|
28
28
|
- `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
|
|
29
29
|
- `AuthService` — token management, sign-in/sign-out events.
|
|
@@ -37,7 +37,8 @@ Local mutations are applied optimistically, ingested into a DBSP layer that driv
|
|
|
37
37
|
- **Mutations are optimistic.** `db.create` / `db.update` / `db.delete` return immediately; the queue drains in the background. Inspect progress via `pendingMutationCount`.
|
|
38
38
|
- **Record IDs are full strings.** `db.create('thread:abc123', {...})` — *not* `db.create('thread', { id: 'abc123' })`. The first arg is `'<table>:<id>'`. Generate IDs with `Uuid` from `surrealdb` (re-exported by `client-solid`).
|
|
39
39
|
- **`db.update` takes `(table, id, payload, options?)`**. The `debounced` option (`{ debounced: true }` or `{ debounced: { delay, key } }`) coalesces rapid updates — use it for CRDT text/title fields.
|
|
40
|
-
- **`db.
|
|
40
|
+
- **`db.remoteQuery(sql, vars?)` is the one-shot remote read.** It runs one SurrealQL statement through the client's remote path (connect gate, per-statement timeout, concurrency limit) and returns the SurrealDB result array; results are not synced into the local cache. Use it for counts and other reads the query builder can't express.
|
|
41
|
+
- **`db.useRemote(fn)` hands out the bare SDK client.** Same cache bypass, but it skips the connect gate (a call during a reconnect fails with "Specify a namespace to use"). Reserve it for SDK features `remoteQuery` cannot reach (transactions, `live`).
|
|
41
42
|
- **`@parent` columns are auto-populated.** Defined in the `.surql` schema as `record<user>` with the `-- @parent` annotation; never write them yourself — they're set from the auth context server-side.
|
|
42
43
|
|
|
43
44
|
## Module map (under `src/modules/`)
|
package/dist/index.d.ts
CHANGED
|
@@ -30,14 +30,39 @@ declare abstract class AbstractDatabaseService {
|
|
|
30
30
|
getClient(): Surreal$1;
|
|
31
31
|
getEvents(): DatabaseEventSystem;
|
|
32
32
|
tx(): Promise<SurrealTransaction>;
|
|
33
|
-
private queryQueue;
|
|
34
33
|
/**
|
|
35
|
-
*
|
|
34
|
+
* How many statements may be in flight at once.
|
|
36
35
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* `1` serializes every statement behind the previous one. The local engines
|
|
37
|
+
* need that: overlapping ops trip the WASM / SQLite transaction layer. The
|
|
38
|
+
* remote service raises it (`RemoteDatabaseService`): a SurrealDB socket
|
|
39
|
+
* multiplexes requests by id, and serializing them capped the client at one
|
|
40
|
+
* statement per round trip (~5/s at 170ms RTT), so the `_00_list_ref` poll
|
|
41
|
+
* alone could hold every registration behind it and one slow one-shot read
|
|
42
|
+
* stalled the whole client. The scheduler's `MAX_CONCURRENT_DOWN` only means
|
|
43
|
+
* something when this is above 1.
|
|
44
|
+
*/
|
|
45
|
+
protected maxConcurrentQueries: number;
|
|
46
|
+
private inFlightQueries;
|
|
47
|
+
private queryWaiters;
|
|
48
|
+
/** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
|
|
49
|
+
private acquireQuerySlot;
|
|
50
|
+
private releaseQuerySlot;
|
|
51
|
+
/**
|
|
52
|
+
* Hook run right before a statement is sent, inside its slot. The remote
|
|
53
|
+
* service uses it to hold statements while a connect is in flight, so a
|
|
54
|
+
* query never runs on a socket that is open but has not had its namespace
|
|
55
|
+
* and token applied yet. The default does nothing.
|
|
56
|
+
*/
|
|
57
|
+
protected beforeQuery(): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Execute a statement under {@link maxConcurrentQueries}.
|
|
60
|
+
*
|
|
61
|
+
* A statement that never settles would hold its slot forever, and with a
|
|
62
|
+
* limit of 1 that blocks every later statement behind it. {@link
|
|
63
|
+
* queryTimeoutMs} bounds each one: on expiry this promise rejects and the
|
|
64
|
+
* slot is released, even though the underlying RPC is still parked in the
|
|
65
|
+
* SDK's pending map.
|
|
41
66
|
*/
|
|
42
67
|
query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
|
|
43
68
|
execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T>;
|
|
@@ -117,6 +142,19 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
|
|
|
117
142
|
* `disconnected`, which is what drives the supervisor's revive loop.
|
|
118
143
|
*/
|
|
119
144
|
forceClose(): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Hold statements while a connect is in flight.
|
|
147
|
+
*
|
|
148
|
+
* `connect()` opens the socket, then selects the namespace/database, then
|
|
149
|
+
* authenticates - three round trips. The SDK considers the connection ready
|
|
150
|
+
* after the first, so a query queued during the other two ran on a socket
|
|
151
|
+
* with no namespace ("Specify a namespace to use") or, worse, no identity.
|
|
152
|
+
* Seen 2026-09-06 after the supervisor rebuilt a socket: a collection
|
|
153
|
+
* recount failed with exactly that error. Waiting on the in-flight connect
|
|
154
|
+
* costs nothing when no connect is running; a failed connect is not
|
|
155
|
+
* swallowed here - the query then fails on its own, as before.
|
|
156
|
+
*/
|
|
157
|
+
protected beforeQuery(): Promise<void>;
|
|
120
158
|
/**
|
|
121
159
|
* Open (or re-open) the remote connection.
|
|
122
160
|
*
|
|
@@ -1389,6 +1427,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1389
1427
|
private listRefPollInFlight;
|
|
1390
1428
|
readonly refSyncIntervalMs: number;
|
|
1391
1429
|
private listRefIdleStreak;
|
|
1430
|
+
private listRefPolledAt;
|
|
1392
1431
|
private stillRemoteStreaks;
|
|
1393
1432
|
private lastLiveEventAt;
|
|
1394
1433
|
private _liveRetryCount;
|
|
@@ -1567,9 +1606,10 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1567
1606
|
private startListRefPoll;
|
|
1568
1607
|
private stopListRefPoll;
|
|
1569
1608
|
/**
|
|
1570
|
-
* One poll cycle: refetch `_00_list_ref` for
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1609
|
+
* One poll cycle: refetch `_00_list_ref` for the active queries that are due,
|
|
1610
|
+
* one round trip per chunk (see `planListRefPollChunks`). Returns whether ANY
|
|
1611
|
+
* query's remoteArray actually changed — the scheduler uses this to drive the
|
|
1612
|
+
* adaptive idle backoff.
|
|
1573
1613
|
*
|
|
1574
1614
|
* Also the ONLY health signal that runs while the page is idle. Sync health is
|
|
1575
1615
|
* otherwise activity-driven (mutations/registrations via the scheduler,
|
|
@@ -1582,14 +1622,37 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1582
1622
|
*/
|
|
1583
1623
|
private pollListRefForActiveQueries;
|
|
1584
1624
|
/**
|
|
1585
|
-
*
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1625
|
+
* The server's current `_00_list_ref` state for a chunk of queries in ONE
|
|
1626
|
+
* round trip: primary edges, subquery-child edges and `_00_query.rowCount`.
|
|
1627
|
+
* Every hash whose query still exists gets an entry (empty arrays and
|
|
1628
|
+
* `rowCount: null` when nothing was readable), so a caller can tell "no
|
|
1629
|
+
* edges" from "not asked". A hash is left out only when the select did not
|
|
1630
|
+
* come back as an array at all — the old per-query code skipped the apply in
|
|
1631
|
+
* that case too, since a non-array is not a membership set.
|
|
1632
|
+
*
|
|
1633
|
+
* Guarded against a batch that silently matches nothing: if the select
|
|
1634
|
+
* returned no edge at all while the client holds edges for a query the
|
|
1635
|
+
* server still reports rows for, the batch is not trusted and the chunk is
|
|
1636
|
+
* re-read one query at a time with the single-query select. An empty set
|
|
1637
|
+
* taken at face value would be recorded as the removal of every row.
|
|
1591
1638
|
*/
|
|
1592
|
-
private
|
|
1639
|
+
private fetchListRefSnapshots;
|
|
1640
|
+
/**
|
|
1641
|
+
* Single-query form of {@link fetchListRefSnapshots}: the selects the
|
|
1642
|
+
* registration path has always used, in one statement batch. `null` when
|
|
1643
|
+
* the primary select did not come back as an array.
|
|
1644
|
+
*/
|
|
1645
|
+
private fetchListRefSnapshot;
|
|
1646
|
+
/**
|
|
1647
|
+
* Land one poll snapshot for `queryHash`: diff the server's edges against the
|
|
1648
|
+
* cached `remoteArray`, sync any added/updated rows through the SyncEngine,
|
|
1649
|
+
* persist the new remoteArray and converge the subquery children. This is
|
|
1650
|
+
* the same shape `createRemoteQuery` does for its initial fetch and what
|
|
1651
|
+
* `handleRemoteListRefChange` does per-LIVE-event — we reuse it on a timer
|
|
1652
|
+
* as a fallback for missed LIVE notifications. Returns whether membership
|
|
1653
|
+
* changed.
|
|
1654
|
+
*/
|
|
1655
|
+
private applyListRefSnapshot;
|
|
1593
1656
|
/**
|
|
1594
1657
|
* Resolve the current `_00_list_ref` table name for the active auth
|
|
1595
1658
|
* context. Public so the `createRemoteQuery` initial-fetch path can
|
|
@@ -1765,6 +1828,13 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1765
1828
|
* query to `fetching` or skew its DevTools timings.
|
|
1766
1829
|
*/
|
|
1767
1830
|
private syncSubqueryChildren;
|
|
1831
|
+
/**
|
|
1832
|
+
* Converge a `.related()` query's child bodies onto `fresh` (the server's
|
|
1833
|
+
* current child edges). Shared by the registration path above and the poll,
|
|
1834
|
+
* which reads the child edges in its batched round trip. See
|
|
1835
|
+
* {@link syncSubqueryChildren} for the deletion-safety rules.
|
|
1836
|
+
*/
|
|
1837
|
+
private applySubqueryChildren;
|
|
1768
1838
|
heartbeatQuery(queryHash: string): Promise<void>;
|
|
1769
1839
|
private cleanupQuery;
|
|
1770
1840
|
}
|
|
@@ -2691,14 +2761,20 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2691
2761
|
query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
|
|
2692
2762
|
private initQuery;
|
|
2693
2763
|
/**
|
|
2694
|
-
* Background tail of {@link initQuery}:
|
|
2695
|
-
* `config.instantHydrate`, and only when
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
2699
|
-
*
|
|
2700
|
-
*
|
|
2701
|
-
*
|
|
2764
|
+
* Background tail of {@link initQuery}: enqueue the `register` down-event,
|
|
2765
|
+
* then instant-hydrate (opt-in via `config.instantHydrate`, and only when
|
|
2766
|
+
* the query is cold). Never rejects — both halves catch and log, so
|
|
2767
|
+
* `void`-ing the returned promise can't produce an unhandled rejection. By
|
|
2768
|
+
* default (hydrate off) the register lifecycle is the single freshness path;
|
|
2769
|
+
* the one-shot fetch is an optimization apps enable explicitly, and it runs
|
|
2770
|
+
* regardless of preload state — cache-first delivery never depends on WHY
|
|
2771
|
+
* rows are cached.
|
|
2772
|
+
*
|
|
2773
|
+
* Register goes first on purpose. The hydrate used to run strictly before
|
|
2774
|
+
* the enqueue, so a windowed list's one-shot read (a full-table scan when
|
|
2775
|
+
* the app has no matching index) held its own registration back by seconds
|
|
2776
|
+
* per window. Now whichever answer lands first paints; a hydrate that
|
|
2777
|
+
* arrives after the registration is dropped (`applyHydration`).
|
|
2702
2778
|
*/
|
|
2703
2779
|
private finishQueryInit;
|
|
2704
2780
|
/**
|
|
@@ -2769,6 +2845,15 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2769
2845
|
*/
|
|
2770
2846
|
isLocalReady(): boolean;
|
|
2771
2847
|
useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T>;
|
|
2848
|
+
/**
|
|
2849
|
+
* Run one raw SurrealQL statement on the remote through the client's own
|
|
2850
|
+
* query path: the connect gate (it never runs on a socket whose namespace
|
|
2851
|
+
* and token are not applied yet), the per-statement timeout and the
|
|
2852
|
+
* concurrency limit. Prefer this over {@link useRemote} for one-shot reads
|
|
2853
|
+
* such as counts; `useRemote` hands out the bare SDK client and raced a
|
|
2854
|
+
* reconnect with "Specify a namespace to use".
|
|
2855
|
+
*/
|
|
2856
|
+
remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
|
|
2772
2857
|
/**
|
|
2773
2858
|
* Mint the salt used for query-id hashing, so two sessions registering the
|
|
2774
2859
|
* same logical query get distinct `_00_query` rows.
|