mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 21:26:04 +00:00
more recipes (#3819)
* more recipes * addition * final * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * styling fix --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
f3943f6a55
commit
59be4ad237
@@ -91,7 +91,7 @@ const playgroundHtml = `
|
||||
</h1>
|
||||
<div style="float:right;" class="navbar">
|
||||
<a href="/" target="_blank" style="margin-right: 35px;">Docs</a>
|
||||
<!-- <a href="/recipes/" target="_blank" style="margin-right: 35px;">Recipes</a> -->
|
||||
<a href="/recipes/" target="_blank" style="margin-right: 35px;">Recipes</a>
|
||||
<a style="display: flex; flex-direction: row; align-items: center;"
|
||||
href="https://github.com/heyPuter/puter/" target="_blank"><svg role="img"
|
||||
style="margin-right:4px; margin-bottom: 3px;" width="17" height="17" viewBox="0 0 24 24" fill="#fff"
|
||||
|
||||
+11
-4
@@ -97,6 +97,12 @@ function renderPage ({ title, description, canonical, body }) {
|
||||
// matter. Subdirectories (assets/) are ignored. Returns null if any recipe is
|
||||
// invalid, after printing every problem found — so one build surfaces all of
|
||||
// them rather than one per run.
|
||||
//
|
||||
// A recipe with `draft: true` in its front matter is still validated, so a
|
||||
// mistake in it surfaces before it is published, but it is left out of the
|
||||
// returned list — which is the only list the site has, so the recipe appears
|
||||
// in no page, no index, no sitemap entry and no llms.txt line until the flag
|
||||
// comes off.
|
||||
function readRecipes () {
|
||||
const validTags = Object.keys(recipeTags);
|
||||
const entries = fs.readdirSync(RECIPES_SRC_DIR, { withFileTypes: true })
|
||||
@@ -131,6 +137,8 @@ function readRecipes () {
|
||||
}
|
||||
}
|
||||
|
||||
if ( frontMatter.draft ) continue;
|
||||
|
||||
recipes.push({
|
||||
slug,
|
||||
title: frontMatter.title ?? slug,
|
||||
@@ -245,8 +253,8 @@ function renderIndexPage (recipes) {
|
||||
<main class="recipes-main">
|
||||
<h1>Recipes</h1>
|
||||
<p class="recipes-intro">
|
||||
Prebuilt patterns for common Puter.js tasks — the recommended way to do
|
||||
each of these. Copy one rather than working it out from the API reference.
|
||||
Build specific Puter.js features with recipes from Puter
|
||||
and the community.
|
||||
</p>
|
||||
${renderCards(recipes)}
|
||||
</main>
|
||||
@@ -254,7 +262,7 @@ function renderIndexPage (recipes) {
|
||||
|
||||
return renderPage({
|
||||
title: 'Recipes | Puter.js',
|
||||
description: 'Prebuilt, copy-pasteable patterns for building with Puter.js — AI, storage, auth, hosting, and more.',
|
||||
description: 'Build specific Puter.js features with recipes from Puter and the community.',
|
||||
canonical: `${site}/recipes/`,
|
||||
body,
|
||||
});
|
||||
@@ -269,7 +277,6 @@ function renderRecipePage (recipe, recipes) {
|
||||
<main class="recipes-main recipe-detail">
|
||||
<h1>${encode(recipe.title)}</h1>
|
||||
${renderTagChips(recipe.tags, { linked: true })}
|
||||
<p class="recipe-lede">${encode(recipe.description)}</p>
|
||||
<hr>
|
||||
${marked.parse(recipe.body)}
|
||||
<a class="recipes-back" href="/recipes/">← All recipes</a>
|
||||
|
||||
@@ -85,16 +85,6 @@ body {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipes-intro,
|
||||
.recipe-lede {
|
||||
margin: 0 0 28px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #55585f;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
/* ---------- Sidebar: search + tag filters ---------- */
|
||||
|
||||
.recipes-search input {
|
||||
@@ -296,21 +286,6 @@ a.recipe-tag:hover {
|
||||
|
||||
.recipe-detail .code-wrapper { margin: 18px 0; }
|
||||
|
||||
/* The anchor links the docs' heading renderer emits; the docs sheet styles them
|
||||
only under .docs-content, so they need positioning here. */
|
||||
.recipe-detail .anchored-heading { position: relative; }
|
||||
|
||||
.recipe-detail .anchor::before {
|
||||
content: '#';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
color: #c3c7cf;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.recipe-detail .anchored-heading:hover .anchor::before { opacity: 1; }
|
||||
|
||||
.recipes-back {
|
||||
display: inline-block;
|
||||
margin-top: 36px;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Host Files Online
|
||||
description: "Learn how to host files from the user's Puter filesystem online and give them a public URL that anyone can open."
|
||||
tags: [fs, hosting]
|
||||
order: 50
|
||||
---
|
||||
|
||||
A file written with [`puter.fs.write()`](/FS/write/) or
|
||||
[`puter.fs.upload()`](/FS/upload/) lives in the user's Puter filesystem, where
|
||||
only the SDK can reach it. To hand out a link anyone can open, such as an
|
||||
`<img>` source or a URL you can share, host the directory that file is in
|
||||
online. You host the directory once, and every file you put in it after that
|
||||
already has a URL.
|
||||
|
||||
## Host a Directory
|
||||
|
||||
To host a directory, use the [`puter.hosting.create()`](/Hosting/create/)
|
||||
method. It maps a subdomain onto a directory:
|
||||
|
||||
```js
|
||||
const dir = await puter.fs.mkdir('public');
|
||||
const site = await puter.hosting.create(puter.randName(), 'public');
|
||||
|
||||
site.subdomain; // 'lucky-hill-8121'
|
||||
site.root_dir.path; // '/username/public'
|
||||
```
|
||||
|
||||
The site is at `https://<subdomain>.puter.site`, and everything inside the directory
|
||||
will be served from there.
|
||||
|
||||
Subdomain names are global, so [`puter.randName()`](/Utils/randName/) claims one
|
||||
nobody else has. A name already in use rejects with a `conflict` error, so catch
|
||||
it and try another name.
|
||||
|
||||
## Reuse the Same Subdomain
|
||||
|
||||
One directory needs one subdomain for its lifetime. To find the subdomain a
|
||||
directory already has, use the [`puter.fs.stat()`](/FS/stat/) method with
|
||||
`returnSubdomains: true`:
|
||||
|
||||
```js
|
||||
const DIR = 'public';
|
||||
|
||||
async function publicSite () {
|
||||
const dir = await puter.fs.stat(DIR, { returnSubdomains: true })
|
||||
.catch(() => puter.fs.mkdir(DIR));
|
||||
|
||||
// [{ uuid, subdomain, address: 'https://lucky-hill-8121.puter.site' }]
|
||||
const existing = dir.subdomains?.[0];
|
||||
const subdomain = existing
|
||||
? existing.subdomain
|
||||
: (await puter.hosting.create(puter.randName(), DIR)).subdomain;
|
||||
|
||||
return { dir, base: `https://${ subdomain }.puter.site` };
|
||||
}
|
||||
```
|
||||
|
||||
Each entry carries the label and a ready-made `address`.
|
||||
|
||||
## Write Files Into It
|
||||
|
||||
To put a file in the hosted directory, use the [`puter.fs.write()`](/FS/write/)
|
||||
or [`puter.fs.upload()`](/FS/upload/) method. Both hand back the
|
||||
[`FSItem`](/Objects/fsitem/) that was stored:
|
||||
|
||||
```js
|
||||
const item = await puter.fs.write('public/report.csv', csvText);
|
||||
const items = await puter.fs.upload(fileInput.files, 'public');
|
||||
```
|
||||
|
||||
The [`puter.fs.upload()`](/FS/upload/) method deduplicates names, so `photo.png`
|
||||
lands as `photo (1).png` when the name is taken. Read the name back off the
|
||||
result and use it to keep the URL consistent.
|
||||
|
||||
## Compose the URL
|
||||
|
||||
Everything inside the directory you host is reachable at the site address, and
|
||||
any folder inside it becomes part of the URL:
|
||||
|
||||
```
|
||||
public/photo.png → https://lucky-hill-8121.puter.site/photo.png
|
||||
public/2026/march/pic.jpg → https://lucky-hill-8121.puter.site/2026/march/pic.jpg
|
||||
```
|
||||
|
||||
In code, that is the site address followed by the path you wrote the file to:
|
||||
|
||||
```js
|
||||
const { base } = await publicSite();
|
||||
|
||||
await puter.fs.write('public/2026/photo.png', blob);
|
||||
|
||||
const url = `${ base }/2026/photo.png`;
|
||||
// https://lucky-hill-8121.puter.site/2026/photo.png
|
||||
```
|
||||
|
||||
## Revoke a Link
|
||||
|
||||
To take a link offline, delete the file with the
|
||||
[`puter.fs.delete()`](/FS/delete/) method:
|
||||
|
||||
```js
|
||||
await puter.fs.delete('public/photo.png');
|
||||
```
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
title: Append items to a growing list
|
||||
description: Keep an append-only list such as an event log or a chat history as a real array in one entry, so adding to it is a single write that never reads the list first.
|
||||
description: Keep a running log of things as they happen, like chat messages, events or activity history, and add to it in one line.
|
||||
tags: [kv, data-modeling]
|
||||
order: 10
|
||||
draft: true
|
||||
---
|
||||
|
||||
**Use this when** the only operation is append, such as an event log, a chat
|
||||
@@ -25,8 +26,8 @@ log.length; // 2 (already an array, no parse step)
|
||||
|
||||
## Append with add()
|
||||
|
||||
[`puter.kv.add()`](/KV/add/) appends without the list ever travelling to your app
|
||||
and back:
|
||||
[`puter.kv.add()`](/KV/add/) appends without the list ever travelling to your
|
||||
app and back:
|
||||
|
||||
```js
|
||||
await puter.kv.add('log', [{ at: 3, event: 'saved' }]);
|
||||
@@ -99,9 +100,9 @@ expression is invalid for update"* and the stored value is left unchanged.
|
||||
## Notes
|
||||
|
||||
- Counters use [`puter.kv.incr()`](/KV/incr/); `add()` is the append operation.
|
||||
- Array elements are not path-addressable, so there is no `log.0` to target. If you
|
||||
find yourself needing to change or remove one item, store the items
|
||||
[keyed by id](/recipes/kv-edit-items-by-id/) instead.
|
||||
- Array elements are not path-addressable, so there is no `log.0` to target. If
|
||||
you find yourself needing to change or remove one item, store the items
|
||||
[storing a small list](/recipes/store-small-list/) instead.
|
||||
- A value is capped at **400 KB**. For a list that grows indefinitely, cap it,
|
||||
roll over to a new key, or move to [one key per
|
||||
item](/recipes/kv-prefix-listing/).
|
||||
item](/recipes/store-large-collection/).
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
title: Managing a list of objects inside a key-value entry
|
||||
description: Store items such as todos, tasks or saved records as an object keyed by id in one entry, so changing or removing one of them is a single write instead of rewriting the whole value.
|
||||
tags: [kv, data-modeling]
|
||||
order: 20
|
||||
---
|
||||
|
||||
**Use this when** items are edited or deleted after they are written, such as a
|
||||
todo list, a task board, saved records, or a set of settings. Keying by id is
|
||||
what makes a single item addressable; editing and deleting both fall out of that.
|
||||
The whole value is still read with a single `get()`.
|
||||
|
||||
## Key the items by id
|
||||
|
||||
Store the items as an **object keyed by id**, not as an array. Object paths
|
||||
are addressable, so every operation becomes a single round trip that never reads
|
||||
the list first:
|
||||
|
||||
```js
|
||||
// Add
|
||||
await puter.kv.update('todos', {
|
||||
[id]: { text: 'Buy milk', done: false, at: Date.now() },
|
||||
});
|
||||
|
||||
// Mark done, changing one field of one item
|
||||
await puter.kv.update('todos', { [`${id}.done`]: true });
|
||||
|
||||
// Delete
|
||||
await puter.kv.remove('todos', id);
|
||||
|
||||
// Show
|
||||
const todos = Object.values(await puter.kv.get('todos') ?? {});
|
||||
```
|
||||
|
||||
Each of those is one call with no read-modify-write, which is what an array
|
||||
cannot give you: array elements are not path-addressable, so changing one entry
|
||||
means reading the whole list, editing it in memory, and writing it back.
|
||||
|
||||
That also makes concurrent edits safe. Two tabs marking different todos done
|
||||
write disjoint paths, so both land. The read-modify-write version has a genuine
|
||||
race: both read the list, both write it back, and one of the two updates is
|
||||
lost.
|
||||
|
||||
## A complete todo list
|
||||
|
||||
```html
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
const KEY = 'todos';
|
||||
|
||||
// Ids become dot-separated path segments, so they must not contain dots, and
|
||||
// numeric-looking ids risk being read as an index. A UUID is safe.
|
||||
const newId = () => crypto.randomUUID();
|
||||
|
||||
async function addTodo(text) {
|
||||
const id = newId();
|
||||
await puter.kv.update(KEY, { [id]: { text, done: false, at: Date.now() } });
|
||||
return id;
|
||||
}
|
||||
|
||||
async function setDone(id, done) {
|
||||
await puter.kv.update(KEY, { [`${id}.done`]: done });
|
||||
}
|
||||
|
||||
async function deleteTodo(id) {
|
||||
await puter.kv.remove(KEY, id);
|
||||
}
|
||||
|
||||
async function listTodos() {
|
||||
const todos = await puter.kv.get(KEY) ?? {};
|
||||
// Key order is not guaranteed, so sort on an explicit field.
|
||||
return Object.entries(todos)
|
||||
.map(([id, todo]) => ({ id, ...todo }))
|
||||
.sort((a, b) => a.at - b.at);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const id = await addTodo('Buy milk');
|
||||
await addTodo('Water the plants');
|
||||
await setDone(id, true);
|
||||
|
||||
console.log(await listTodos());
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Ordering
|
||||
|
||||
A map has no inherent order, and the stored field order is not preserved on read.
|
||||
Carry an explicit `at` (or `order`) field on each item and sort when you render,
|
||||
as `listTodos()` does above. At sizes that fit in one entry this costs
|
||||
nothing measurable.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Keep dots out of ids**, since a dot separates path segments.
|
||||
`crypto.randomUUID()` is safe. Avoid numeric-looking ids such as `"1"`, which
|
||||
risk being read as an array index.
|
||||
- `puter.kv.remove(key, ...paths)` takes several paths, so a few items can be
|
||||
deleted in one call: `puter.kv.remove('todos', idA, idB)`.
|
||||
- Removing by array index is the case this shape exists to avoid: `remove(key,
|
||||
'items.0')` returns a success payload and silently changes nothing.
|
||||
- A value is capped at **400 KB**, roughly a few thousand small items. Past that,
|
||||
or when items need their own TTL, switch to [one key per
|
||||
item](/recipes/kv-prefix-listing/).
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
title: Managing a collection of key-value entries
|
||||
description: Give each record its own key under a shared prefix, so single records are written, changed and expired directly while the collection is read back a slice at a time.
|
||||
tags: [kv, data-modeling, performance]
|
||||
order: 30
|
||||
---
|
||||
|
||||
**Use this when** the collection is too big for one entry, past **400 KB**, or
|
||||
records need to expire on their own schedule, or reads want one slice at a time
|
||||
rather than everything at once.
|
||||
|
||||
## Design the key
|
||||
|
||||
Each record is its own entry, so the key is what organises the collection. Build
|
||||
it so the field you filter on comes first:
|
||||
|
||||
```js
|
||||
// todo:<category>:<id>
|
||||
await puter.kv.set(`todo:${category}:${id}`, { text: 'Buy milk', done: false });
|
||||
```
|
||||
|
||||
Pick a field that actually partitions the data, such as a category, a project or
|
||||
a status. The store is already scoped to one user, so a user id in the key adds a
|
||||
level that never varies.
|
||||
|
||||
Everything else in this recipe follows from that layout: a full key addresses one
|
||||
record, and a prefix addresses a slice.
|
||||
|
||||
## Work on one record
|
||||
|
||||
Because each record has its own key, every single-record operation is a direct
|
||||
call with no read of the rest:
|
||||
|
||||
```js
|
||||
const key = `todo:${category}:${id}`;
|
||||
|
||||
await puter.kv.update(key, { done: true }); // change one field
|
||||
await puter.kv.set(key, { text: 'Buy oat milk', done: false }); // replace
|
||||
await puter.kv.del(key); // delete
|
||||
await puter.kv.expire(key, 60 * 60 * 24); // expires on its own, 24h
|
||||
```
|
||||
|
||||
Per-record expiry is the thing this shape gives you that the single-entry ones
|
||||
cannot: a TTL applies to a whole key, so records sharing one entry can only
|
||||
expire together.
|
||||
|
||||
## Read the collection
|
||||
|
||||
[`puter.kv.list()`](/KV/list/) reads back a slice, with values:
|
||||
|
||||
```js
|
||||
const rows = await puter.kv.list(`todo:${category}:`, true);
|
||||
// [ { key: 'todo:home:a', value: { text: 'Buy milk', done: false } },
|
||||
// { key: 'todo:home:b', value: { text: 'Water plants', done: true } } ]
|
||||
```
|
||||
|
||||
Records come back sorted lexicographically by key.
|
||||
|
||||
## Page through it
|
||||
|
||||
Pass `limit` to get a page plus a `cursor`, and keep going until a page comes
|
||||
back without one:
|
||||
|
||||
```js
|
||||
let cursor;
|
||||
do {
|
||||
const page = await puter.kv.list({
|
||||
pattern: `todo:${category}:`,
|
||||
returnValues: true,
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
render(page.items);
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
```
|
||||
|
||||
`for await` does the same thing with `stream: true`:
|
||||
|
||||
```js
|
||||
for await ( const page of puter.kv.list({ pattern: `todo:${category}:`, returnValues: true, limit: 100, stream: true }) ) {
|
||||
render(page.items);
|
||||
}
|
||||
```
|
||||
|
||||
## What a prefix buys you
|
||||
|
||||
The pattern is **prefix-only**, with `*` allowed at the end and nowhere else. So
|
||||
a key buys you exactly **one** filter dimension: whichever field you put first.
|
||||
|
||||
```js
|
||||
puter.kv.list('todo:home:', true); // every todo in the home category
|
||||
puter.kv.list('todo:home:*', true); // the same thing
|
||||
```
|
||||
|
||||
Filtering on a second field, done vs. not done for example, means listing the
|
||||
prefix and filtering the results client-side:
|
||||
|
||||
```js
|
||||
const rows = await puter.kv.list(`todo:${category}:`, true);
|
||||
const open = rows.filter(r => ! r.value.done);
|
||||
```
|
||||
|
||||
Only the leading field is selectable, so give that position to whichever one you
|
||||
read by most often. If that is status rather than category, key on
|
||||
`todo:<done>:<id>` instead and let category become the client-side filter. This
|
||||
is key-prefix partitioning, not a query engine, and there is no secondary
|
||||
index.
|
||||
|
||||
## Notes
|
||||
|
||||
- Every page is metered, and a bare `list()` with no pattern reads the entire
|
||||
store. Always pass a `pattern`, a `limit`, or both.
|
||||
- Results sort lexicographically by key, so zero-pad numbers
|
||||
(`todo:home:000042`) if you want them to sort numerically.
|
||||
- `includeTotal` costs a full count and grows with the store, so request it once
|
||||
on the first page rather than in a hot path. To find out only whether more
|
||||
pages exist, check for `cursor`.
|
||||
- Listing is the expensive operation here. If showing the whole collection is what
|
||||
your app does most and it fits in 400 KB, [keying the items by id](/recipes/kv-edit-items-by-id/)
|
||||
makes that a single `get()` instead of a scan.
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Query a Collection
|
||||
description: "Learn how to perform queries on a collection of records in the Puter.js key-value database."
|
||||
tags: [kv, data-modeling, performance]
|
||||
order: 35
|
||||
---
|
||||
|
||||
In some cases your app needs filtering, whether by category, status, date or
|
||||
something else. You can do that with a key prefix. This picks up where [store a
|
||||
large collection](/recipes/store-large-collection/) leaves off, with each record
|
||||
under its own key.
|
||||
|
||||
## Put the Filter Criteria in the Key
|
||||
|
||||
To query by a field, embed it in the key when you write the record with the
|
||||
[`puter.kv.set()`](/KV/set/) method. Notice that the status comes before the
|
||||
unique id:
|
||||
|
||||
```js
|
||||
// order:<status>:<id>
|
||||
await puter.kv.set(`order:${ status }:${ id }`, order);
|
||||
```
|
||||
|
||||
To read one slice, use the [`puter.kv.list()`](/KV/list/) method with that
|
||||
prefix. The `true` brings the values back with the keys, so one round trip both
|
||||
selects and loads:
|
||||
|
||||
```js
|
||||
const pending = await puter.kv.list('order:pending:*', true);
|
||||
// [ { key: 'order:pending:0001', value: { customer: 'alice', total: 48 } }, ... ]
|
||||
```
|
||||
|
||||
## How a Pattern Matches
|
||||
|
||||
The [`puter.kv.list()`](/KV/list/) method matches a prefix of the key, byte for
|
||||
byte:
|
||||
|
||||
```js
|
||||
await puter.kv.list('order:pending:'); // every pending order
|
||||
await puter.kv.list('order:pending:*'); // the same set, the trailing * is optional
|
||||
await puter.kv.list('order:pend'); // also matches, a prefix can stop mid-segment
|
||||
await puter.kv.list('order:*:alice:'); // empty, the * is a literal asterisk here
|
||||
await puter.kv.list('ORDER:pending:'); // empty, matching is case-sensitive
|
||||
```
|
||||
|
||||
Lowercase the segments you build keys from, and the case a value arrives in
|
||||
stops mattering:
|
||||
|
||||
```js
|
||||
await puter.kv.set(`order:${ status.toLowerCase() }:${ id }`, order);
|
||||
```
|
||||
|
||||
## Filter on a Second Field
|
||||
|
||||
Every prefix of a key is a filter you can read, so an extra segment adds a
|
||||
filter:
|
||||
|
||||
```js
|
||||
// order:<status>:<customer>:<id>
|
||||
await puter.kv.set(`order:${ status }:${ customer }:${ id }`, order);
|
||||
|
||||
await puter.kv.list('order:pending:', true); // by status
|
||||
await puter.kv.list('order:pending:alice:', true); // by status and customer
|
||||
```
|
||||
|
||||
The segments read left to right, so this layout answers "pending" and "pending
|
||||
for alice", while "everything for alice" needs its own key. Put the field every
|
||||
view filters on first, and the one only some views add second.
|
||||
|
||||
## Query by Another Field
|
||||
|
||||
To filter by a different field, you need a duplicate key that starts with that
|
||||
field. Write it beside the record and copy in the fields the view shows:
|
||||
|
||||
```js
|
||||
const order = { id, status, customer, total };
|
||||
|
||||
await puter.kv.set(`order:id:${ id }`, order);
|
||||
await puter.kv.set(`order:by-customer:${ customer }:${ id }`, { id, status, total });
|
||||
```
|
||||
|
||||
Reading by customer is then one query, the same as any other prefix:
|
||||
|
||||
```js
|
||||
const rows = await puter.kv.list('order:by-customer:alice:*', true);
|
||||
```
|
||||
|
||||
Since both keys hold the data, you need to make sure every write happens to
|
||||
both. Update the duplicate whenever the record changes, and delete it whenever
|
||||
the record is deleted.
|
||||
|
||||
## Filter the Rest in Your Code
|
||||
|
||||
A field you rarely filter on can stay out of the key. Narrow the read with a
|
||||
prefix first, then filter what came back:
|
||||
|
||||
```js
|
||||
const rows = await puter.kv.list('order:pending:', true);
|
||||
const large = rows.filter(row => row.value.total > 50);
|
||||
```
|
||||
|
||||
That works once the prefix has already cut the set down to about a page, and it
|
||||
keeps rarely used filters out of the key layout.
|
||||
|
||||
## Filter by Date
|
||||
|
||||
ISO 8601 timestamps sort chronologically as text, so a prefix of one is a
|
||||
calendar range:
|
||||
|
||||
```js
|
||||
await puter.kv.set(`log:${ new Date().toISOString() }`, entry);
|
||||
// log:2026-09-09T14:03:11.204Z
|
||||
|
||||
await puter.kv.list('log:2026-09-', true); // September
|
||||
await puter.kv.list('log:2026-09-09', true); // one day
|
||||
await puter.kv.list('log:2026-09-09T14', true); // one hour
|
||||
```
|
||||
|
||||
A listing comes back in ascending key order, so newest first is a `reverse()` on
|
||||
the page, or a key built from a counted-down timestamp such as `String(1e13 -
|
||||
Date.now())`.
|
||||
|
||||
## Filter by Number
|
||||
|
||||
A number in a key sorts as text, so `10` lands before `9`. Pad it to a fixed
|
||||
width and the order comes out numeric, which also makes a digit prefix a range:
|
||||
|
||||
```js
|
||||
await puter.kv.set(`invoice:${ String(number).padStart(6, '0') }`, invoice);
|
||||
// invoice:000042
|
||||
|
||||
await puter.kv.list('invoice:*', true); // every invoice, in numeric order
|
||||
await puter.kv.list('invoice:000*', true); // 000000 to 000999
|
||||
```
|
||||
|
||||
Pick a width the numbers will not outgrow. Once the count passes six digits,
|
||||
`invoice:1000000` sorts before `invoice:999999` and the order breaks.
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Store Data
|
||||
description: "Learn how to store data in the Puter.js key-value database. Every entry lives inside the user's own Puter account."
|
||||
tags: [kv, auth, data-modeling]
|
||||
order: 5
|
||||
---
|
||||
|
||||
The foundation of every application is storing data, and in Puter.js you do that
|
||||
with the [key-value store API](/KV/). It supports the standard operations you
|
||||
would expect from any database, such as writes, reads, updates, deletes, and
|
||||
more. All data lives inside the user's own Puter account.
|
||||
|
||||
## Set
|
||||
|
||||
To store data, use the [`puter.kv.set()`](/KV/set/) method. It takes a key and a
|
||||
value:
|
||||
|
||||
```js
|
||||
await puter.kv.set('theme', 'dark');
|
||||
```
|
||||
|
||||
A value can be a string, a number, a boolean, or a whole object or array, so a
|
||||
structured record goes in the same way a single setting does:
|
||||
|
||||
```js
|
||||
await puter.kv.set('settings', { theme: 'dark', sound: false, volume: 0.8 });
|
||||
await puter.kv.set('recent', ['puter.js', 'kv', 'workers']);
|
||||
```
|
||||
|
||||
Setting the same key again replaces its value:
|
||||
|
||||
```js
|
||||
await puter.kv.set('theme', 'dark');
|
||||
await puter.kv.set('theme', 'light'); // 'theme' is now 'light'
|
||||
```
|
||||
|
||||
## Get
|
||||
|
||||
To read it back, use the [`puter.kv.get()`](/KV/get/) method. It takes a key and
|
||||
returns the value in the shape you stored it:
|
||||
|
||||
```js
|
||||
const settings = await puter.kv.get('settings');
|
||||
settings.theme; // 'dark'
|
||||
```
|
||||
|
||||
A key that was never written comes back empty, which is where your defaults go:
|
||||
|
||||
```js
|
||||
const settings = await puter.kv.get('settings') ?? { theme: 'light', sound: true };
|
||||
```
|
||||
|
||||
For most apps that is the whole storage layer. You call
|
||||
[`puter.kv.set()`](/KV/set/) when something changes and
|
||||
[`puter.kv.get()`](/KV/get/) when the app loads.
|
||||
|
||||
## Where the Data Lives
|
||||
|
||||
Each entry is written to the **signed-in user's own account**, inside a sandbox
|
||||
that belongs to your app. User A's `settings` and user B's `settings` are
|
||||
separate entries, and neither user can read the other's. Every other app in the
|
||||
same account gets its own sandbox, so your keys and another app's keys never
|
||||
mix. The user covers their own storage under the [User-Pays
|
||||
Model](/user-pays-model/).
|
||||
|
||||
## List
|
||||
|
||||
To see what you stored, use the [`puter.kv.list()`](/KV/list/) method. It
|
||||
returns the keys your app wrote for this user, sorted by key:
|
||||
|
||||
```js
|
||||
const keys = await puter.kv.list();
|
||||
// ['recent', 'settings', 'theme']
|
||||
```
|
||||
|
||||
Pass `true` to get the values along with them:
|
||||
|
||||
```js
|
||||
const entries = await puter.kv.list(true);
|
||||
// [{ key: 'recent', value: ['puter.js', 'kv', 'workers'] }, ...]
|
||||
```
|
||||
|
||||
## Delete
|
||||
|
||||
To remove one entry, use the [`puter.kv.del()`](/KV/del/) method:
|
||||
|
||||
```js
|
||||
await puter.kv.del('theme');
|
||||
```
|
||||
|
||||
## Flush
|
||||
|
||||
To empty your app's sandbox for this user, use the
|
||||
[`puter.kv.flush()`](/KV/flush/) method:
|
||||
|
||||
```js
|
||||
await puter.kv.flush();
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Binary data, such as an image, an audio clip or a PDF, goes to the user's
|
||||
drive with the [filesystem API](/FS/) instead. [Store files in the user's own
|
||||
Puter account](/recipes/store-files/) covers it.
|
||||
- Data every user has to see, such as a leaderboard or a guestbook, goes [behind
|
||||
a worker](/recipes/store-server-side-data/), which runs under your account
|
||||
instead of theirs.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: Store Files
|
||||
description: "Learn how to store binary files in Puter.js object storage. Every file lives inside the user's own Puter account."
|
||||
tags: [fs, auth]
|
||||
order: 7
|
||||
---
|
||||
|
||||
Most applications need somewhere to keep binary files, such as images, videos,
|
||||
PDFs, or exports the app generates. In Puter.js that object storage is the
|
||||
[filesystem API](/FS/). It supports the standard operations you would expect
|
||||
from any filesystem, such as writes, reads, listings, deletes, and more. Files
|
||||
are addressed by path rather than by bucket and key, and they all live inside
|
||||
the user's own Puter account.
|
||||
|
||||
## Write
|
||||
|
||||
To save a file, use the [`puter.fs.write()`](/FS/write/) method. It takes a path
|
||||
and the contents:
|
||||
|
||||
```js
|
||||
await puter.fs.write('notes/todo.txt', 'Buy milk');
|
||||
```
|
||||
|
||||
The contents can be a `String`, `File`, `Blob`, `ArrayBuffer` or typed array, so
|
||||
text and binary data are written the same way. Writing to a path that already
|
||||
exists replaces the file, and passing `dedupeName` keeps both copies by saving
|
||||
the new one under a free name:
|
||||
|
||||
```js
|
||||
await puter.fs.write('uploads/photo.png', blob, { dedupeName: true });
|
||||
```
|
||||
|
||||
Writing into a directory that does not exist yet is one more option:
|
||||
|
||||
```js
|
||||
await puter.fs.write('exports/2026/report.csv', csv, { createMissingParents: true });
|
||||
```
|
||||
|
||||
## Read
|
||||
|
||||
To read a file back, use the [`puter.fs.read()`](/FS/read/) method. It hands
|
||||
back a `Blob`:
|
||||
|
||||
```js
|
||||
const blob = await puter.fs.read('notes/todo.txt');
|
||||
await blob.text(); // 'Buy milk'
|
||||
```
|
||||
|
||||
A `Blob` is what the browser already works with, so `.text()`, `.arrayBuffer()`
|
||||
and `URL.createObjectURL()` all work on the result.
|
||||
|
||||
## Where the Files Live
|
||||
|
||||
Each file is written to the **signed-in user's own drive**. User A's
|
||||
`notes/todo.txt` and user B's are separate files, neither user can read the
|
||||
other's, and the user covers their own storage under the [User-Pays
|
||||
Model](/user-pays-model/).
|
||||
|
||||
A relative path resolves against `~/AppData/<your-app-id>/`, the sandbox Puter
|
||||
creates for your app the first time the user signs in. You can create any files
|
||||
and folders you like inside it, and your app cannot see anything outside it.
|
||||
|
||||
## Upload
|
||||
|
||||
To take a file from an `<input type="file">`, use the
|
||||
[`puter.fs.upload()`](/FS/upload/) method:
|
||||
|
||||
```js
|
||||
input.onchange = async () => {
|
||||
const file = await puter.fs.upload(input.files, 'uploads', { createMissingParents: true });
|
||||
file.path; // '/user/AppData/app-.../uploads/photo.png'
|
||||
};
|
||||
```
|
||||
|
||||
One selected file resolves to one [`FSItem`](/Objects/fsitem/) and several
|
||||
resolve to an array of them. A name that is already taken stays as it is, and
|
||||
the new file lands under a free one.
|
||||
|
||||
## List
|
||||
|
||||
To see what a directory holds, use the [`puter.fs.readdir()`](/FS/readdir/)
|
||||
method:
|
||||
|
||||
```js
|
||||
const items = await puter.fs.readdir('uploads', { sortBy: 'modified', sortOrder: 'desc' });
|
||||
// [ { name: 'photo.png', path: '/user/AppData/app-.../uploads/photo.png', size, modified, isDir }, ... ]
|
||||
```
|
||||
|
||||
It pages the way a key listing does, with `limit` and `cursor`, or `stream:
|
||||
true` for `for await`, which is what keeps a directory of thousands affordable
|
||||
to display.
|
||||
|
||||
To read the metadata of one file, use the [`puter.fs.stat()`](/FS/stat/) method:
|
||||
|
||||
```js
|
||||
const info = await puter.fs.stat('uploads/photo.png'); // size, timestamps, id
|
||||
```
|
||||
|
||||
## Rename, Move and Copy
|
||||
|
||||
To reorganize what is there, use the [`puter.fs.rename()`](/FS/rename/),
|
||||
[`puter.fs.move()`](/FS/move/) and [`puter.fs.copy()`](/FS/copy/) methods:
|
||||
|
||||
```js
|
||||
await puter.fs.rename('uploads/photo.png', 'cover.png'); // second argument is a name
|
||||
await puter.fs.move('uploads/draft.png', 'archive'); // second argument is a directory
|
||||
await puter.fs.copy('uploads/cover.png', 'archive');
|
||||
```
|
||||
|
||||
## Delete
|
||||
|
||||
To remove a file, use the [`puter.fs.delete()`](/FS/delete/) method:
|
||||
|
||||
```js
|
||||
await puter.fs.delete('uploads/old.png');
|
||||
```
|
||||
|
||||
It also takes an array of paths to remove several items in one call, and
|
||||
deleting a directory is recursive by default.
|
||||
|
||||
## Get a Link
|
||||
|
||||
To show a file in an `<img>` tag or hand it to a download button, use the
|
||||
[`puter.fs.getReadURL()`](/FS/getReadURL/) method. It mints a temporary URL for
|
||||
one file:
|
||||
|
||||
```js
|
||||
const url = await puter.fs.getReadURL('uploads/photo.png', '1h');
|
||||
```
|
||||
|
||||
The URL gives temporary access to that one file. It expires after the duration
|
||||
you pass, or in 24 hours if you leave the duration off.
|
||||
|
||||
## Notes
|
||||
|
||||
- Structured data, such as settings, records and lists, belongs in the
|
||||
[key-value database](/KV/) instead. A JSON file means reading and rewriting
|
||||
the whole thing on every change, while a key-value entry addresses one record
|
||||
at a time. [Store data in the Puter.js key-value
|
||||
database](/recipes/store-data/) covers it.
|
||||
- Files every user of the app has to reach go [behind a
|
||||
worker](/recipes/store-server-side-data/), which runs under your account
|
||||
instead of theirs.
|
||||
- A URL that stays valid takes hosting a directory instead of minting a link per
|
||||
file. [Host a file online](/recipes/host-file/) covers it.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: Store a Large Collection
|
||||
description: "Learn how to store a large collection of data in the Puter.js key-value database. It fits any number of records and you can read or change one at a time."
|
||||
tags: [kv, data-modeling, performance]
|
||||
order: 30
|
||||
---
|
||||
|
||||
You can keep an entire table of records in the Puter.js key-value database, such
|
||||
as every todo in an app or every order in a store. Each record gets its own key,
|
||||
and the key is the part you design up front. In a regular database you pick a
|
||||
primary key and an index. Here you do both in one string, since a full key reads
|
||||
one record and a key prefix reads a group of them.
|
||||
|
||||
## Store a Record
|
||||
|
||||
Give each record its own key and write it with the [`puter.kv.set()`](/KV/set/)
|
||||
method. Name the collection first and put the record id last:
|
||||
|
||||
```js
|
||||
await puter.kv.set(`todo:${ id }`, { text: 'Buy milk', done: false });
|
||||
```
|
||||
|
||||
The id works like a primary key. It has to be unique inside the collection, and
|
||||
it is how you reach that record later, so any unique string does, such as
|
||||
`crypto.randomUUID()` or a timestamp.
|
||||
|
||||
## Read, Change or Delete a Record
|
||||
|
||||
To read one record, use the [`puter.kv.get()`](/KV/get/) method with its full
|
||||
key. To change a field, use [`puter.kv.update()`](/KV/update/), and to replace
|
||||
the record, use [`puter.kv.set()`](/KV/set/) again. To remove it, use
|
||||
[`puter.kv.del()`](/KV/del/). None of them read or write the rest of the
|
||||
collection:
|
||||
|
||||
```js
|
||||
const key = `todo:${ id }`;
|
||||
|
||||
const todo = await puter.kv.get(key);
|
||||
await puter.kv.update(key, { done: true });
|
||||
await puter.kv.set(key, { text: 'Buy oat milk', done: false });
|
||||
await puter.kv.del(key);
|
||||
```
|
||||
|
||||
## Read the Collection
|
||||
|
||||
To read the records back, use the [`puter.kv.list()`](/KV/list/) method with the
|
||||
name of the collection. The `true` brings the values back with the keys, so one
|
||||
round trip both selects and loads:
|
||||
|
||||
```js
|
||||
const rows = await puter.kv.list('todo:*', true);
|
||||
// [ { key: 'todo:a', value: { text: 'Buy milk', done: false } },
|
||||
// { key: 'todo:b', value: { text: 'Water plants', done: true } } ]
|
||||
```
|
||||
|
||||
To narrow that down, filter the results in your own code:
|
||||
|
||||
```js
|
||||
const open = rows.filter(row => ! row.value.done);
|
||||
```
|
||||
|
||||
Every record is still sent to your app that way, and you throw most of them
|
||||
away.
|
||||
|
||||
## Filter with a Key Prefix
|
||||
|
||||
To have the database do the filtering, you can perform filter based on the key.
|
||||
The [`puter.kv.list()`](/KV/list/) method accepts a key prefix match, allowing
|
||||
you to only retrieve the records with the matched prefix. The tradeoff is that
|
||||
you design the key before you write any records:
|
||||
|
||||
```js
|
||||
// todo:<category>:<id>
|
||||
await puter.kv.set(`todo:${ category }:${ id }`, { text: 'Buy milk', done: false });
|
||||
```
|
||||
|
||||
A prefix now reads one category, and only those records come back:
|
||||
|
||||
```js
|
||||
const rows = await puter.kv.list(`todo:${ category }:*`, true);
|
||||
```
|
||||
|
||||
Pick a field that actually partitions the data, such as a category, a project or
|
||||
a status.
|
||||
|
||||
The pattern is prefix-only, with `*` allowed at the end, so a key gives you
|
||||
exactly one filter dimension. Decide which field needs the filter by prefix,
|
||||
since filtering on a different field later means rewriting every key you have
|
||||
already written.
|
||||
|
||||
Records sort lexicographically by key, so zero-pad any number you want to sort
|
||||
numerically, as in `todo:home:000042`.
|
||||
|
||||
## Page Through a Collection
|
||||
|
||||
To read a large collection a page at a time, pass a `limit` and keep going until
|
||||
a page comes back with no `cursor`:
|
||||
|
||||
```js
|
||||
let cursor;
|
||||
do {
|
||||
const page = await puter.kv.list({
|
||||
pattern: `todo:*`,
|
||||
returnValues: true,
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
console.log(page.items);
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
```
|
||||
|
||||
To show numbered pages instead, jump to a page with an `offset`:
|
||||
|
||||
```js
|
||||
const pageSize = 20;
|
||||
const pageNumber = 3;
|
||||
|
||||
const page = await puter.kv.list({
|
||||
pattern: `todo:*`,
|
||||
returnValues: true,
|
||||
limit: pageSize,
|
||||
offset: (pageNumber - 1) * pageSize,
|
||||
includeTotal: true,
|
||||
});
|
||||
|
||||
console.log(page.items);
|
||||
console.log(`Page ${ pageNumber } of ${ Math.ceil(page.total / pageSize) }`);
|
||||
```
|
||||
|
||||
The `offset` skips the records on the pages before this one, and `includeTotal:
|
||||
true` adds a `total` to the page with the number of records matching the
|
||||
pattern, which is what gives you a page count. The largest offset allowed is
|
||||
5000.
|
||||
|
||||
## Set a TTL
|
||||
|
||||
To have a record delete itself later, set a TTL on it with the
|
||||
[`puter.kv.expire()`](/KV/expire/) method. It takes the key and a number of
|
||||
seconds:
|
||||
|
||||
```js
|
||||
await puter.kv.expire(`todo:${ category }:${ id }`, 60 * 60 * 24);
|
||||
```
|
||||
|
||||
A TTL applies to one key, so each record counts down on its own.
|
||||
|
||||
## Notes
|
||||
|
||||
- A [`puter.kv.list()`](/KV/list/) call with no pattern reads the entire store,
|
||||
and every page is metered. Always pass a `pattern`, a `limit`, or both.
|
||||
- There is no secondary index, so a read path that needs a different filter
|
||||
needs a different key layout. [Query a collection](/recipes/query-collection/)
|
||||
covers the layouts that work, such as composite key segments and a second key
|
||||
per read path.
|
||||
- For a list of a few hundred items that you always read all of, [store a small
|
||||
list](/recipes/store-small-list/) is one read instead of a page at a time.
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Store Server-Side Data
|
||||
description: "Learn how to store data that every user of your app can read and update, instead of each user only accessing their own storage."
|
||||
tags: [workers, kv, auth]
|
||||
order: 40
|
||||
---
|
||||
|
||||
Everything you store with [`puter.kv`](/KV/) or [`puter.fs`](/FS/) lives in the
|
||||
user's own account, which works differently than a traditional backend. If your app requires users reading and writing to the same data, use a [serverless worker](/Workers/) instead. This allows you to run server-side JavaScript code and you can have your users accessing the same data, which belongs to you as the developer.
|
||||
|
||||
## Worker Context and User Context
|
||||
|
||||
Inside a worker you get your own Puter context in the `me.puter` global object,
|
||||
so you can store data in your [key-value database](/KV/). That centralizes the
|
||||
data and keeps it server-side for your users.
|
||||
|
||||
You also get the caller's Puter context in the `user.puter` parameter when the
|
||||
request is authenticated, which is how you get information about the user.
|
||||
|
||||
Data only its owner reads has no reason to make this trip.
|
||||
[Store data](/recipes/store-data/) covers [`puter.kv`](/KV/) in app code, which
|
||||
is already per-user and costs no round trip through your worker.
|
||||
|
||||
## Write a Record
|
||||
|
||||
The handler writes to your own [key-value database](/KV/) through `me.puter`, so
|
||||
every player's row lands in the same store, and it uses `user.puter` to find out
|
||||
who is calling:
|
||||
|
||||
```js
|
||||
// leaderboard.js
|
||||
const PREFIX = 'leaderboard:score:';
|
||||
|
||||
router.post('/scores', async ({ request, user }) => {
|
||||
if ( ! user ) {
|
||||
return new Response('sign in required', { status: 401 });
|
||||
}
|
||||
|
||||
const { uuid, username } = await user.puter.getUser();
|
||||
const { score } = await request.json();
|
||||
if ( typeof score !== 'number' ) {
|
||||
return new Response('invalid score', { status: 400 });
|
||||
}
|
||||
|
||||
await me.puter.kv.set(`${ PREFIX }${ uuid }`, { username, score, at: Date.now() });
|
||||
return { username, score };
|
||||
});
|
||||
```
|
||||
|
||||
The key is built from the caller's `uuid`, so a request can only ever touch its
|
||||
own row, whatever the request body says.
|
||||
|
||||
For `user` to be there at all, your app has to call the worker with the
|
||||
[`puter.workers.exec()`](/Workers/exec/) method, which attaches the signed-in
|
||||
user's session:
|
||||
|
||||
```js
|
||||
await puter.workers.exec(`${ API }/scores`, { method: 'POST', body });
|
||||
```
|
||||
|
||||
A plain `fetch()` of the same URL arrives without a session, so the `! user`
|
||||
branch is how a route becomes sign-in-only. The last section covers the client
|
||||
side in full.
|
||||
|
||||
## Read the Records
|
||||
|
||||
To read the data back, use the same `me.puter` context and list it with the
|
||||
[`puter.kv.list()`](/KV/list/) method:
|
||||
|
||||
```js
|
||||
router.get('/scores', async () => {
|
||||
const rows = await me.puter.kv.list(PREFIX, true);
|
||||
return rows
|
||||
.map(row => row.value)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20);
|
||||
});
|
||||
```
|
||||
|
||||
A handler returning a plain object or array is sent as JSON. Read-all and
|
||||
write-own both fall out of the key, since the `GET` handler lists the prefix and
|
||||
the `POST` handler addresses exactly one key inside it. See
|
||||
[`router`](/Workers/router/) for the rest of the handler surface.
|
||||
|
||||
## Deploy the Worker
|
||||
|
||||
The worker is one file you write to your Puter account and create once, which
|
||||
gives it a permanent URL such as `https://leaderboard-api.puter.work`. See
|
||||
[deployment](/Workers/#deployment) for the steps.
|
||||
|
||||
## Call It From Your App
|
||||
|
||||
To call the worker with the signed-in user's session attached, use the
|
||||
[`puter.workers.exec()`](/Workers/exec/) method, which takes the same arguments
|
||||
as `fetch()`:
|
||||
|
||||
```js
|
||||
const API = 'https://leaderboard-api.puter.work';
|
||||
|
||||
// Submit this player's score
|
||||
const res = await puter.workers.exec(`${ API }/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ score: 120 }),
|
||||
});
|
||||
await res.json(); // { username: 'grace', score: 120 }
|
||||
|
||||
// Read the whole board
|
||||
const board = await (await puter.workers.exec(`${ API }/scores`)).json();
|
||||
// [ { username: 'ada', score: 450, at: 1788820000000 },
|
||||
// { username: 'grace', score: 120, at: 1788827048741 } ]
|
||||
```
|
||||
|
||||
Every user of your app now reads and writes the same board, because every one of
|
||||
these requests lands on the same store.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Store a Small List
|
||||
description: "Learn how to keep a list of items, such as todos or notes, inside one key-value entry so you can retrieve data in a single read. It fits a few thousand small items."
|
||||
tags: [kv, data-modeling]
|
||||
order: 20
|
||||
---
|
||||
|
||||
Most applications keep a list the user edits later, such as todos, notes, saved
|
||||
records or a task board. The whole list can live in one key-value entry, so a
|
||||
screen loads with a single [`puter.kv.get()`](/KV/get/) and there is nothing to
|
||||
page through.
|
||||
|
||||
You can store the list as an object with an item id key. This lets you add,
|
||||
edit, and delete each item in one call without manually reading the entire list.
|
||||
|
||||
## Add an Item
|
||||
|
||||
To add an item, use the [`puter.kv.update()`](/KV/update/) method with the id as
|
||||
the path:
|
||||
|
||||
```js
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
await puter.kv.update('todos', {
|
||||
[id]: { text: 'Buy milk', done: false, at: Date.now() },
|
||||
});
|
||||
```
|
||||
|
||||
The id becomes the key you reference later to update or delete that item. Any
|
||||
unique string works, and
|
||||
[`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID)
|
||||
is a safe default.
|
||||
|
||||
## Show the List
|
||||
|
||||
To load the list, use the [`puter.kv.get()`](/KV/get/) method. One read returns
|
||||
every item:
|
||||
|
||||
```js
|
||||
const todos = await puter.kv.get('todos') ?? {};
|
||||
|
||||
const items = Object.entries(todos)
|
||||
.map(([id, todo]) => ({ id, ...todo }))
|
||||
.sort((a, b) => a.at - b.at);
|
||||
```
|
||||
|
||||
An object has no inherent order, and the stored field order is not preserved on
|
||||
read, so carry an `at` or `order` field on each item and sort when you render.
|
||||
At sizes that fit in one entry, sorting in memory costs nothing measurable.
|
||||
|
||||
## Edit an Item
|
||||
|
||||
To change one field of one item, use the [`puter.kv.update()`](/KV/update/)
|
||||
method with the item's id and the field you are changing:
|
||||
|
||||
```js
|
||||
await puter.kv.update('todos', { [`${ id }.done`]: true });
|
||||
```
|
||||
|
||||
This updates the specific property of the object with that id, without you
|
||||
having to manually iterate the whole list and update it.
|
||||
|
||||
## Delete an Item
|
||||
|
||||
To remove an item, use the [`puter.kv.remove()`](/KV/remove/) method with its
|
||||
id:
|
||||
|
||||
```js
|
||||
await puter.kv.remove('todos', id);
|
||||
```
|
||||
|
||||
It also takes several paths in one call, so `remove('todos', idA, idB)` deletes
|
||||
two items at once.
|
||||
|
||||
## When to Switch
|
||||
|
||||
One entry holds up to [400 KB](/KV/MAX_VALUE_SIZE/), which is a few thousand
|
||||
small items. If your list holds more than that, or you expect it to, [store a
|
||||
large collection](/recipes/store-large-collection/) instead, which gives you:
|
||||
|
||||
- no ceiling on how many records you keep
|
||||
- an expiry per record, instead of one for the whole list
|
||||
- reads a page at a time, instead of the whole list on every render
|
||||
Reference in New Issue
Block a user