Skip to content

Commit e6c3171

Browse files
feat: add new remote function query.batch (#14272)
* feat: add new remote function `query.batch` Implements `query.batch` to address the n+1 problem * my god this high already * hhnngghhhh * make it treeshakeable on the client * hydrate data * validation * lint * explain + simplify (no use in clearing a timeout for the next macrotask) * fast-path for remote client calls * validate * note in docs about output shape * adjust API * deduplicate * test deduplication * fix * oops * omg lol * Update documentation/docs/20-core-concepts/60-remote-functions.md Co-authored-by: Rich Harris <[email protected]> * per-item error handling * Update documentation/docs/20-core-concepts/60-remote-functions.md --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent 6e9c60c commit e6c3171

File tree

15 files changed

+469
-7
lines changed

15 files changed

+469
-7
lines changed

.changeset/red-waves-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: add new remote function `query.batch`

documentation/docs/20-core-concepts/60-remote-functions.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,59 @@ Any query can be re-fetched via its `refresh` method, which retrieves the latest
172172
173173
> [!NOTE] Queries are cached while they're on the page, meaning `getPosts() === getPosts()`. This means you don't need a reference like `const posts = getPosts()` in order to update the query.
174174
175+
## query.batch
176+
177+
`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem: rather than each query resulting in a separate database call (for example), simultaneous queries are grouped together.
178+
179+
On the server, the callback receives an array of the arguments the function was called with. It must return a function of the form `(input: Input, index: number) => Output`. SvelteKit will then call this with each of the input arguments to resolve the individual calls with their results.
180+
181+
```js
182+
/// file: weather.remote.js
183+
// @filename: ambient.d.ts
184+
declare module '$lib/server/database' {
185+
export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
186+
}
187+
// @filename: index.js
188+
// ---cut---
189+
import * as v from 'valibot';
190+
import { query } from '$app/server';
191+
import * as db from '$lib/server/database';
192+
193+
export const getWeather = query.batch(v.string(), async (cities) => {
194+
const weather = await db.sql`
195+
SELECT * FROM weather
196+
WHERE city = ANY(${cities})
197+
`;
198+
const lookup = new Map(weather.map(w => [w.city, w]));
199+
200+
return (city) => lookup.get(city);
201+
});
202+
```
203+
204+
```svelte
205+
<!--- file: Weather.svelte --->
206+
<script>
207+
import CityWeather from './CityWeather.svelte';
208+
import { getWeather } from './weather.remote.js';
209+
210+
let { cities } = $props();
211+
let limit = $state(5);
212+
</script>
213+
214+
<h2>Weather</h2>
215+
216+
{#each cities.slice(0, limit) as city}
217+
<h3>{city.name}</h3>
218+
<CityWeather weather={await getWeather(city.id)} />
219+
{/each}
220+
221+
{#if cities.length > limit}
222+
<button onclick={() => limit += 5}>
223+
Load more
224+
</button>
225+
{/if}
226+
```
227+
175228
## form
176229
177230
The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...

packages/kit/src/exports/internal/remote-functions.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@ export function validate_remote_functions(module, file) {
1010
}
1111

1212
for (const name in module) {
13-
const type = module[name]?.__?.type;
13+
const type = /** @type {import('types').RemoteInfo['type']} */ (module[name]?.__?.type);
1414

15-
if (type !== 'form' && type !== 'command' && type !== 'query' && type !== 'prerender') {
15+
if (
16+
type !== 'form' &&
17+
type !== 'command' &&
18+
type !== 'query' &&
19+
type !== 'query_batch' &&
20+
type !== 'prerender'
21+
) {
1622
throw new Error(
1723
`\`${name}\` exported from ${file} is invalid — all exports from this file must be remote functions`
1824
);

packages/kit/src/runtime/app/server/remote/query.js

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,149 @@ export function query(validate_or_fn, maybe_fn) {
122122

123123
return wrapper;
124124
}
125+
126+
/**
127+
* Creates a batch query function that collects multiple calls and executes them in a single request
128+
*
129+
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation.
130+
*
131+
* @template Input
132+
* @template Output
133+
* @overload
134+
* @param {'unchecked'} validate
135+
* @param {(args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} fn
136+
* @returns {RemoteQueryFunction<Input, Output>}
137+
* @since 2.35
138+
*/
139+
/**
140+
* Creates a batch query function that collects multiple calls and executes them in a single request
141+
*
142+
* See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation.
143+
*
144+
* @template {StandardSchemaV1} Schema
145+
* @template Output
146+
* @overload
147+
* @param {Schema} schema
148+
* @param {(args: StandardSchemaV1.InferOutput<Schema>[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput<Schema>, idx: number) => Output>} fn
149+
* @returns {RemoteQueryFunction<StandardSchemaV1.InferInput<Schema>, Output>}
150+
* @since 2.35
151+
*/
152+
/**
153+
* @template Input
154+
* @template Output
155+
* @param {any} validate_or_fn
156+
* @param {(args?: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} [maybe_fn]
157+
* @returns {RemoteQueryFunction<Input, Output>}
158+
* @since 2.35
159+
*/
160+
/*@__NO_SIDE_EFFECTS__*/
161+
function batch(validate_or_fn, maybe_fn) {
162+
/** @type {(args?: Input[]) => (arg: Input, idx: number) => Output} */
163+
const fn = maybe_fn ?? validate_or_fn;
164+
165+
/** @type {(arg?: any) => MaybePromise<Input>} */
166+
const validate = create_validator(validate_or_fn, maybe_fn);
167+
168+
/** @type {RemoteInfo & { type: 'query_batch' }} */
169+
const __ = {
170+
type: 'query_batch',
171+
id: '',
172+
name: '',
173+
run: (args) => {
174+
const { event, state } = get_request_store();
175+
176+
return run_remote_function(
177+
event,
178+
state,
179+
false,
180+
args,
181+
(array) => Promise.all(array.map(validate)),
182+
fn
183+
);
184+
}
185+
};
186+
187+
/** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */
188+
let batching = { args: [], resolvers: [] };
189+
190+
/** @type {RemoteQueryFunction<Input, Output> & { __: RemoteInfo }} */
191+
const wrapper = (arg) => {
192+
if (prerendering) {
193+
throw new Error(
194+
`Cannot call query.batch '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead`
195+
);
196+
}
197+
198+
const { event, state } = get_request_store();
199+
200+
/** @type {Promise<any> & Partial<RemoteQuery<any>>} */
201+
const promise = get_response(__.id, arg, state, () => {
202+
// Collect all the calls to the same query in the same macrotask,
203+
// then execute them as one backend request.
204+
return new Promise((resolve, reject) => {
205+
// We don't need to deduplicate args here, because get_response already caches/reuses identical calls
206+
batching.args.push(arg);
207+
batching.resolvers.push({ resolve, reject });
208+
209+
if (batching.args.length > 1) return;
210+
211+
setTimeout(async () => {
212+
const batched = batching;
213+
batching = { args: [], resolvers: [] };
214+
215+
try {
216+
const get_result = await run_remote_function(
217+
event,
218+
state,
219+
false,
220+
batched.args,
221+
(array) => Promise.all(array.map(validate)),
222+
fn
223+
);
224+
225+
for (let i = 0; i < batched.resolvers.length; i++) {
226+
try {
227+
batched.resolvers[i].resolve(get_result(batched.args[i], i));
228+
} catch (error) {
229+
batched.resolvers[i].reject(error);
230+
}
231+
}
232+
} catch (error) {
233+
for (const resolver of batched.resolvers) {
234+
resolver.reject(error);
235+
}
236+
}
237+
}, 0);
238+
});
239+
});
240+
241+
promise.catch(() => {});
242+
243+
promise.refresh = async () => {
244+
const { state } = get_request_store();
245+
const refreshes = state.refreshes;
246+
247+
if (!refreshes) {
248+
throw new Error(
249+
`Cannot call refresh on query.batch '${__.name}' because it is not executed in the context of a command/form remote function`
250+
);
251+
}
252+
253+
const cache_key = create_remote_cache_key(__.id, stringify_remote_arg(arg, state.transport));
254+
refreshes[cache_key] = await /** @type {Promise<any>} */ (promise);
255+
};
256+
257+
promise.withOverride = () => {
258+
throw new Error(`Cannot call '${__.name}.withOverride()' on the server`);
259+
};
260+
261+
return /** @type {RemoteQuery<Output>} */ (promise);
262+
};
263+
264+
Object.defineProperty(wrapper, '__', { value: __ });
265+
266+
return wrapper;
267+
}
268+
269+
// Add batch as a property to the query function
270+
Object.defineProperty(query, 'batch', { value: batch, enumerable: true });
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { command } from './command.svelte.js';
22
export { form } from './form.svelte.js';
33
export { prerender } from './prerender.svelte.js';
4-
export { query } from './query.svelte.js';
4+
export { query, query_batch } from './query.svelte.js';

packages/kit/src/runtime/client/remote-functions/query.svelte.js

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/** @import { RemoteQueryFunction } from '@sveltejs/kit' */
2+
/** @import { RemoteFunctionResponse } from 'types' */
23
import { app_dir, base } from '__sveltekit/paths';
3-
import { remote_responses, started } from '../client.js';
4+
import { app, goto, remote_responses, started } from '../client.js';
45
import { tick } from 'svelte';
56
import { create_remote_function, remote_request } from './shared.svelte.js';
7+
import * as devalue from 'devalue';
8+
import { HttpError, Redirect } from '@sveltejs/kit/internal';
69

710
/**
811
* @param {string} id
@@ -25,6 +28,97 @@ export function query(id) {
2528
});
2629
}
2730

31+
/**
32+
* @param {string} id
33+
* @returns {(arg: any) => Query<any>}
34+
*/
35+
export function query_batch(id) {
36+
/** @type {Map<string, Array<{resolve: (value: any) => void, reject: (error: any) => void}>>} */
37+
let batching = new Map();
38+
39+
return create_remote_function(id, (cache_key, payload) => {
40+
return new Query(cache_key, () => {
41+
if (!started) {
42+
const result = remote_responses[cache_key];
43+
if (result) {
44+
return result;
45+
}
46+
}
47+
48+
// Collect all the calls to the same query in the same macrotask,
49+
// then execute them as one backend request.
50+
return new Promise((resolve, reject) => {
51+
// create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function
52+
// is invoked multiple times with the same payload, so we need to deduplicate here
53+
const entry = batching.get(payload) ?? [];
54+
entry.push({ resolve, reject });
55+
batching.set(payload, entry);
56+
57+
if (batching.size > 1) return;
58+
59+
// Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them,
60+
// and flushes could reveal more queries that should be batched.
61+
setTimeout(async () => {
62+
const batched = batching;
63+
batching = new Map();
64+
65+
try {
66+
const response = await fetch(`${base}/${app_dir}/remote/${id}`, {
67+
method: 'POST',
68+
body: JSON.stringify({
69+
payloads: Array.from(batched.keys())
70+
}),
71+
headers: {
72+
'Content-Type': 'application/json'
73+
}
74+
});
75+
76+
if (!response.ok) {
77+
throw new Error('Failed to execute batch query');
78+
}
79+
80+
const result = /** @type {RemoteFunctionResponse} */ (await response.json());
81+
if (result.type === 'error') {
82+
throw new HttpError(result.status ?? 500, result.error);
83+
}
84+
85+
if (result.type === 'redirect') {
86+
// TODO double-check this
87+
await goto(result.location);
88+
await new Promise((r) => setTimeout(r, 100));
89+
throw new Redirect(307, result.location);
90+
}
91+
92+
const results = devalue.parse(result.result, app.decoders);
93+
94+
// Resolve individual queries
95+
// Maps guarantee insertion order so we can do it like this
96+
let i = 0;
97+
98+
for (const resolvers of batched.values()) {
99+
for (const { resolve, reject } of resolvers) {
100+
if (results[i].type === 'error') {
101+
reject(new HttpError(results[i].status, results[i].error));
102+
} else {
103+
resolve(results[i].data);
104+
}
105+
}
106+
i++;
107+
}
108+
} catch (error) {
109+
// Reject all queries in the batch
110+
for (const resolver of batched.values()) {
111+
for (const { reject } of resolver) {
112+
reject(error);
113+
}
114+
}
115+
}
116+
}, 0);
117+
});
118+
});
119+
});
120+
}
121+
28122
/**
29123
* @template T
30124
* @implements {Partial<Promise<T>>}

0 commit comments

Comments
 (0)