Split a Large Value Across Keys


A chat history or activity log can start out in a single key, with new messages added by puter.kv.add(). But it keeps growing, and one key has a size limit. Well before it reaches that, it also gets expensive, because reads and writes are billed by the size of the whole entry.

The fix is to split it across several smaller keys, often called shards. New messages go into the latest shard instead of one big entry. For one large document that's always read and saved as a whole, like a long text, use a file instead, since files don't have that limit.

What a Large Entry Costs

Reads are billed per 4 KB of the entry, and writes per 1 KB. Compared to a small entry, an entry at the size limit costs about 100 times as much to read and about 400 times as much to write.

What matters is the size of the whole entry, not how much you change:

For example, with messages of about 200 bytes and a chat with 1,000 messages:

One key Shards of 50 messages
Size of the entry you write to ~200 KB ~10 KB at most
Adding one message ~200 small writes ~10 small writes
Loading the latest messages ~50 small reads ~3 small reads
Loading the whole chat ~50 small reads ~50 small reads

Splitting makes adding messages and loading recent ones much cheaper. Loading everything costs about the same either way, since it's the same amount of data.

Split a History into Shards

Give each message a number with puter.kv.incr(), and use the number to pick its shard. With 50 messages per shard, messages 1 to 50 go in shard 0, 51 to 100 in shard 1, and so on:

const PER_SHARD = 50;

const shardKey = (chatId, shard) =>
    `chat:${ chatId }:shard:${ String(shard).padStart(6, '0') }`;

async function addMessage (chatId, text) {
    const number = await puter.kv.incr(`chat:${ chatId }:count`);
    const shard = Math.floor((number - 1) / PER_SHARD);

    await puter.kv.add(shardKey(chatId, shard), [{ number, text, at: Date.now() }]);
}

puter.kv.incr() gives every message its own number, even if two tabs send at the same moment, so a shard never gets more than 50 messages. The zero padding keeps the shard keys in order when they're listed.

Aim for shards of about 10 to 50 KB, and cap how long a message can be, so 50 messages can't grow bigger than you planned. The PER_SHARD value decides which shard each message goes in, so keep it fixed once you have data.

Messages sent at the same moment can land out of order inside a shard. Each one has its number, so sort by that when reading.

Load the Latest Messages

The counter tells you which shard is the newest, so loading the latest messages takes two small reads:

async function latestShard (chatId) {
    const count = await puter.kv.get(`chat:${ chatId }:count`) ?? 0;
    return Math.floor((count - 1) / PER_SHARD);   // -1 when there are no messages
}

async function readShard (chatId, shard) {
    const messages = await puter.kv.get(shardKey(chatId, shard)) ?? [];
    return messages.sort((a, b) => a.number - b.number);
}

const newest = await latestShard(chatId);
const messages = newest >= 0 ? await readShard(chatId, newest) : [];

For a "Load older messages" button, read the shard before the one you're showing with readShard(chatId, shard - 1). If the newest shard only has a few messages, load the one before it too, so the screen isn't mostly empty.

Load the Whole History

To load everything, for example for an export, list the shards with their values a page at a time:

async function readAll (chatId) {
    const all = [];

    for await ( const page of puter.kv.list({ pattern: `chat:${ chatId }:shard:`, returnValues: true, stream: true }) ) {
        for ( const { value } of page.items ) {
            all.push(...value);
        }
    }
    return all.sort((a, b) => a.number - b.number);
}

Let Old Shards Expire

To keep only recent history, give each shard a TTL when its first message is added, the same way Add Counters expires daily counts. This is the whole addMessage() with that added:

async function addMessage (chatId, text) {
    const number = await puter.kv.incr(`chat:${ chatId }:count`);
    const shard = Math.floor((number - 1) / PER_SHARD);
    const key = shardKey(chatId, shard);

    await puter.kv.add(key, [{ number, text, at: Date.now() }]);

    if ( number % PER_SHARD === 1 ) {
        await puter.kv.expire(key, 60 * 60 * 24 * 90);   // 90 days after the shard started
    }
}

Later messages keep the TTL, so a shard is deleted 90 days after its first message. To delete a whole chat, delete the keys that start with chat:<id>:, as in Export or Delete Data.

Full Code

const PER_SHARD = 50;

const shardKey = (chatId, shard) =>
    `chat:${ chatId }:shard:${ String(shard).padStart(6, '0') }`;

async function addMessage (chatId, text) {
    const number = await puter.kv.incr(`chat:${ chatId }:count`);
    const shard = Math.floor((number - 1) / PER_SHARD);
    const key = shardKey(chatId, shard);

    await puter.kv.add(key, [{ number, text, at: Date.now() }]);

    if ( number % PER_SHARD === 1 ) {
        await puter.kv.expire(key, 60 * 60 * 24 * 90);   // 90 days after the shard started
    }
}

async function latestShard (chatId) {
    const count = await puter.kv.get(`chat:${ chatId }:count`) ?? 0;
    return Math.floor((count - 1) / PER_SHARD);   // -1 when there are no messages
}

async function readShard (chatId, shard) {
    const messages = await puter.kv.get(shardKey(chatId, shard)) ?? [];
    return messages.sort((a, b) => a.number - b.number);
}

async function readAll (chatId) {
    const all = [];

    for await ( const page of puter.kv.list({ pattern: `chat:${ chatId }:shard:`, returnValues: true, stream: true }) ) {
        for ( const { value } of page.items ) {
            all.push(...value);
        }
    }
    return all.sort((a, b) => a.number - b.number);
}
← All recipes