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 08c308b3ff29..4af669e42ed1 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -172,6 +172,59 @@ Any query can be re-fetched via its `refresh` method, which retrieves the latest > [!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. +## 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: rather than each query resulting in a separate database call (for example), simultaneous queries are grouped together. + +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: weather.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 getWeather = query.batch(v.string(), async (cities) => { + const weather = await db.sql` + SELECT * FROM weather + WHERE city = ANY(${cities}) + `; + const lookup = new Map(weather.map(w => [w.city, w])); + + return (city) => lookup.get(city); +}); +``` + +```svelte + + + +

Weather

+ +{#each cities.slice(0, limit) as city} +

{city.name}

+ +{/each} + +{#if cities.length > limit} + +{/if} +``` + ## 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..bd0893375046 100644 --- a/packages/kit/src/exports/internal/remote-functions.js +++ b/packages/kit/src/exports/internal/remote-functions.js @@ -10,9 +10,15 @@ 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 !== '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 1c80b39263a2..bb36543f17fa 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -122,3 +122,149 @@ 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<(arg: Input, idx: number) => Output>} fn + * @returns {RemoteQueryFunction} + * @since 2.35 + */ +/** + * 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<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>} fn + * @returns {RemoteQueryFunction, Output>} + * @since 2.35 + */ +/** + * @template Input + * @template Output + * @param {any} validate_or_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[]) => (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); + + /** @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: [] }; + + /** @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) => { + // 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 }); + + if (batching.args.length > 1) return; + + setTimeout(async () => { + const batched = batching; + batching = { args: [], resolvers: [] }; + + try { + const get_result = 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++) { + 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) { + 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/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 a6243bc85a1b..b7be26ef39eb 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,97 @@ export function query(id) { }); } +/** + * @param {string} id + * @returns {(arg: any) => Query} + */ +export function query_batch(id) { + /** @type {Map void, reject: (error: any) => void}>>} */ + let batching = new Map(); + + 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) => { + // 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.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 = new Map(); + + try { + const response = await fetch(`${base}/${app_dir}/remote/${id}`, { + method: 'POST', + body: JSON.stringify({ + payloads: Array.from(batched.keys()) + }), + 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 + // Maps guarantee insertion order so we can do it like this + let i = 0; + + for (const resolvers of batched.values()) { + 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++; + } + } catch (error) { + // Reject all queries in the batch + for (const resolver of batched.values()) { + for (const { reject } of resolver) { + reject(error); + } + } + } + }, 0); + }); + }); + }); +} + /** * @template T * @implements {Partial>} diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index a09031d17a61..cbd843af6c72 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -60,12 +60,57 @@ async function handle_remote_call_internal(event, state, options, manifest, id) let form_client_refreshes; try { + 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(); + + const args = payloads.map((payload) => parse_remote_arg(payload, transport)); + const get_result = await with_request_store({ event, state }, () => info.run(args)); + 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} */ ({ + type: 'result', + result: stringify(results, transport) + }) + ); + } + 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' )}` ); diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index d5aea28c39f1..69e78dc46431 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -553,6 +553,16 @@ export type RemoteInfo = id: string; name: string; } + | { + /** + * Corresponds to the name of the client-side exports (that's why we use underscores and not dots) + */ + 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<(arg: any, idx: number) => any>; + } | { type: 'form'; id: string; 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 new file mode 100644 index 000000000000..dbe085847835 --- /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, idx} +
  • + {#await get_todo(id)} + Loading todo {id}... + {:then todo} + {todo.title} + {:catch error} + 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 new file mode 100644 index 000000000000..917729e1c412 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/batch/batch.remote.js @@ -0,0 +1,15 @@ +import { query } from '$app/server'; +import { error } from '@sveltejs/kit'; + +export const get_todo = query.batch('unchecked', (ids) => { + if (JSON.stringify(ids) !== JSON.stringify(['1', '2', 'error'])) { + throw new Error(`Expected 3 IDs (deduplicated), got ${JSON.stringify(ids)}`); + } + + 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/src/routes/remote/validation/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/validation/+page.svelte index a0ab91242535..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 @@ -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,18 @@ status = 'wrong error message'; return; } - status = 'success'; + + try { + // @ts-expect-error + 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 +133,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..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 @@ -35,3 +35,13 @@ 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', + (_) => (item) => (item === 'valid' ? 'success' : 'failure') +); + +export const validated_batch_query_with_validation = query.batch( + schema, + (_) => (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 44d6a794f171..94bf81746793 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1942,4 +1942,21 @@ 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'); + 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)); + + 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 b74cf42aa0c3..ddb95c7d6e26 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2922,6 +2922,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.35 + */ + 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 + * + * See [Remote functions](https://svelte.dev/docs/kit/remote-functions#query.batch) for full documentation. + * + * @since 2.35 + */ + function batch(schema: Schema, fn: (args: StandardSchemaV1.InferOutput[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput, idx: number) => Output>): RemoteQueryFunction, Output>; + } type RemotePrerenderInputsGenerator = () => MaybePromise; type MaybePromise = T | Promise;