Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-waves-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add new remote function `query.batch`
53 changes: 53 additions & 0 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<any[]>;
}
// @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
<!--- file: Weather.svelte --->
<script>
import CityWeather from './CityWeather.svelte';
import { getWeather } from './weather.remote.js';

let { cities } = $props();
let limit = $state(5);
</script>

<h2>Weather</h2>

{#each cities.slice(0, limit) as city}
<h3>{city.name}</h3>
<CityWeather weather={await getWeather(city.id)} />
{/each}

{#if cities.length > limit}
<button onclick={() => limit += 5}>
Load more
</button>
{/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)...
Expand Down
10 changes: 8 additions & 2 deletions packages/kit/src/exports/internal/remote-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
Expand Down
146 changes: 146 additions & 0 deletions packages/kit/src/runtime/app/server/remote/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input, Output>}
* @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<Schema>[]) => MaybePromise<(arg: StandardSchemaV1.InferOutput<Schema>, idx: number) => Output>} fn
* @returns {RemoteQueryFunction<StandardSchemaV1.InferInput<Schema>, 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<Input, Output>}
* @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<Input>} */
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<Input, Output> & { __: 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<any> & Partial<RemoteQuery<any>>} */
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<any>} */ (promise);
};

promise.withOverride = () => {
throw new Error(`Cannot call '${__.name}.withOverride()' on the server`);
};

return /** @type {RemoteQuery<Output>} */ (promise);
};

Object.defineProperty(wrapper, '__', { value: __ });

return wrapper;
}

// Add batch as a property to the query function
Object.defineProperty(query, 'batch', { value: batch, enumerable: true });
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/client/remote-functions/index.js
Original file line number Diff line number Diff line change
@@ -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';
96 changes: 95 additions & 1 deletion packages/kit/src/runtime/client/remote-functions/query.svelte.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +28,97 @@ export function query(id) {
});
}

/**
* @param {string} id
* @returns {(arg: any) => Query<any>}
*/
export function query_batch(id) {
/** @type {Map<string, Array<{resolve: (value: any) => 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));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this about?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from the existing query code. Last time I specifically tested this I had to give the async function a bit of time before I rejected it to not cause problems with async Svelte. Not sure if that's still the case hence the TODO

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tried deleting it, no tests failed. Not sure if that means it's safe to discard but my inclination is to do so, at most we should be using tick() I think but it might not be necessary at all

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<Promise<T>>}
Expand Down
Loading
Loading