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:
Reynaldi Chernando
2026-09-10 01:07:26 +07:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent f3943f6a55
commit 59be4ad237
14 changed files with 892 additions and 279 deletions
+1 -1
View File
@@ -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
View File
@@ -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/">&larr; All recipes</a>
-25
View File
@@ -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;
+103
View File
@@ -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');
```
+8 -7
View File
@@ -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/).
-105
View File
@@ -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/).
-121
View File
@@ -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.
+137
View File
@@ -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.
+107
View File
@@ -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.
+145
View File
@@ -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.
+83
View File
@@ -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