Upgrade a table of stored documents
Reading upgrades one document. This rewrites every row of a table that is behind, in one transaction.
pnpm add @niscorp/strata @niscorp/prism zod @electric-sql/pgliteA Postgres, and a pool
PGlite is Postgres compiled to run wherever Node runs, so this file needs no server.
Strata does not ask for a driver. It asks for two functions: query, and transaction, which it needs because a run lands whole or not at all. A pg pool is wrapped the same way.
import { PGlite } from '@electric-sql/pglite';
import { evaluate } from '@niscorp/prism';
import { createUpgrader, defineSequence } from '@niscorp/strata';
import { upgradeStore, type DocumentStore, type StrataPool } from '@niscorp/strata/postgres';
const db = new PGlite();
const pool: StrataPool = {
query: (text, values) => db.query<Record<string, unknown>>(text, values),
transaction: (run) => db.transaction((tx) => run({ query: (text, values) => tx.query(text, values) })),
};A table with a column for the stamp
A table that holds documents has two jsonb columns: the document, and the stamp it was written at.
Three tickets, stored by three releases. The stamps say which.
await db.exec(`
CREATE TABLE tickets (id text PRIMARY KEY, ticket jsonb NOT NULL, grammar jsonb NOT NULL);
INSERT INTO tickets VALUES
('T-1', '{"holder":"Grace","seat":"C4"}', '{"theatre.tickets":0}'),
('T-2', '{"holder":"Ada","seats":["A1"]}', '{"theatre.tickets":1}'),
('T-3', '{"member":"Mia","seats":["B7","B8"]}', '{"theatre.tickets":2}');
`);The format, and its two changes
A sequence that owns a format of document is called a grammar. It declares documents, the kinds it owns, and its migrations are document steps: at names the kind, and transform is a Prism config.
The config is handed one document as $.document and answers with the document to put in its place. The first moves one seat into a list. The second renames a key.
const seatsFromSeat = {
$merge: [
{ $omit: { from: { $ref: '$.document' }, keys: ['seat'] } },
{ seats: [{ $ref: '$.document.seat' }] },
],
};
const memberFromHolder = { $renameKeys: { from: { $ref: '$.document' }, map: { holder: 'member' } } };
const tickets = defineSequence({
id: 'theatre.tickets',
documents: { ticket: {} },
migrations: [
{
description: 'seat is now seats, a list',
steps: [{ kind: 'document', at: 'theatre.tickets/ticket', transform: seatsFromSeat }],
},
{
description: 'holder is now member',
steps: [{ kind: 'document', at: 'theatre.tickets/ticket', transform: memberFromHolder }],
},
],
});
const upgrader = await createUpgrader([tickets], { transform: evaluate });Rewrite what is behind
upgradeStore is told where the documents are: the table, the two columns, the kind, and the columns that name a row.
It reads every row, upgrades the ones that are behind, and writes the document and its new stamp back. All of it is one transaction. A row written by newer code stops the whole pass, and nothing is changed.
const store: DocumentStore = {
table: 'tickets',
column: 'ticket',
stamp: 'grammar',
kind: 'theatre.tickets/ticket',
key: ['id'],
};
console.log(await upgradeStore(pool, store, upgrader));What the table holds now
Every ticket is in today’s format and carries today’s stamp, so a second pass finds nothing to do.
const held = await pool.query('SELECT * FROM tickets ORDER BY id');
for (const row of held.rows) {
console.log(row.id, JSON.stringify(row.ticket), JSON.stringify(row.grammar));
}
const again = await upgradeStore(pool, store, upgrader);
console.log('second pass:', again.upgraded);Run it.
The report: three rows, two of them rewritten, and how often each migration ran. Then the three tickets as the table now holds them, and a second pass that rewrites none.
npx tsx store.mts{
total: 3,
upgraded: 2,
applied: { 'theatre.tickets/1': 1, 'theatre.tickets/2': 2 }
}
T-1 {"seats":["C4"],"member":"Grace"} {"theatre.tickets":2}
T-2 {"seats":["A1"],"member":"Ada"} {"theatre.tickets":2}
T-3 {"seats":["B7","B8"],"member":"Mia"} {"theatre.tickets":2}
second pass: 0