Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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`
41 changes: 41 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,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<any[]>;
}
// @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;
});

Choose a reason for hiding this comment

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

How does SvelteKit split the array result back out to the n functions? Like if I have functions that call getPost('a'), getPost('b'), getPost('c'), I'd get ['a', 'b', 'c'] on the server, and return the corresponding posts in an array from my query function, but how will SvelteKit know which item in that array to give back to my functions?

Copy link
Member Author

Choose a reason for hiding this comment

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

Is that a question that you want answered in the docs or a "curious about the implementation" question?

Choose a reason for hiding this comment

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

yes 😆

Copy link
Member Author

Choose a reason for hiding this comment

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

added something to the docs, let me know if that helps / is enough

```

```svelte
<script>
import { getPost } from './batch.remote.js';

let { posts } = $props();
</script>

<h1>All my posts</h1>

<ul>
{#each posts as post}
<li>{await getPost(post.id).summary}</li>
{/each}
</ul>
```

## 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
8 changes: 7 additions & 1 deletion packages/kit/src/exports/internal/remote-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
Expand Down
126 changes: 126 additions & 0 deletions packages/kit/src/runtime/app/server/remote/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<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<Output[]>} fn
* @returns {RemoteQueryFunction<StandardSchemaV1.InferInput<Schema>, Output>}
* @since 2.35
*/
/**
* @template Input
* @template Output
* @param {any} validate_or_fn
* @param {(args?: Input[]) => MaybePromise<Output[]>} [maybe_fn]
* @returns {RemoteQueryFunction<Input, Output>}
* @since 2.35
*/
/*@__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<Input>} */
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<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) => {
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<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 });
77 changes: 76 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,78 @@ export function query(id) {
});
}

/**
* @param {string} id
* @returns {(arg: any) => Query<any>}
*/
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));
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
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<Promise<T>>}
Expand Down
17 changes: 17 additions & 0 deletions packages/kit/src/runtime/server/remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ export type ValidatedKitConfig = Omit<RecursiveRequired<KitConfig>, 'adapter'> &

export type RemoteInfo =
| {
type: 'query' | 'command';
type: 'query' | 'query.batch' | 'command';
id: string;
name: string;
}
Expand Down
23 changes: 23 additions & 0 deletions packages/kit/test/apps/basics/src/routes/remote/batch/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script>
import { get_todo } from './batch.remote.js';

const todoIds = ['1', '2'];
</script>

<h1>Query Batch Test</h1>

<ul>
{#each todoIds as id}
<li>
{#await get_todo(id)}
<span>Loading todo {id}...</span>
{:then todo}
<span id="batch-result-{id}">{todo.title}</span>
{:catch error}
<span>Error loading todo {id}: {error.message}</span>
{/await}
</li>
{/each}
</ul>

<button onclick={() => todoIds.forEach((id) => get_todo(id).refresh())}>refresh</button>
Original file line number Diff line number Diff line change
@@ -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));
});
Loading
Loading