From 1affeca31e571eb42dc97bab13293d852a818da7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 20 Aug 2025 00:30:16 +0200 Subject: [PATCH 01/20] feat: add new remote function `query.batch` Implements `query.batch` to address the n+1 problem --- .changeset/red-waves-give.md | 5 + .../20-core-concepts/60-remote-functions.md | 41 ++++++ .../src/exports/internal/remote-functions.js | 8 +- .../src/runtime/app/server/remote/query.js | 126 ++++++++++++++++++ .../client/remote-functions/query.svelte.js | 77 ++++++++++- packages/kit/src/runtime/server/remote.js | 17 +++ packages/kit/src/types/internal.d.ts | 2 +- .../src/routes/remote/batch/+page.svelte | 23 ++++ .../src/routes/remote/batch/batch.remote.js | 12 ++ .../src/routes/remote/validation/+page.svelte | 21 ++- .../remote/validation/validation.remote.js | 8 ++ .../kit/test/apps/basics/test/client.test.js | 15 +++ packages/kit/types/index.d.ts | 18 +++ 13 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 .changeset/red-waves-give.md create mode 100644 packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js diff --git a/.changeset/red-waves-give.md b/.changeset/red-waves-give.md new file mode 100644 index 000000000000..54af4ea776d0 --- /dev/null +++ b/.changeset/red-waves-give.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add new remote function `query.batch` diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index ef0cfe6dc843..49319511579f 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -172,6 +172,47 @@ Any query can be updated via its `refresh` method: > [!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 refresh the query. +## query.batch + +`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem where you have many calls to the same resource and it's more efficient to do one invocation with an array of arguments of the collected calls instead of doing one invocation per call. + +```js +/// file: src/routes/blog/data.remote.js +// @filename: ambient.d.ts +declare module '$lib/server/database' { + export function sql(strings: TemplateStringsArray, ...values: any[]): Promise; +} +// @filename: index.js +// ---cut--- +import * as v from 'valibot'; +import { query } from '$app/server'; +import * as db from '$lib/server/database'; + +export const getPost = query.batch(v.string(), async (slugs) => { + const posts = await db.sql` + SELECT * FROM post + WHERE slug = ANY(${slugs}) + `; + return posts; +}); +``` + +```svelte + + +

All my posts

+ + +``` + ## form 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)... diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index ad7962399cb8..fe85f39b025f 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -12,7 +12,13 @@ export function validate_remote_functions(module, file) { for (const name in module) { const type = module[name]?.__?.type; - if (type !== 'form' && type !== 'command' && type !== 'query' && type !== 'prerender') { + if ( + type !== 'form' && + type !== 'command' && + type !== 'query' && + type !== 'query.batch' && + type !== 'prerender' + ) { throw new Error( `\`${name}\` exported from ${file} is invalid — all exports from this file must be remote functions` ); diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 6a210a2ed8f7..67d22dbffd5b 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -104,3 +104,129 @@ export function query(validate_or_fn, maybe_fn) { return wrapper; } + +/** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @template Input + * @template Output + * @overload + * @param {'unchecked'} validate + * @param {(args: Input[]) => MaybePromise} fn + * @returns {RemoteQueryFunction} + * @since 2.27 + */ +/** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @template {StandardSchemaV1} Schema + * @template Output + * @overload + * @param {Schema} schema + * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise} fn + * @returns {RemoteQueryFunction, Output>} + * @since 2.27 + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_fn + * @param {(args?: Input[]) => MaybePromise} [maybe_fn] + * @returns {RemoteQueryFunction} + * @since 2.27 + */ +/*@__NO_SIDE_EFFECTS__*/ +function batch(validate_or_fn, maybe_fn) { + /** @type {(args?: Input[]) => Output[]} */ + const fn = maybe_fn ?? validate_or_fn; + + /** @type {(arg?: any) => MaybePromise} */ + const validate = create_validator(validate_or_fn, maybe_fn); + + /** @type {RemoteInfo} */ + const __ = { type: 'query.batch', id: '', name: '' }; + + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ + let batching = { args: [], resolvers: [], timeoutId: null }; + + /** @type {RemoteQueryFunction & { __: RemoteInfo }} */ + const wrapper = (arg) => { + if (prerendering) { + throw new Error( + `Cannot call query.batch '${__.name}' while prerendering, as prerendered pages need static data. Use 'prerender' from $app/server instead` + ); + } + + const { event, state } = get_request_store(); + + /** @type {Promise & Partial>} */ + const promise = get_response(__.id, arg, state, () => { + // Collect all the calls to the same query in the same macrotask, + // then execute them as one backend request. + return new Promise((resolve, reject) => { + batching.args.push(arg); + batching.resolvers.push({ resolve, reject }); + + if (batching.timeoutId) { + clearTimeout(batching.timeoutId); + } + + batching.timeoutId = setTimeout(async () => { + const batched = batching; + batching = { args: [], resolvers: [], timeoutId: null }; + + try { + const results = await run_remote_function( + event, + state, + false, + batched.args, + (array) => Promise.all(array.map(validate)), + fn + ); + for (let i = 0; i < batched.resolvers.length; i++) { + batched.resolvers[i].resolve(results[i]); + } + } catch (error) { + for (const resolver of batched.resolvers) { + resolver.reject(error); + } + } + }, 0); + }); + }); + + promise.catch(() => {}); + + promise.refresh = async () => { + const { state } = get_request_store(); + const refreshes = state.refreshes; + + if (!refreshes) { + throw new Error( + `Cannot call refresh on query.batch '${__.name}' because it is not executed in the context of a command/form remote function` + ); + } + + const cache_key = create_remote_cache_key(__.id, stringify_remote_arg(arg, state.transport)); + refreshes[cache_key] = await /** @type {Promise} */ (promise); + }; + + promise.withOverride = () => { + throw new Error(`Cannot call '${__.name}.withOverride()' on the server`); + }; + + return /** @type {RemoteQuery} */ (promise); + }; + + Object.defineProperty(wrapper, '__', { value: __ }); + + return wrapper; +} + +// Add batch as a property to the query function +Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index a6243bc85a1b..871e552ba5ab 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -1,8 +1,11 @@ /** @import { RemoteQueryFunction } from '@sveltejs/kit' */ +/** @import { RemoteFunctionResponse } from 'types' */ import { app_dir, base } from '__sveltekit/paths'; -import { remote_responses, started } from '../client.js'; +import { app, goto, remote_responses, started } from '../client.js'; import { tick } from 'svelte'; import { create_remote_function, remote_request } from './shared.svelte.js'; +import * as devalue from 'devalue'; +import { HttpError, Redirect } from '@sveltejs/kit/internal'; /** * @param {string} id @@ -25,6 +28,78 @@ export function query(id) { }); } +/** + * @param {string} id + * @returns {(arg: any) => Query} + */ +function batch(id) { + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ + let batching = { args: [], resolvers: [], timeoutId: null }; + + return create_remote_function(id, (cache_key, payload) => { + return new Query(cache_key, () => { + // Collect all the calls to the same query in the same macrotask, + // then execute them as one backend request. + return new Promise((resolve, reject) => { + batching.args.push(payload); + batching.resolvers.push({ resolve, reject }); + + if (batching.timeoutId) { + clearTimeout(batching.timeoutId); + } + + batching.timeoutId = setTimeout(async () => { + const batched = batching; + batching = { args: [], resolvers: [], timeoutId: null }; + + try { + const response = await fetch(`${base}/${app_dir}/remote/${id}`, { + method: 'POST', + body: JSON.stringify({ + payloads: batched.args + }), + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to execute batch query'); + } + + const result = /** @type {RemoteFunctionResponse} */ (await response.json()); + if (result.type === 'error') { + throw new HttpError(result.status ?? 500, result.error); + } + + if (result.type === 'redirect') { + // TODO double-check this + await goto(result.location); + await new Promise((r) => setTimeout(r, 100)); + throw new Redirect(307, result.location); + } + + const results = devalue.parse(result.result, app.decoders); + + // Resolve individual queries + for (let i = 0; i < batched.resolvers.length; i++) { + batched.resolvers[i].resolve(results[i]); + } + } catch (error) { + // Reject all queries in the batch + for (const resolver of batched.resolvers) { + resolver.reject(error); + } + } + }, 0); // Wait one macrotask + }); + }); + }); +} + +// Add batch as a property to the query function +Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); + /** * @template T * @implements {Partial>} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 6a5ec140a990..369854d41edd 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,6 +60,23 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { + if (info.type === 'query.batch' && event.request.method === 'POST') { + /** @type {{ payloads: string[] }} */ + const { payloads } = await event.request.json(); + + const args = payloads.map((payload) => parse_remote_arg(payload, transport)); + const results = await with_request_store({ event, state }, () => + Promise.all(args.map((arg) => fn(arg))) + ); + + return json( + /** @type {RemoteFunctionResponse} */ ({ + type: 'result', + result: stringify(results, transport) + }) + ); + } + if (info.type === 'form') { if (!is_form_content_type(event.request)) { throw new SvelteKitError( diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 24d7d2bd4a7b..6a4720bdbeb8 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -548,7 +548,7 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | 'command'; + type: 'query' | 'query.batch' | 'command'; id: string; name: string; } diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte new file mode 100644 index 000000000000..54650e293a3d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -0,0 +1,23 @@ + + +

Query Batch Test

+ +
    + {#each todoIds as id} +
  • + {#await get_todo(id)} + Loading todo {id}... + {:then todo} + {todo.title} + {:catch error} + Error loading todo {id}: {error.message} + {/await} +
  • + {/each} +
+ + diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js new file mode 100644 index 000000000000..484880f13d46 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -0,0 +1,12 @@ +import { query } from '$app/server'; + +const mock_data = [ + { id: '1', title: 'Buy groceries' }, + { id: '2', title: 'Walk the dog' }, + { id: '3', title: 'Write code' }, + { id: '4', title: 'Read book' } +]; + +export const get_todo = query.batch('unchecked', (ids) => { + return ids.map((id) => mock_data.find((todo) => todo.id === id)); +}); diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte index a0ab91242535..f911e3b9aef4 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte @@ -6,7 +6,9 @@ validated_prerendered_query_no_args, validated_prerendered_query_with_arg, validated_command_no_args, - validated_command_with_arg + validated_command_with_arg, + validated_batch_query_no_validation, + validated_batch_query_with_validation } from './validation.remote.js'; function validate_result(result) { @@ -32,6 +34,9 @@ validate_result(await validated_prerendered_query_with_arg('valid')); validate_result(await validated_command_with_arg('valid')); + validate_result(await validated_batch_query_no_validation('valid')); + validate_result(await validated_batch_query_with_validation('valid')); + status = 'success'; } catch (e) { status = 'error'; @@ -98,7 +103,17 @@ status = 'wrong error message'; return; } - status = 'success'; + + try { + await validated_batch_query_with_validation(123); + status = 'error'; + } catch (e) { + if (!isHttpError(e) || e.body.message !== 'Input must be a string') { + status = 'wrong error message'; + return; + } + status = 'success'; + } } } } @@ -117,6 +132,8 @@ validate_result(await validated_prerendered_query_with_arg('valid', 'ignored')); // @ts-expect-error validate_result(await validated_command_with_arg('valid', 'ignored')); + // @ts-expect-error + validate_result(await validated_batch_query_no_validation('valid', 'ignored')); status = 'success'; } catch (e) { diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js index b58825d9cc9e..7a2e734a05be 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js @@ -35,3 +35,11 @@ export const validated_command_no_args = command((arg) => export const validated_command_with_arg = command(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); + +export const validated_batch_query_no_validation = query.batch('unchecked', (items) => + items.map((item) => (item === 'valid' ? 'success' : 'failure')) +); + +export const validated_batch_query_with_validation = query.batch(schema, (items) => + items.map((item) => (typeof item === 'string' ? 'success' : 'failure')) +); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index b76fe93f52fe..c06dd870050f 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1917,4 +1917,19 @@ test.describe('remote functions', () => { await expect(page.locator('#form-pending')).toHaveText('Form pending: 0'); await expect(page.locator('#form-button-pending')).toHaveText('Button pending: 0'); }); + + // TODO once we have async SSR adjust the test and move this into test.js + test('query.batch works', async ({ page }) => { + await page.goto('/remote/batch'); + + await expect(page.locator('#batch-result-1')).toHaveText('Buy groceries'); + await expect(page.locator('#batch-result-2')).toHaveText('Walk the dog'); + + let request_count = 0; + page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); + + await page.click('button'); + await page.waitForTimeout(100); // allow all requests to finish + expect(request_count).toBe(1); + }); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 6c7fc61b4492..58897ba0b816 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2899,6 +2899,24 @@ declare module '$app/server' { * @since 2.27 */ export function query(schema: Schema, fn: (arg: StandardSchemaV1.InferOutput) => MaybePromise): RemoteQueryFunction, Output>; + export namespace query { + /** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @since 2.27 + */ + function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; + /** + * Creates a batch query function that collects multiple calls and executes them in a single request + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @since 2.27 + */ + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; + } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; From b70a3941389ac6f57a3741c902663f62760382f6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 20 Aug 2025 00:31:10 +0200 Subject: [PATCH 02/20] my god this high already --- packages/kit/src/runtime/app/server/remote/query.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 67d22dbffd5b..55737a862570 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -116,7 +116,7 @@ export function query(validate_or_fn, maybe_fn) { * @param {'unchecked'} validate * @param {(args: Input[]) => MaybePromise} fn * @returns {RemoteQueryFunction} - * @since 2.27 + * @since 2.35 */ /** * Creates a batch query function that collects multiple calls and executes them in a single request @@ -129,7 +129,7 @@ export function query(validate_or_fn, maybe_fn) { * @param {Schema} schema * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise} fn * @returns {RemoteQueryFunction, Output>} - * @since 2.27 + * @since 2.35 */ /** * @template Input @@ -137,7 +137,7 @@ export function query(validate_or_fn, maybe_fn) { * @param {any} validate_or_fn * @param {(args?: Input[]) => MaybePromise} [maybe_fn] * @returns {RemoteQueryFunction} - * @since 2.27 + * @since 2.35 */ /*@__NO_SIDE_EFFECTS__*/ function batch(validate_or_fn, maybe_fn) { From bd0c519bc55b67075fe2750d02b24231fa90c32e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 20 Aug 2025 00:57:34 +0200 Subject: [PATCH 03/20] hhnngghhhh --- packages/kit/types/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 58897ba0b816..1c322e087beb 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2905,7 +2905,7 @@ declare module '$app/server' { * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. * - * @since 2.27 + * @since 2.35 */ function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; /** @@ -2913,7 +2913,7 @@ declare module '$app/server' { * * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. * - * @since 2.27 + * @since 2.35 */ function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; } From 41fad93b054689a351979d33d84518b77134231b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:21:33 +0200 Subject: [PATCH 04/20] make it treeshakeable on the client --- packages/kit/src/exports/internal/remote-functions.js | 4 ++-- packages/kit/src/runtime/app/server/remote/query.js | 2 +- packages/kit/src/runtime/client/remote-functions/index.js | 2 +- .../kit/src/runtime/client/remote-functions/query.svelte.js | 5 +---- packages/kit/src/runtime/server/remote.js | 2 +- packages/kit/src/types/internal.d.ts | 5 ++++- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/exports/internal/remote-functions.js b/packages/kit/src/exports/internal/remote-functions.js index fe85f39b025f..bd0893375046 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -10,13 +10,13 @@ export function validate_remote_functions(module, file) { } for (const name in module) { - const type = module[name]?.__?.type; + const type = /** @type {import('types').RemoteInfo['type']} */ (module[name]?.__?.type); if ( type !== 'form' && type !== 'command' && type !== 'query' && - type !== 'query.batch' && + type !== 'query_batch' && type !== 'prerender' ) { throw new Error( diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 55737a862570..47b55e85da17 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -148,7 +148,7 @@ function batch(validate_or_fn, maybe_fn) { const validate = create_validator(validate_or_fn, maybe_fn); /** @type {RemoteInfo} */ - const __ = { type: 'query.batch', id: '', name: '' }; + const __ = { type: 'query_batch', id: '', name: '' }; /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ let batching = { args: [], resolvers: [], timeoutId: null }; diff --git a/packages/kit/src/runtime/client/remote-functions/index.js b/packages/kit/src/runtime/client/remote-functions/index.js index ec6ad0f6f344..4b20cabddd92 100644 --- a/packages/kit/src/runtime/client/remote-functions/index.js +++ b/packages/kit/src/runtime/client/remote-functions/index.js @@ -1,4 +1,4 @@ export { command } from './command.svelte.js'; export { form } from './form.svelte.js'; export { prerender } from './prerender.svelte.js'; -export { query } from './query.svelte.js'; +export { query, query_batch } from './query.svelte.js'; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 871e552ba5ab..b89dfe7b0aca 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -32,7 +32,7 @@ export function query(id) { * @param {string} id * @returns {(arg: any) => Query} */ -function batch(id) { +export function query_batch(id) { /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ let batching = { args: [], resolvers: [], timeoutId: null }; @@ -97,9 +97,6 @@ function batch(id) { }); } -// Add batch as a property to the query function -Object.defineProperty(query, 'batch', { value: batch, enumerable: true }); - /** * @template T * @implements {Partial>} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 369854d41edd..9180e575afbc 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,7 +60,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { - if (info.type === 'query.batch' && event.request.method === 'POST') { + if (info.type === 'query_batch' && event.request.method === 'POST') { /** @type {{ payloads: string[] }} */ const { payloads } = await event.request.json(); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 6a4720bdbeb8..0b3c726715df 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -548,7 +548,10 @@ export type ValidatedKitConfig = Omit, 'adapter'> & export type RemoteInfo = | { - type: 'query' | 'query.batch' | 'command'; + /** + * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) + */ + type: 'query' | 'query_batch' | 'command'; id: string; name: string; } From 4819f97a7b7a710df600a1935ece9185161ee0e7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:27:26 +0200 Subject: [PATCH 05/20] hydrate data --- .../src/runtime/client/remote-functions/query.svelte.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index b89dfe7b0aca..98b982629b73 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -38,6 +38,13 @@ export function query_batch(id) { return create_remote_function(id, (cache_key, payload) => { return new Query(cache_key, () => { + if (!started) { + const result = remote_responses[cache_key]; + if (result) { + return result; + } + } + // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { From a955c16a934b5b51b2ebb6a6c44f85722cb787e5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:30:30 +0200 Subject: [PATCH 06/20] validation --- packages/kit/src/runtime/server/remote.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 9180e575afbc..c184d094fb62 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,7 +60,15 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { - if (info.type === 'query_batch' && event.request.method === 'POST') { + if (info.type === 'query_batch') { + if (event.request.method !== 'POST') { + throw new SvelteKitError( + 405, + 'Method Not Allowed', + `\`query.batch\` functions must be invoked via POST request, not ${event.request.method}` + ); + } + /** @type {{ payloads: string[] }} */ const { payloads } = await event.request.json(); @@ -78,11 +86,19 @@ async function handle_remote_call_internal(event, state, options, manifest, id) } if (info.type === 'form') { + if (event.request.method !== 'POST') { + throw new SvelteKitError( + 405, + 'Method Not Allowed', + `\`form\` functions must be invoked via POST request, not ${event.request.method}` + ); + } + if (!is_form_content_type(event.request)) { throw new SvelteKitError( 415, 'Unsupported Media Type', - `Form actions expect form-encoded data — received ${event.request.headers.get( + `\`form\` functions expect form-encoded data — received ${event.request.headers.get( 'content-type' )}` ); From 1b5f45399bc6e3782f5b64160430c3a442b307fe Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:33:21 +0200 Subject: [PATCH 07/20] lint --- packages/kit/src/runtime/server/remote.js | 2 +- .../test/apps/basics/src/routes/remote/validation/+page.svelte | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index c184d094fb62..5ed241b0ac58 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -69,7 +69,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) ); } - /** @type {{ payloads: string[] }} */ + /** @type {{ payloads: string[] }} */ const { payloads } = await event.request.json(); const args = payloads.map((payload) => parse_remote_arg(payload, transport)); diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte index f911e3b9aef4..ae8e0ed1c6a3 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte @@ -105,6 +105,7 @@ } try { + // @ts-expect-error await validated_batch_query_with_validation(123); status = 'error'; } catch (e) { From 5da377dd71cd60778f99091b87b9aaa8231be836 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:37:01 +0200 Subject: [PATCH 08/20] explain + simplify (no use in clearing a timeout for the next macrotask) --- .../kit/src/runtime/app/server/remote/query.js | 12 ++++-------- .../client/remote-functions/query.svelte.js | 16 +++++++--------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 47b55e85da17..93e958cec35d 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -150,8 +150,8 @@ function batch(validate_or_fn, maybe_fn) { /** @type {RemoteInfo} */ const __ = { type: 'query_batch', id: '', name: '' }; - /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ - let batching = { args: [], resolvers: [], timeoutId: null }; + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ + let batching = { args: [], resolvers: [] }; /** @type {RemoteQueryFunction & { __: RemoteInfo }} */ const wrapper = (arg) => { @@ -171,13 +171,9 @@ function batch(validate_or_fn, maybe_fn) { batching.args.push(arg); batching.resolvers.push({ resolve, reject }); - if (batching.timeoutId) { - clearTimeout(batching.timeoutId); - } - - batching.timeoutId = setTimeout(async () => { + setTimeout(async () => { const batched = batching; - batching = { args: [], resolvers: [], timeoutId: null }; + batching = { args: [], resolvers: [] }; try { const results = await run_remote_function( diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 98b982629b73..2f088a0b0226 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -33,8 +33,8 @@ export function query(id) { * @returns {(arg: any) => Query} */ export function query_batch(id) { - /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}>, timeoutId: any }} */ - let batching = { args: [], resolvers: [], timeoutId: null }; + /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ + let batching = { args: [], resolvers: [] }; return create_remote_function(id, (cache_key, payload) => { return new Query(cache_key, () => { @@ -51,13 +51,11 @@ export function query_batch(id) { batching.args.push(payload); batching.resolvers.push({ resolve, reject }); - if (batching.timeoutId) { - clearTimeout(batching.timeoutId); - } - - batching.timeoutId = setTimeout(async () => { + // Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them, + // and flushes could reveal more queries that should be batched. + setTimeout(async () => { const batched = batching; - batching = { args: [], resolvers: [], timeoutId: null }; + batching = { args: [], resolvers: [] }; try { const response = await fetch(`${base}/${app_dir}/remote/${id}`, { @@ -98,7 +96,7 @@ export function query_batch(id) { resolver.reject(error); } } - }, 0); // Wait one macrotask + }, 0); }); }); }); From e036a279037d3eda6d031dee9f708011e339d31f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 14:52:09 +0200 Subject: [PATCH 09/20] fast-path for remote client calls --- .../src/runtime/app/server/remote/query.js | 22 +++++++++++++++++-- .../client/remote-functions/query.svelte.js | 2 ++ packages/kit/src/runtime/server/remote.js | 4 +--- packages/kit/src/types/internal.d.ts | 9 +++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 93e958cec35d..8c9d34275fec 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -147,8 +147,24 @@ function batch(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** @type {RemoteInfo} */ - const __ = { type: 'query_batch', id: '', name: '' }; + /** @type {RemoteInfo & { type: 'query_batch' }} */ + const __ = { + type: 'query_batch', + id: '', + name: '', + run: (args) => { + const { event, state } = get_request_store(); + + return run_remote_function( + event, + state, + false, + args, + (array) => Promise.all(array.map(validate)), + fn + ); + } + }; /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ let batching = { args: [], resolvers: [] }; @@ -171,6 +187,8 @@ function batch(validate_or_fn, maybe_fn) { batching.args.push(arg); batching.resolvers.push({ resolve, reject }); + if (batching.args.length > 1) return; + setTimeout(async () => { const batched = batching; batching = { args: [], resolvers: [] }; diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 2f088a0b0226..62b1e1bd5aa1 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -51,6 +51,8 @@ export function query_batch(id) { batching.args.push(payload); batching.resolvers.push({ resolve, reject }); + if (batching.args.length > 1) return; + // Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them, // and flushes could reveal more queries that should be batched. setTimeout(async () => { diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 5ed241b0ac58..cb602679de19 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -73,9 +73,7 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const { payloads } = await event.request.json(); const args = payloads.map((payload) => parse_remote_arg(payload, transport)); - const results = await with_request_store({ event, state }, () => - Promise.all(args.map((arg) => fn(arg))) - ); + const results = await with_request_store({ event, state }, () => info.run(args)); return json( /** @type {RemoteFunctionResponse} */ ({ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 0b3c726715df..a27406513980 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -547,13 +547,20 @@ export type ValidatedKitConfig = Omit, 'adapter'> & }; export type RemoteInfo = + | { + type: 'query' | 'command'; + id: string; + name: string; + } | { /** * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) */ - type: 'query' | 'query_batch' | 'command'; + type: 'query_batch'; id: string; name: string; + /** Direct access to the function without batching etc logic, for remote functions called from the client */ + run: (args: any[]) => Promise; } | { type: 'form'; From afdae9700f018f5a0b744280dfcaa042e8763930 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:19:00 +0200 Subject: [PATCH 10/20] validate --- .../src/runtime/app/server/remote/query.js | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 8c9d34275fec..7bdd145e5a4d 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -147,15 +147,33 @@ function batch(validate_or_fn, maybe_fn) { /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); + /** + * @param {any[]} input + * @param {any} output + */ + function validate_output(input, output) { + if (!Array.isArray(output)) { + throw new Error( + `Batch query '${__.name}' returned a result of type ${typeof output}. It must return an array of the same length as the input array` + ); + } + + if (input.length !== output.length) { + throw new Error( + `Batch query '${__.name}' was called with ${input.length} arguments, but returned ${output.length} results. Make sure to return an array of the same length as the input array` + ); + } + } + /** @type {RemoteInfo & { type: 'query_batch' }} */ const __ = { type: 'query_batch', id: '', name: '', - run: (args) => { + run: async (args) => { const { event, state } = get_request_store(); - return run_remote_function( + const results = await run_remote_function( event, state, false, @@ -163,6 +181,10 @@ function batch(validate_or_fn, maybe_fn) { (array) => Promise.all(array.map(validate)), fn ); + + validate_output(args, results); + + return results; } }; @@ -202,6 +224,9 @@ function batch(validate_or_fn, maybe_fn) { (array) => Promise.all(array.map(validate)), fn ); + + validate_output(batched.args, results); + for (let i = 0; i < batched.resolvers.length; i++) { batched.resolvers[i].resolve(results[i]); } From 6434cf37244a852c8ab7a0d899d09c63ecd3b285 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 22 Aug 2025 15:21:05 +0200 Subject: [PATCH 11/20] note in docs about output shape --- documentation/docs/20-core-concepts/60-remote-functions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 49319511579f..a97860152125 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -176,6 +176,8 @@ Any query can be updated via its `refresh` method: `query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem where you have many calls to the same resource and it's more efficient to do one invocation with an array of arguments of the collected calls instead of doing one invocation per call. +Individual calls to the same `query.batch` function are put into an array, and the remote function is invoked with the collected calls that happened within one macrotask. You must return an array of the same length, with each output entry being at the same array position as its corresponding input. SvelteKit will then split this array back up and resolve the individual calls with their results. + ```js /// file: src/routes/blog/data.remote.js // @filename: ambient.d.ts From e630de7220202fc3fb6a099c26900ddcef23d227 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 13:53:09 +0200 Subject: [PATCH 12/20] adjust API --- .../20-core-concepts/60-remote-functions.md | 43 ++++++++++++------- .../src/runtime/app/server/remote/query.js | 40 ++++------------- packages/kit/src/runtime/server/remote.js | 3 +- packages/kit/src/types/internal.d.ts | 2 +- .../src/routes/remote/batch/batch.remote.js | 3 +- packages/kit/types/index.d.ts | 4 +- 6 files changed, 42 insertions(+), 53 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index a97860152125..73b1e966a7b6 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -174,12 +174,12 @@ Any query can be updated via its `refresh` method: ## query.batch -`query.batch` works like `query` except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem where you have many calls to the same resource and it's more efficient to do one invocation with an array of arguments of the collected calls instead of doing one invocation per call. +`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. -Individual calls to the same `query.batch` function are put into an array, and the remote function is invoked with the collected calls that happened within one macrotask. You must return an array of the same length, with each output entry being at the same array position as its corresponding input. SvelteKit will then split this array back up and resolve the individual calls with their results. +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. ```js -/// file: src/routes/blog/data.remote.js +/// file: weather.remote.js // @filename: ambient.d.ts declare module '$lib/server/database' { export function sql(strings: TemplateStringsArray, ...values: any[]): Promise; @@ -190,29 +190,40 @@ import * as v from 'valibot'; import { query } from '$app/server'; import * as db from '$lib/server/database'; -export const getPost = query.batch(v.string(), async (slugs) => { - const posts = await db.sql` - SELECT * FROM post - WHERE slug = ANY(${slugs}) +export const getWeather = query.batch(v.string(), async (cities) => { + const weather = await db.sql` + SELECT * FROM weather + WHERE city = ANY(${cities}) `; - return posts; + const weatherMap = new Map(weather.map(w => [w.city, w])); + + return (city) => weatherMap.get(city); }); ``` ```svelte + -

All my posts

+

Weather

-
    - {#each posts as post} -
  • {await getPost(post.id).summary}
  • - {/each} -
+{#each cities.slice(0, limit) as city} +

{city.name}

+ +{/each} + + ``` ## form diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 7bdd145e5a4d..d9d77ca36dde 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -114,7 +114,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {'unchecked'} validate - * @param {(args: Input[]) => MaybePromise} fn + * @param {(args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} fn * @returns {RemoteQueryFunction} * @since 2.35 */ @@ -127,7 +127,7 @@ export function query(validate_or_fn, maybe_fn) { * @template Output * @overload * @param {Schema} schema - * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise} fn + * @param {(args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>} fn * @returns {RemoteQueryFunction, Output>} * @since 2.35 */ @@ -135,45 +135,27 @@ export function query(validate_or_fn, maybe_fn) { * @template Input * @template Output * @param {any} validate_or_fn - * @param {(args?: Input[]) => MaybePromise} [maybe_fn] + * @param {(args?: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>} [maybe_fn] * @returns {RemoteQueryFunction} * @since 2.35 */ /*@__NO_SIDE_EFFECTS__*/ function batch(validate_or_fn, maybe_fn) { - /** @type {(args?: Input[]) => Output[]} */ + /** @type {(args?: Input[]) => (arg: Input, idx: number) => Output} */ const fn = maybe_fn ?? validate_or_fn; /** @type {(arg?: any) => MaybePromise} */ const validate = create_validator(validate_or_fn, maybe_fn); - /** - * @param {any[]} input - * @param {any} output - */ - function validate_output(input, output) { - if (!Array.isArray(output)) { - throw new Error( - `Batch query '${__.name}' returned a result of type ${typeof output}. It must return an array of the same length as the input array` - ); - } - - if (input.length !== output.length) { - throw new Error( - `Batch query '${__.name}' was called with ${input.length} arguments, but returned ${output.length} results. Make sure to return an array of the same length as the input array` - ); - } - } - /** @type {RemoteInfo & { type: 'query_batch' }} */ const __ = { type: 'query_batch', id: '', name: '', - run: async (args) => { + run: (args) => { const { event, state } = get_request_store(); - const results = await run_remote_function( + return run_remote_function( event, state, false, @@ -181,10 +163,6 @@ function batch(validate_or_fn, maybe_fn) { (array) => Promise.all(array.map(validate)), fn ); - - validate_output(args, results); - - return results; } }; @@ -216,7 +194,7 @@ function batch(validate_or_fn, maybe_fn) { batching = { args: [], resolvers: [] }; try { - const results = await run_remote_function( + const get_result = await run_remote_function( event, state, false, @@ -225,10 +203,8 @@ function batch(validate_or_fn, maybe_fn) { fn ); - validate_output(batched.args, results); - for (let i = 0; i < batched.resolvers.length; i++) { - batched.resolvers[i].resolve(results[i]); + batched.resolvers[i].resolve(get_result(batched.args[i], i)); } } catch (error) { for (const resolver of batched.resolvers) { diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index cb602679de19..4146d71a3ff7 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -73,7 +73,8 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const { payloads } = await event.request.json(); const args = payloads.map((payload) => parse_remote_arg(payload, transport)); - const results = await with_request_store({ event, state }, () => info.run(args)); + const get_result = await with_request_store({ event, state }, () => info.run(args)); + const results = args.map((arg, i) => get_result(arg, i)); return json( /** @type {RemoteFunctionResponse} */ ({ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index a27406513980..d770abda5290 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -560,7 +560,7 @@ export type RemoteInfo = id: string; name: string; /** Direct access to the function without batching etc logic, for remote functions called from the client */ - run: (args: any[]) => Promise; + run: (args: any[]) => Promise<(arg: any, idx: number) => any>; } | { type: 'form'; diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js index 484880f13d46..ec5c90d245bd 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -8,5 +8,6 @@ const mock_data = [ ]; export const get_todo = query.batch('unchecked', (ids) => { - return ids.map((id) => mock_data.find((todo) => todo.id === id)); + const results = ids.map((id) => mock_data.find((todo) => todo.id === id)); + return (id) => results.find((todo) => todo?.id === id); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 1c322e087beb..5552121c449b 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2907,7 +2907,7 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise): RemoteQueryFunction; + function batch(validate: "unchecked", fn: (args: Input[]) => MaybePromise<(arg: Input, idx: number) => Output>): RemoteQueryFunction; /** * Creates a batch query function that collects multiple calls and executes them in a single request * @@ -2915,7 +2915,7 @@ declare module '$app/server' { * * @since 2.35 */ - function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise): RemoteQueryFunction, Output>; + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise; From cbfa1cf8a95434a7f7cfc08b9ad009164252a1af Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 14:08:43 +0200 Subject: [PATCH 13/20] deduplicate --- .../src/runtime/app/server/remote/query.js | 1 + .../client/remote-functions/query.svelte.js | 33 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index d9d77ca36dde..4ab86e1d9205 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -184,6 +184,7 @@ function batch(validate_or_fn, maybe_fn) { // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { + // We don't need to deduplicate args here, because get_response already caches/reuses identical calls batching.args.push(arg); batching.resolvers.push({ resolve, reject }); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 62b1e1bd5aa1..64b501e54312 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -33,8 +33,8 @@ export function query(id) { * @returns {(arg: any) => Query} */ export function query_batch(id) { - /** @type {{ args: any[], resolvers: Array<{resolve: (value: any) => void, reject: (error: any) => void}> }} */ - let batching = { args: [], resolvers: [] }; + /** @type {Map void, reject: (error: any) => void}> }>} */ + let batching = new Map(); return create_remote_function(id, (cache_key, payload) => { return new Query(cache_key, () => { @@ -48,22 +48,25 @@ export function query_batch(id) { // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { - batching.args.push(payload); - batching.resolvers.push({ resolve, reject }); + // create_remote_function caches identical calls, but in case a refresh to the same query is called multiple times this function + // is invoked multiple times with the same payload, so we need to deduplicate here + const entry = batching.get(payload) ?? []; + entry.push({ resolve, reject }); + batching.set(payload, entry); - if (batching.args.length > 1) return; + if (batching.size > 1) return; // Wait for the next macrotask - don't use microtask as Svelte runtime uses these to collect changes and flush them, // and flushes could reveal more queries that should be batched. setTimeout(async () => { const batched = batching; - batching = { args: [], resolvers: [] }; + batching = new Map(); try { const response = await fetch(`${base}/${app_dir}/remote/${id}`, { method: 'POST', body: JSON.stringify({ - payloads: batched.args + payloads: Array.from(batched.keys()) }), headers: { 'Content-Type': 'application/json' @@ -89,13 +92,21 @@ export function query_batch(id) { const results = devalue.parse(result.result, app.decoders); // Resolve individual queries - for (let i = 0; i < batched.resolvers.length; i++) { - batched.resolvers[i].resolve(results[i]); + // Maps guarantee insertion order so we can do it like this + let i = 0; + + for (const resolvers of batched.values()) { + for (const { resolve } of resolvers) { + resolve(results[i]); + } + i++; } } catch (error) { // Reject all queries in the batch - for (const resolver of batched.resolvers) { - resolver.reject(error); + for (const resolver of batched.values()) { + for (const { reject } of resolver) { + reject(error); + } } } }, 0); From aa7cd56e6b97dd0a7db1297b7163e0570f2d0f8e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 14:11:12 +0200 Subject: [PATCH 14/20] test deduplication --- .../kit/test/apps/basics/src/routes/remote/batch/+page.svelte | 2 +- .../test/apps/basics/src/routes/remote/batch/batch.remote.js | 4 ++++ packages/kit/test/apps/basics/test/client.test.js | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 54650e293a3d..16cf84886dfe 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -1,7 +1,7 @@

Query Batch Test

diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js index ec5c90d245bd..0103bc7dd8aa 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -8,6 +8,10 @@ const mock_data = [ ]; export const get_todo = query.batch('unchecked', (ids) => { + if (ids.length !== 2) { + throw new Error(`Expected 2 IDs (deduplicated), got ${JSON.stringify(ids)}`); + } + const results = ids.map((id) => mock_data.find((todo) => todo.id === id)); return (id) => results.find((todo) => todo?.id === id); }); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c06dd870050f..a7a8e319c656 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1924,6 +1924,7 @@ test.describe('remote functions', () => { await expect(page.locator('#batch-result-1')).toHaveText('Buy groceries'); await expect(page.locator('#batch-result-2')).toHaveText('Walk the dog'); + await expect(page.locator('#batch-result-3')).toHaveText('Buy groceries'); let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); From 910125c48f30e5183636d1e7c32dae577b110b6d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 14:21:46 +0200 Subject: [PATCH 15/20] fix --- .../runtime/client/remote-functions/query.svelte.js | 2 +- .../src/routes/remote/validation/validation.remote.js | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 64b501e54312..f58cd2861072 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -33,7 +33,7 @@ export function query(id) { * @returns {(arg: any) => Query} */ export function query_batch(id) { - /** @type {Map void, reject: (error: any) => void}> }>} */ + /** @type {Map void, reject: (error: any) => void}>>} */ let batching = new Map(); return create_remote_function(id, (cache_key, payload) => { diff --git a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js index 7a2e734a05be..c4050a7bc471 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/validation/validation.remote.js @@ -36,10 +36,12 @@ export const validated_command_with_arg = command(schema, (...arg) => typeof arg[0] === 'string' && arg.length === 1 ? 'success' : 'failure' ); -export const validated_batch_query_no_validation = query.batch('unchecked', (items) => - items.map((item) => (item === 'valid' ? 'success' : 'failure')) +export const validated_batch_query_no_validation = query.batch( + 'unchecked', + (_) => (item) => (item === 'valid' ? 'success' : 'failure') ); -export const validated_batch_query_with_validation = query.batch(schema, (items) => - items.map((item) => (typeof item === 'string' ? 'success' : 'failure')) +export const validated_batch_query_with_validation = query.batch( + schema, + (_) => (item) => (typeof item === 'string' ? 'success' : 'failure') ); From 2ca6eb1157ebec1d4b30419d6feeb328bfec8892 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 15:38:11 +0200 Subject: [PATCH 16/20] oops --- .../kit/test/apps/basics/src/routes/remote/batch/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 16cf84886dfe..2fca0537918b 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -7,12 +7,12 @@

Query Batch Test

    - {#each todoIds as id} + {#each todoIds as id, idx}
  • {#await get_todo(id)} Loading todo {id}... {:then todo} - {todo.title} + {todo.title} {:catch error} Error loading todo {id}: {error.message} {/await} From dfa4cb45516c06f83753d49241c4443aa10bb731 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 15:48:13 +0200 Subject: [PATCH 17/20] omg lol --- .../kit/test/apps/basics/src/routes/remote/batch/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 2fca0537918b..676a1bb3cb41 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -12,7 +12,7 @@ {#await get_todo(id)} Loading todo {id}... {:then todo} - {todo.title} + {todo.title} {:catch error} Error loading todo {id}: {error.message} {/await} From 44f60b811d0fb71f85fd4aac279d0ae6a3829018 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:59:17 +0200 Subject: [PATCH 18/20] Update documentation/docs/20-core-concepts/60-remote-functions.md Co-authored-by: Rich Harris --- .../docs/20-core-concepts/60-remote-functions.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 73b1e966a7b6..0204441ec44c 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -218,12 +218,11 @@ export const getWeather = query.batch(v.string(), async (cities) => { {/each} - +{#if cities.length > limit} + +{/if} ``` ## form From d8d02bc4e797b9f3697c89f5fec7d86c58efa2f8 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sat, 23 Aug 2025 23:12:45 +0200 Subject: [PATCH 19/20] per-item error handling --- .../src/runtime/app/server/remote/query.js | 6 +++++- .../client/remote-functions/query.svelte.js | 8 ++++++-- packages/kit/src/runtime/server/remote.js | 15 +++++++++++++- .../basics/src/routes/remote/batch/+page.js | 2 ++ .../src/routes/remote/batch/+page.svelte | 6 +++--- .../src/routes/remote/batch/batch.remote.js | 20 +++++++++---------- .../kit/test/apps/basics/test/client.test.js | 1 + 7 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/routes/remote/batch/+page.js diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 4ab86e1d9205..bd9fb0e4c6e5 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -205,7 +205,11 @@ function batch(validate_or_fn, maybe_fn) { ); for (let i = 0; i < batched.resolvers.length; i++) { - batched.resolvers[i].resolve(get_result(batched.args[i], i)); + try { + batched.resolvers[i].resolve(get_result(batched.args[i], i)); + } catch (error) { + batched.resolvers[i].reject(error); + } } } catch (error) { for (const resolver of batched.resolvers) { diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index f58cd2861072..b7be26ef39eb 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -96,8 +96,12 @@ export function query_batch(id) { let i = 0; for (const resolvers of batched.values()) { - for (const { resolve } of resolvers) { - resolve(results[i]); + for (const { resolve, reject } of resolvers) { + if (results[i].type === 'error') { + reject(new HttpError(results[i].status, results[i].error)); + } else { + resolve(results[i].data); + } } i++; } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index 4146d71a3ff7..7f11bd97ca27 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -74,7 +74,20 @@ async function handle_remote_call_internal(event, state, options, manifest, id) const args = payloads.map((payload) => parse_remote_arg(payload, transport)); const get_result = await with_request_store({ event, state }, () => info.run(args)); - const results = args.map((arg, i) => get_result(arg, i)); + const results = await Promise.all( + args.map(async (arg, i) => { + try { + return { type: 'result', data: get_result(arg, i) }; + } catch (error) { + return { + type: 'error', + error: await handle_error_and_jsonify(event, state, options, error), + status: + error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500 + }; + } + }) + ); return json( /** @type {RemoteFunctionResponse} */ ({ diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.js b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.js new file mode 100644 index 000000000000..d87494778d07 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.js @@ -0,0 +1,2 @@ +// TODO remove once we have async SSR +export const ssr = false; diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte index 676a1bb3cb41..dbe085847835 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte @@ -1,7 +1,7 @@

    Query Batch Test

    @@ -10,11 +10,11 @@ {#each todoIds as id, idx}
  • {#await get_todo(id)} - Loading todo {id}... + Loading todo {id}... {:then todo} {todo.title} {:catch error} - Error loading todo {id}: {error.message} + Error loading todo {id}: {error.body.message} {/await}
  • {/each} diff --git a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js index 0103bc7dd8aa..917729e1c412 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -1,17 +1,15 @@ import { query } from '$app/server'; - -const mock_data = [ - { id: '1', title: 'Buy groceries' }, - { id: '2', title: 'Walk the dog' }, - { id: '3', title: 'Write code' }, - { id: '4', title: 'Read book' } -]; +import { error } from '@sveltejs/kit'; export const get_todo = query.batch('unchecked', (ids) => { - if (ids.length !== 2) { - throw new Error(`Expected 2 IDs (deduplicated), got ${JSON.stringify(ids)}`); + if (JSON.stringify(ids) !== JSON.stringify(['1', '2', 'error'])) { + throw new Error(`Expected 3 IDs (deduplicated), got ${JSON.stringify(ids)}`); } - const results = ids.map((id) => mock_data.find((todo) => todo.id === id)); - return (id) => results.find((todo) => todo?.id === id); + return (id) => + id === '1' + ? { id: '1', title: 'Buy groceries' } + : id === '2' + ? { id: '2', title: 'Walk the dog' } + : error(404, { message: 'Not found' }); }); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index a7a8e319c656..e385ac175fd5 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1925,6 +1925,7 @@ test.describe('remote functions', () => { await expect(page.locator('#batch-result-1')).toHaveText('Buy groceries'); await expect(page.locator('#batch-result-2')).toHaveText('Walk the dog'); await expect(page.locator('#batch-result-3')).toHaveText('Buy groceries'); + await expect(page.locator('#batch-result-4')).toHaveText('Error loading todo error: Not found'); let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); From 0986c2992e277abff79b2d6dfa56d994f366d59b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 25 Aug 2025 09:34:45 -0400 Subject: [PATCH 20/20] Update documentation/docs/20-core-concepts/60-remote-functions.md --- documentation/docs/20-core-concepts/60-remote-functions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 0204441ec44c..5a1dcdee109b 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -195,9 +195,9 @@ export const getWeather = query.batch(v.string(), async (cities) => { SELECT * FROM weather WHERE city = ANY(${cities}) `; - const weatherMap = new Map(weather.map(w => [w.city, w])); + const lookup = new Map(weather.map(w => [w.city, w])); - return (city) => weatherMap.get(city); + return (city) => lookup.get(city); }); ```