Managing a collection of key-value entries

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.


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:

// 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:

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() reads back a slice, with values:

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:

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:

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.

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:

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

← All recipes