@spooky-sync/core 0.0.1-canary.235 → 0.0.1-canary.237

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,22 +30,40 @@ 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.
36
- *
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.
34
+ * How many statements may be in flight at once.
35
+ *
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.
41
56
  */
57
+ protected beforeQuery(): Promise<void>;
42
58
  /**
43
- * Hook run inside the query queue, right before a statement is sent. The
44
- * remote service uses it to hold statements while a connect is in flight,
45
- * so a query never runs on a socket that is open but has not had its
46
- * namespace and token applied yet. The default does nothing.
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.
47
66
  */
48
- protected beforeQuery(): Promise<void>;
49
67
  query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
50
68
  execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T>;
51
69
  close(): Promise<void>;
@@ -1409,6 +1427,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
1409
1427
  private listRefPollInFlight;
1410
1428
  readonly refSyncIntervalMs: number;
1411
1429
  private listRefIdleStreak;
1430
+ private listRefPolledAt;
1412
1431
  private stillRemoteStreaks;
1413
1432
  private lastLiveEventAt;
1414
1433
  private _liveRetryCount;
@@ -1587,9 +1606,10 @@ declare class Sp00kySync<S extends SchemaStructure> {
1587
1606
  private startListRefPoll;
1588
1607
  private stopListRefPoll;
1589
1608
  /**
1590
- * One poll cycle: refetch `_00_list_ref` for every active query. Returns
1591
- * whether ANY query's remoteArray actually changed — the scheduler uses this
1592
- * 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.
1593
1613
  *
1594
1614
  * Also the ONLY health signal that runs while the page is idle. Sync health is
1595
1615
  * otherwise activity-driven (mutations/registrations via the scheduler,
@@ -1602,14 +1622,37 @@ declare class Sp00kySync<S extends SchemaStructure> {
1602
1622
  */
1603
1623
  private pollListRefForActiveQueries;
1604
1624
  /**
1605
- * Pull the upstream list_ref entries for `queryHash`, diff them
1606
- * against the local `remoteArray` cache, sync any added/updated rows
1607
- * through the SyncEngine, then persist the new remoteArray. This is
1608
- * the same shape `createRemoteQuery` does for its initial fetch and
1609
- * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
1610
- * 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.
1638
+ */
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.
1611
1644
  */
1612
- private refetchListRefForQuery;
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;
1613
1656
  /**
1614
1657
  * Resolve the current `_00_list_ref` table name for the active auth
1615
1658
  * context. Public so the `createRemoteQuery` initial-fetch path can
@@ -1785,6 +1828,13 @@ declare class Sp00kySync<S extends SchemaStructure> {
1785
1828
  * query to `fetching` or skew its DevTools timings.
1786
1829
  */
1787
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;
1788
1838
  heartbeatQuery(queryHash: string): Promise<void>;
1789
1839
  private cleanupQuery;
1790
1840
  }
@@ -2711,14 +2761,20 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2711
2761
  query<Table extends TableNames<S>>(table: Table, options: QueryOptions<TableModel<GetTable<S, Table>>, false>, ttl?: QueryTimeToLive): QueryBuilder<S, Table, Sp00kyQueryResultPromise>;
2712
2762
  private initQuery;
2713
2763
  /**
2714
- * Background tail of {@link initQuery}: instant-hydrate (opt-in via
2715
- * `config.instantHydrate`, and only when the query is cold) followed by
2716
- * enqueuing the `register` down-event. Never rejects — both halves catch and
2717
- * log, so `void`-ing the returned promise can't produce an unhandled
2718
- * rejection. By default (hydrate off) the register lifecycle is the single
2719
- * freshness path; the one-shot fetch is an optimization apps enable
2720
- * explicitly, and it runs regardless of preload state — cache-first delivery
2721
- * 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`).
2722
2778
  */
2723
2779
  private finishQueryInit;
2724
2780
  /**
@@ -2789,6 +2845,15 @@ declare class Sp00kyClient<S extends SchemaStructure> {
2789
2845
  */
2790
2846
  isLocalReady(): boolean;
2791
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>;
2792
2857
  /**
2793
2858
  * Mint the salt used for query-id hashing, so two sessions registering the
2794
2859
  * same logical query get distinct `_00_query` rows.