@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 +3 -2
- package/dist/index.d.ts +95 -30
- package/dist/index.js +356 -111
- 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 +103 -73
- package/src/services/database/remote.ts +8 -0
- package/src/sp00ky.init-query.test.ts +10 -10
- package/src/sp00ky.ts +47 -28
- package/src/types.ts +5 -0
|
@@ -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
|
-
*
|
|
54
|
+
* How many statements may be in flight at once.
|
|
57
55
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
'
|
|
55
|
-
).toBeLessThan(
|
|
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:
|
|
122
|
+
it('cold path with hydrate enabled: enqueue → fetch → hydrate, in order', async () => {
|
|
123
123
|
await client.finishQueryInit(hash, q, {});
|
|
124
|
-
expect(calls).toEqual(['
|
|
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(['
|
|
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(['
|
|
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(['
|
|
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(['
|
|
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.
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
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}:
|
|
1470
|
-
* `config.instantHydrate`, and only when
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
1476
|
-
*
|
|
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
|