-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat: add new remote function query.batch
#14272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
1affeca
b70a394
bd0c519
41fad93
4819f97
a955c16
1b5f453
5da377d
e036a27
afdae97
6434cf3
e630de7
cbfa1cf
aa7cd56
910125c
2ca6eb1
dfa4cb4
44f60b8
d8d02bc
0986c29
4e81119
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@sveltejs/kit': minor | ||
--- | ||
|
||
feat: add new remote function `query.batch` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
}); | ||
|
||
``` | ||
|
||
```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> | ||
``` | ||
dummdidumm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
## 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)... | ||
|
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 | ||
|
@@ -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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what's this about? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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>>} | ||
|
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)); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.