Strata · guide

Upgrade a table of stored documents

Reading upgrades one document. This rewrites every row of a table that is behind, in one transaction.

Install it
pnpm add @niscorp/strata @niscorp/prism zod @electric-sql/pglite
Step 1 of 5

A 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.

A Postgres, and a pool
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) })),
};
Step 2 of 5

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.

A table with a column for the stamp
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}');
`);
Step 3 of 5

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.

The format, and its two changes
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 });
Step 4 of 5

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.

Rewrite what is behind
const store: DocumentStore = {
  table: 'tickets',
  column: 'ticket',
  stamp: 'grammar',
  kind: 'theatre.tickets/ticket',
  key: ['id'],
};
console.log(await upgradeStore(pool, store, upgrader));
Step 5 of 5

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.

What the table holds now
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);
Then

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.

Run it
npx tsx store.mts
It prints
{
  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