@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.
@@ -23,13 +23,20 @@ const silentLogger: any = (() => {
23
23
 
24
24
  class TestService extends AbstractDatabaseService {
25
25
  protected eventType = DatabaseEventTypes.RemoteQuery;
26
- constructor(client: any, timeoutMs: number) {
26
+ constructor(client: any, timeoutMs: number, maxConcurrent = 1) {
27
27
  super(client, silentLogger, createDatabaseEventSystem());
28
28
  this.queryTimeoutMs = timeoutMs;
29
+ this.maxConcurrentQueries = maxConcurrent;
29
30
  }
30
31
  async connect(): Promise<void> {}
31
32
  }
32
33
 
34
+ function deferred<T>() {
35
+ let resolve!: (v: T) => void;
36
+ const promise = new Promise<T>((r) => (resolve = r));
37
+ return { promise, resolve };
38
+ }
39
+
33
40
  describe('AbstractDatabaseService query timeout', () => {
34
41
  beforeEach(() => vi.useFakeTimers());
35
42
  afterEach(() => vi.useRealTimers());
@@ -80,4 +87,54 @@ describe('AbstractDatabaseService query timeout', () => {
80
87
  // No timer was armed, so a slow-but-alive query is never cut off.
81
88
  expect(vi.getTimerCount()).toBe(0);
82
89
  });
90
+
91
+ it('keeps statements strictly ordered at the default limit of 1', async () => {
92
+ const first = deferred<unknown[]>();
93
+ const client = { query: vi.fn().mockImplementationOnce(() => first.promise).mockResolvedValue([['second']]) };
94
+ const svc = new TestService(client, 0);
95
+
96
+ const a = svc.query('SELECT 1');
97
+ const b = svc.query('SELECT 2');
98
+ await vi.advanceTimersByTimeAsync(0);
99
+ // The second statement has not been handed to the SDK while the first is in flight.
100
+ expect(client.query).toHaveBeenCalledTimes(1);
101
+ first.resolve([['first']]);
102
+ await expect(a).resolves.toEqual([['first']]);
103
+ await expect(b).resolves.toEqual([['second']]);
104
+ expect(client.query).toHaveBeenCalledTimes(2);
105
+ });
106
+
107
+ it('runs statements concurrently up to maxConcurrentQueries and queues the rest', async () => {
108
+ const d1 = deferred<unknown[]>();
109
+ const d2 = deferred<unknown[]>();
110
+ const client = {
111
+ query: vi
112
+ .fn()
113
+ .mockImplementationOnce(() => d1.promise)
114
+ .mockImplementationOnce(() => d2.promise)
115
+ .mockResolvedValue([['third']]),
116
+ };
117
+ const svc = new TestService(client, 0, 2);
118
+
119
+ const a = svc.query('SELECT 1');
120
+ const b = svc.query('SELECT 2');
121
+ const c = svc.query('SELECT 3');
122
+ await vi.advanceTimersByTimeAsync(0);
123
+ // Two in flight at once; the third waits for a slot.
124
+ expect(client.query).toHaveBeenCalledTimes(2);
125
+ d2.resolve([['second']]);
126
+ await expect(b).resolves.toEqual([['second']]);
127
+ await vi.advanceTimersByTimeAsync(0);
128
+ expect(client.query).toHaveBeenCalledTimes(3);
129
+ d1.resolve([['first']]);
130
+ await expect(a).resolves.toEqual([['first']]);
131
+ await expect(c).resolves.toEqual([['third']]);
132
+ });
133
+
134
+ it('releases the slot when a statement fails', async () => {
135
+ const client = { query: vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValue([['ok']]) };
136
+ const svc = new TestService(client, 0);
137
+ await expect(svc.query('SELECT 1')).rejects.toThrow('boom');
138
+ await expect(svc.query('SELECT 2')).resolves.toEqual([['ok']]);
139
+ });
83
140
  });
@@ -50,88 +50,118 @@ export abstract class AbstractDatabaseService {
50
50
  return this.client.beginTransaction();
51
51
  }
52
52
 
53
- private queryQueue: Promise<void> = Promise.resolve();
54
-
55
53
  /**
56
- * Execute a query with serialized execution to prevent WASM transaction issues.
54
+ * How many statements may be in flight at once.
57
55
  *
58
- * Serialization means every query waits on the previous one, so a call that
59
- * never settles blocks the whole chain forever. {@link queryTimeoutMs} bounds
60
- * each link: on expiry this promise rejects and the chain moves on, even
61
- * though the underlying RPC is still parked in the SDK's pending map.
56
+ * `1` serializes every statement behind the previous one. The local engines
57
+ * need that: overlapping ops trip the WASM / SQLite transaction layer. The
58
+ * remote service raises it (`RemoteDatabaseService`): a SurrealDB socket
59
+ * multiplexes requests by id, and serializing them capped the client at one
60
+ * statement per round trip (~5/s at 170ms RTT), so the `_00_list_ref` poll
61
+ * alone could hold every registration behind it and one slow one-shot read
62
+ * stalled the whole client. The scheduler's `MAX_CONCURRENT_DOWN` only means
63
+ * something when this is above 1.
62
64
  */
65
+ protected maxConcurrentQueries = 1;
66
+ private inFlightQueries = 0;
67
+ private queryWaiters: Array<() => void> = [];
68
+
69
+ /** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
70
+ private acquireQuerySlot(): Promise<void> {
71
+ if (this.inFlightQueries < this.maxConcurrentQueries) {
72
+ this.inFlightQueries++;
73
+ return Promise.resolve();
74
+ }
75
+ return new Promise<void>((resolve) => {
76
+ this.queryWaiters.push(() => {
77
+ this.inFlightQueries++;
78
+ resolve();
79
+ });
80
+ });
81
+ }
82
+
83
+ private releaseQuerySlot(): void {
84
+ this.inFlightQueries--;
85
+ const next = this.queryWaiters.shift();
86
+ if (next) next();
87
+ }
88
+
63
89
  /**
64
- * Hook run inside the query queue, right before a statement is sent. The
65
- * remote service uses it to hold statements while a connect is in flight,
66
- * so a query never runs on a socket that is open but has not had its
67
- * namespace and token applied yet. The default does nothing.
90
+ * Hook run right before a statement is sent, inside its slot. The remote
91
+ * service uses it to hold statements while a connect is in flight, so a
92
+ * query never runs on a socket that is open but has not had its namespace
93
+ * and token applied yet. The default does nothing.
68
94
  */
69
95
  protected async beforeQuery(): Promise<void> {}
70
96
 
97
+ /**
98
+ * Execute a statement under {@link maxConcurrentQueries}.
99
+ *
100
+ * A statement that never settles would hold its slot forever, and with a
101
+ * limit of 1 that blocks every later statement behind it. {@link
102
+ * queryTimeoutMs} bounds each one: on expiry this promise rejects and the
103
+ * slot is released, even though the underlying RPC is still parked in the
104
+ * SDK's pending map.
105
+ */
71
106
  async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
72
- return new Promise((resolve, reject) => {
73
- this.queryQueue = this.queryQueue
74
- // oxlint-disable-next-line promise/always-return
75
- .then(async () => {
76
- await this.beforeQuery();
77
- const startTime = performance.now();
78
- try {
79
- this.logger.debug(
80
- { query, vars, Category: 'sp00ky-client::Database::query' },
81
- 'Executing query'
82
- );
83
- const pending = this.client.query(query, vars);
84
- // In SurrealDB 2.0, .query() collects results by default.
85
- // We cast to T directly as proper typing depends on the caller knowing the return structure.
86
- // "timed out" in the message is load-bearing: `classifySyncError`
87
- // keys off it to classify this as `network` so the sync queues
88
- // retry rather than rolling the mutation back.
89
- const result = (await withTimeout(
90
- pending as unknown as Promise<T>,
91
- this.queryTimeoutMs,
92
- () => this.timeoutError(query)
93
- )) as T;
94
- const duration = performance.now() - startTime;
95
-
96
- // Emit query event
97
- this.events.emit(this.eventType, {
98
- query,
99
- vars,
100
- duration,
101
- success: true,
102
- timestamp: Date.now(),
103
- });
104
-
105
- resolve(result);
106
- this.logger.trace(
107
- { query, result, Category: 'sp00ky-client::Database::query' },
108
- 'Query executed successfully'
109
- );
110
- } catch (err) {
111
- const duration = performance.now() - startTime;
112
-
113
- // Emit query event with error
114
- this.events.emit(this.eventType, {
115
- query,
116
- vars,
117
- duration,
118
- success: false,
119
- error: err instanceof Error ? err.message : String(err),
120
- timestamp: Date.now(),
121
- });
122
-
123
- this.logger.error(
124
- { query, vars, err, Category: 'sp00ky-client::Database::query' },
125
- 'Query execution failed'
126
- );
127
- // oxlint-disable-next-line no-multiple-resolved -- resolve/reject are in try/catch, mutually exclusive
128
- reject(err);
129
- }
130
- })
131
- .catch(() => {
132
- // Ignore queue errors to keep the chain alive; the specific promise was rejected above.
107
+ await this.acquireQuerySlot();
108
+ try {
109
+ await this.beforeQuery();
110
+ const startTime = performance.now();
111
+ try {
112
+ this.logger.debug(
113
+ { query, vars, Category: 'sp00ky-client::Database::query' },
114
+ 'Executing query'
115
+ );
116
+ const pending = this.client.query(query, vars);
117
+ // In SurrealDB 2.0, .query() collects results by default.
118
+ // We cast to T directly as proper typing depends on the caller knowing the return structure.
119
+ // "timed out" in the message is load-bearing: `classifySyncError`
120
+ // keys off it to classify this as `network` so the sync queues
121
+ // retry rather than rolling the mutation back.
122
+ const result = (await withTimeout(
123
+ pending as unknown as Promise<T>,
124
+ this.queryTimeoutMs,
125
+ () => this.timeoutError(query)
126
+ )) as T;
127
+ const duration = performance.now() - startTime;
128
+
129
+ // Emit query event
130
+ this.events.emit(this.eventType, {
131
+ query,
132
+ vars,
133
+ duration,
134
+ success: true,
135
+ timestamp: Date.now(),
133
136
  });
134
- });
137
+
138
+ this.logger.trace(
139
+ { query, result, Category: 'sp00ky-client::Database::query' },
140
+ 'Query executed successfully'
141
+ );
142
+ return result;
143
+ } catch (err) {
144
+ const duration = performance.now() - startTime;
145
+
146
+ // Emit query event with error
147
+ this.events.emit(this.eventType, {
148
+ query,
149
+ vars,
150
+ duration,
151
+ success: false,
152
+ error: err instanceof Error ? err.message : String(err),
153
+ timestamp: Date.now(),
154
+ });
155
+
156
+ this.logger.error(
157
+ { query, vars, err, Category: 'sp00ky-client::Database::query' },
158
+ 'Query execution failed'
159
+ );
160
+ throw err;
161
+ }
162
+ } finally {
163
+ this.releaseQuerySlot();
164
+ }
135
165
  }
136
166
 
137
167
  async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
@@ -30,6 +30,13 @@ export function resolveReconnectConfig(
30
30
  /** Transport events the SDK publishes, mapped 1:1 to {@link ConnectionState}. */
31
31
  export type RemoteConnectionEvent = ConnectionState | 'error';
32
32
 
33
+ /**
34
+ * Statements the remote service keeps in flight at once. Enough for the sync
35
+ * scheduler's `MAX_CONCURRENT_DOWN` registrations plus the list_ref poll and
36
+ * an app's own one-shot reads, small enough to stay polite to the server.
37
+ */
38
+ const REMOTE_MAX_CONCURRENT_QUERIES = 6;
39
+
33
40
  export class RemoteDatabaseService extends AbstractDatabaseService {
34
41
  private config: Sp00kyConfig<any>['database'];
35
42
  protected eventType = DatabaseEventTypes.RemoteQuery;
@@ -90,6 +97,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
90
97
  this.config = config;
91
98
  this.reconnectConfig = resolveReconnectConfig(config.reconnect);
92
99
  this.queryTimeoutMs = Math.max(0, config.queryTimeoutMs ?? 60_000);
100
+ this.maxConcurrentQueries = REMOTE_MAX_CONCURRENT_QUERIES;
93
101
  }
94
102
 
95
103
  getConfig(): Sp00kyConfig<any>['database'] {
@@ -43,16 +43,16 @@ describe('Sp00kyClient.initQuery structural invariants', () => {
43
43
  );
44
44
  });
45
45
 
46
- it('finishQueryInit hydrates strictly before enqueuing register', () => {
46
+ it('finishQueryInit enqueues register before the hydrate fetch', () => {
47
47
  const body = methodBody('finishQueryInit');
48
48
  const fetchIdx = body.indexOf('this.remote.query');
49
49
  const enqueueIdx = body.indexOf('this.sync.enqueueDownEvent');
50
50
  expect(fetchIdx).toBeGreaterThanOrEqual(0);
51
51
  expect(enqueueIdx).toBeGreaterThanOrEqual(0);
52
52
  expect(
53
- fetchIdx,
54
- 'hydrate must settle before the register enqueue so a stale one-shot snapshot can never land after the authoritative _00_list_ref overwrite'
55
- ).toBeLessThan(enqueueIdx);
53
+ enqueueIdx,
54
+ 'the authoritative register must never wait on the one-shot hydrate read (a windowed list paid a full-table scan per window before its own registration could start); applyHydration drops a hydrate that lands after the registration'
55
+ ).toBeLessThan(fetchIdx);
56
56
  });
57
57
 
58
58
  it('finishQueryInit captures the bucket epoch before the remote fetch', () => {
@@ -119,16 +119,16 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
119
119
  });
120
120
  });
121
121
 
122
- it('cold path with hydrate enabled: fetch → hydrate → enqueue, in order', async () => {
122
+ it('cold path with hydrate enabled: enqueue → fetch → hydrate, in order', async () => {
123
123
  await client.finishQueryInit(hash, q, {});
124
- expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
124
+ expect(calls).toEqual(['enqueue', 'fetch', 'hydrate']);
125
125
  expect(enqueued).toEqual([{ type: 'register', payload: { hash } }]);
126
126
  });
127
127
 
128
128
  it('a preloaded query still hydrates when enabled — cache-first never depends on WHY rows are cached', async () => {
129
129
  client.preloadedHashes.add(q.hash);
130
130
  await client.finishQueryInit(hash, q, {});
131
- expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
131
+ expect(calls).toEqual(['enqueue', 'fetch', 'hydrate']);
132
132
  });
133
133
 
134
134
  it('warm query (not cold) goes straight to enqueue', async () => {
@@ -143,7 +143,7 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
143
143
  throw new Error('offline');
144
144
  };
145
145
  await expect(client.finishQueryInit(hash, q, {})).resolves.toBeUndefined();
146
- expect(calls).toEqual(['fetch', 'enqueue']);
146
+ expect(calls).toEqual(['enqueue', 'fetch']);
147
147
  });
148
148
 
149
149
  it('a bucket switch during the fetch skips applyHydration', async () => {
@@ -153,7 +153,7 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
153
153
  return [[{ id: 'user:a' }]];
154
154
  };
155
155
  await client.finishQueryInit(hash, q, {});
156
- expect(calls).toEqual(['fetch', 'enqueue']);
156
+ expect(calls).toEqual(['enqueue', 'fetch']);
157
157
  });
158
158
 
159
159
  it('default (instantHydrate unset) does not hydrate — register lifecycle is the only freshness path', async () => {
@@ -177,7 +177,7 @@ describe('Sp00kyClient.finishQueryInit behavior', () => {
177
177
  expect(h2).toBe(hash);
178
178
  // Let the shared chain drain.
179
179
  await Promise.all([...client.pendingQueryInits.values()]);
180
- expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
180
+ expect(calls).toEqual(['enqueue', 'fetch', 'hydrate']);
181
181
  expect(client.pendingQueryInits.size).toBe(0);
182
182
  });
183
183
  });
package/src/sp00ky.ts CHANGED
@@ -1449,12 +1449,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
1449
1449
  // Local-first paint: the hash is returned as soon as the LOCAL registration
1450
1450
  // above completes — `queryState.records` is already seeded from the local
1451
1451
  // cache/SSP snapshot, so `useQuery` subscribes and paints from memory with
1452
- // zero network on the paint path. Instant-hydrate and the `register`
1453
- // down-event continue in a background chain (hydrate strictly before
1454
- // enqueue, so a stale one-shot snapshot can never land after the sync's
1455
- // authoritative `_00_list_ref` overwrite). Concurrent mounts of the same
1456
- // query share one chain; a sequential re-mount starts a fresh one so its
1457
- // `register` re-enqueue keeps freshening warm data on use.
1452
+ // zero network on the paint path. The `register` down-event and the
1453
+ // instant-hydrate continue in a background chain: register is enqueued
1454
+ // first (the authoritative path never waits on a one-shot read), and a
1455
+ // hydrate that lands after the registration is dropped by
1456
+ // `applyHydration`. Concurrent mounts of the same query share one chain; a
1457
+ // sequential re-mount starts a fresh one so its `register` re-enqueue
1458
+ // keeps freshening warm data on use.
1458
1459
  if (!this.pendingQueryInits.has(hash)) {
1459
1460
  const chain = this.finishQueryInit(hash, q, params).finally(() => {
1460
1461
  this.pendingQueryInits.delete(hash);
@@ -1466,20 +1467,40 @@ export class Sp00kyClient<S extends SchemaStructure> {
1466
1467
  }
1467
1468
 
1468
1469
  /**
1469
- * Background tail of {@link initQuery}: instant-hydrate (opt-in via
1470
- * `config.instantHydrate`, and only when the query is cold) followed by
1471
- * enqueuing the `register` down-event. Never rejects — both halves catch and
1472
- * log, so `void`-ing the returned promise can't produce an unhandled
1473
- * rejection. By default (hydrate off) the register lifecycle is the single
1474
- * freshness path; the one-shot fetch is an optimization apps enable
1475
- * explicitly, and it runs regardless of preload state — cache-first delivery
1476
- * never depends on WHY rows are cached.
1470
+ * Background tail of {@link initQuery}: enqueue the `register` down-event,
1471
+ * then instant-hydrate (opt-in via `config.instantHydrate`, and only when
1472
+ * the query is cold). Never rejects — both halves catch and log, so
1473
+ * `void`-ing the returned promise can't produce an unhandled rejection. By
1474
+ * default (hydrate off) the register lifecycle is the single freshness path;
1475
+ * the one-shot fetch is an optimization apps enable explicitly, and it runs
1476
+ * regardless of preload state — cache-first delivery never depends on WHY
1477
+ * rows are cached.
1478
+ *
1479
+ * Register goes first on purpose. The hydrate used to run strictly before
1480
+ * the enqueue, so a windowed list's one-shot read (a full-table scan when
1481
+ * the app has no matching index) held its own registration back by seconds
1482
+ * per window. Now whichever answer lands first paints; a hydrate that
1483
+ * arrives after the registration is dropped (`applyHydration`).
1477
1484
  */
1478
1485
  private async finishQueryInit(
1479
1486
  hash: string,
1480
1487
  q: InnerQuery<any, any, any>,
1481
1488
  params: Record<string, any>
1482
1489
  ): Promise<void> {
1490
+ try {
1491
+ await this.sync.enqueueDownEvent({
1492
+ type: 'register',
1493
+ payload: {
1494
+ hash,
1495
+ },
1496
+ });
1497
+ } catch (err) {
1498
+ this.logger.error(
1499
+ { err, hash, Category: 'sp00ky-client::Sp00kyClient::initQuery' },
1500
+ 'Failed to enqueue register down-event'
1501
+ );
1502
+ }
1503
+
1483
1504
  if (this.config.instantHydrate === true && this.dataModule.isCold(hash)) {
1484
1505
  try {
1485
1506
  // Fence against bucket switches: rows fetched under the previous
@@ -1504,20 +1525,6 @@ export class Sp00kyClient<S extends SchemaStructure> {
1504
1525
  }
1505
1526
  }
1506
1527
  }
1507
-
1508
- try {
1509
- await this.sync.enqueueDownEvent({
1510
- type: 'register',
1511
- payload: {
1512
- hash,
1513
- },
1514
- });
1515
- } catch (err) {
1516
- this.logger.error(
1517
- { err, hash, Category: 'sp00ky-client::Sp00kyClient::initQuery' },
1518
- 'Failed to enqueue register down-event'
1519
- );
1520
- }
1521
1528
  }
1522
1529
 
1523
1530
  /**
@@ -1704,6 +1711,18 @@ export class Sp00kyClient<S extends SchemaStructure> {
1704
1711
  return fn(this.remote.getClient());
1705
1712
  }
1706
1713
 
1714
+ /**
1715
+ * Run one raw SurrealQL statement on the remote through the client's own
1716
+ * query path: the connect gate (it never runs on a socket whose namespace
1717
+ * and token are not applied yet), the per-statement timeout and the
1718
+ * concurrency limit. Prefer this over {@link useRemote} for one-shot reads
1719
+ * such as counts; `useRemote` hands out the bare SDK client and raced a
1720
+ * reconnect with "Specify a namespace to use".
1721
+ */
1722
+ async remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
1723
+ return this.remote.query<T>(sql, vars);
1724
+ }
1725
+
1707
1726
  /**
1708
1727
  * Mint the salt used for query-id hashing, so two sessions registering the
1709
1728
  * same logical query get distinct `_00_query` rows.
package/src/types.ts CHANGED
@@ -596,6 +596,11 @@ export interface QueryState {
596
596
  /** Set once `applyHydration` has run for this query, so the cold instant-hydrate
597
597
  * path fires at most once per query (see DataModule.isCold/applyHydration). */
598
598
  hydrated?: boolean;
599
+ /** Set once a server-side membership set has been accepted this session
600
+ * (`updateQueryRemoteArray`). The `register` down-event is enqueued before
601
+ * the instant-hydrate, so a hydrate that finishes later must not prime
602
+ * membership over the authoritative set. Ephemeral. */
603
+ serverMembership?: boolean;
599
604
  /** Set once `notifyQuerySynced` has emitted for this registration lifetime.
600
605
  * Ephemeral (unlike the persisted `updateCount`), so a re-registered query
601
606
  * always emits at least once even when its records are unchanged — otherwise