@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 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.useRemote(fn)` bypasses the cache.** Use only for queries that can't be expressed via the query builder (e.g. graph traversals across `RELATE` edges). Results are not synced into the local cache.
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
- * Execute a query with serialized execution to prevent WASM transaction issues.
34
+ * How many statements may be in flight at once.
36
35
  *
37
- * Serialization means every query waits on the previous one, so a call that
38
- * never settles blocks the whole chain forever. {@link queryTimeoutMs} bounds
39
- * each link: on expiry this promise rejects and the chain moves on, even
40
- * though the underlying RPC is still parked in the SDK's pending map.
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 every active query. Returns
1571
- * whether ANY query's remoteArray actually changed — the scheduler uses this
1572
- * to drive the adaptive idle backoff.
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
- * Pull the upstream list_ref entries for `queryHash`, diff them
1586
- * against the local `remoteArray` cache, sync any added/updated rows
1587
- * through the SyncEngine, then persist the new remoteArray. This is
1588
- * the same shape `createRemoteQuery` does for its initial fetch and
1589
- * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
1590
- * it on a timer as a fallback for missed LIVE notifications.
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 refetchListRefForQuery;
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}: instant-hydrate (opt-in via
2695
- * `config.instantHydrate`, and only when the query is cold) followed by
2696
- * enqueuing the `register` down-event. Never rejects — both halves catch and
2697
- * log, so `void`-ing the returned promise can't produce an unhandled
2698
- * rejection. By default (hydrate off) the register lifecycle is the single
2699
- * freshness path; the one-shot fetch is an optimization apps enable
2700
- * explicitly, and it runs regardless of preload state — cache-first delivery
2701
- * never depends on WHY rows are cached.
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.