From 5e62e143b66bfd5c17125b1dac5905d3e653cd6f Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 6 Nov 2026 22:22:35 +0100 Subject: [PATCH] feature: every write of an instance's variable files is kept as a revision with the schema version of their layout and the application that wrote them. --- ADR.md | 22 +++++++++ CHANGELOG.md | 17 +++++++ README.md | 42 ++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- server/index.ts | 14 +++++- server/sql/tfvars_store.sql | 60 ++++++++++++++++++++++- server/tfvarsStore.test.ts | 48 +++++++++++++++--- server/tfvarsStore.ts | 98 ++++++++++++++++++++++++++++++------- shared/index.ts | 2 +- shared/tfvars.ts | 14 ++++++ 11 files changed, 286 insertions(+), 37 deletions(-) diff --git a/ADR.md b/ADR.md index 93c0bd2..b374aea 100644 --- a/ADR.md +++ b/ADR.md @@ -135,3 +135,25 @@ exporting them too. Consequences: An application's shared API types import from `/shared` without the code of either side. A type added to `shared/` is exported from `/shared` as well as from the side that uses it. + +## ADR-7: Every write of an instance's variable files is a revision with its schema and producer + +Status: Accepted + +Context: A configurator changes the layout of the variable files it writes from one version to +the next, and Cloud Platform has to tell whether an instance's files are of the layout its +module's configurator writes before it runs Terraform with them. The files written earlier have +to stay available, to restore or compare them. + +Decision: `tf_variable_revisions` holds every write of an instance's files: all files after the +write, the schema version of their layout as the writer gives it, null when it does not know it, +the producer, the writing application with its version, and the time. `write` and `remove` take +the schema and the producer and record the revision in the transaction of the change, under the +lock of the files; a write of no file records none. The current files stay in `tf_variable_files`, +so reading them and their version is unchanged. Revisions are kept until removed by hand with +SQL. Files without revisions are recorded as revision 1 of an unknown producer and schema when the +store is first used. + +Consequences: The schema of an instance's files is the schema of its newest revision; files +imported without a schema, or written before revisions, have none until a configurator writes +them. Revisions grow with every write; the README describes the SQL that removes them. diff --git a/CHANGELOG.md b/CHANGELOG.md index e291ce5..59766bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.7.0 + +feature: every write of an instance's variable files is kept as a revision with the schema version +of their layout and the application that wrote them. + +- `tf_variable_revisions` keeps all files of an instance after each write, with `schema`, null when + not known, `producer` and `saved`; revisions are numbered from 1 for each instance and kept until + removed by hand. +- `write(version, contents, meta)` and `remove(version, name, meta)` take `meta`, `{ schema, + producer }`, and record the revision in the same transaction; a write of no file records none. +- `read()` answers the newest revision as `revision`; `revisions()` lists them, newest first, and + `revision(n)` answers one with its files. `TfvarsRevision` is exported from `/server` and + `/shared`. +- The files of an instance without revisions become its revision 1, of the producer `unknown` and + no schema. +- The README describes the SQL that purges some or all revisions of a module's instance. + ## 0.6.0-2 fix: release.mjs reads a summary wrapped over several lines. diff --git a/README.md b/README.md index e88044c..5176e34 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ which edit their variables. `createTfvarsStore(pool)` takes a `pg` pool and appl | `tf_modules` | `name`, `root`, the folder of the Terraform code, `description` and `configurator_url` | | `tf_instances` | `module`, `name` and `state_schema`, the schema of the instance's state in the pg backend, unique, and `created` | | `tf_variable_files` | `module`, `instance`, `name` (`*.tfvars.json`), `content` and `previous`, the object it replaced, as `jsonb`, and `updated` | +| `tf_variable_revisions` | `module`, `instance`, `revision`, numbered from 1 for each instance, `files`, all its files after a write as a `jsonb` object by name, `schema`, the schema version of their layout or null, `producer` and `saved` | Names of modules and instances are `TF_NAME_PATTERN`, of variable files `TFVARS_FILE_PATTERN`, of state schemas `STATE_SCHEMA_PATTERN`. @@ -187,11 +188,42 @@ of state schemas `STATE_SCHEMA_PATTERN`. - `modules.list()`, `modules.put(module)`, which creates or changes, and `modules.delete(name)`. - `instances.list(module)`, `instances.create(module, name, stateSchema)` and `instances.delete(module, name)`, which deletes its variable files too. -- `variables(module, instance)`: `read()` answers the files, their sizes and times and the - version, `versionOf` the files, the name and the SHA-256 of each in name order. `write(version, - contents)` writes files given by name as JSON text and `remove(version, name)` deletes one; both - lock the table, compare the version and change in one transaction, and answer `false`, changing - nothing, when the files changed since the version. +- `variables(module, instance)`: `read()` answers the files, their sizes and times, the version, + `versionOf` the files, the name and the SHA-256 of each in name order, and the newest revision. + `write(version, contents, meta)` writes files given by name as JSON text and `remove(version, + name, meta)` deletes one; both lock the table, compare the version, change and record all files + as the next revision with `meta`, `{ schema, producer }`, in one transaction, and answer + `false`, changing nothing, when the files changed since the version. A write of no file records + no revision. `revisions()` lists the revisions, newest first, without their files, and + `revision(n)` answers one with its files, or `null`. + +Every revision is kept. The files of an instance without any revision, as those written before +revisions existed, are recorded as its revision 1, of the producer `unknown` and no schema, when +the store is first used. + +### Purging revisions + +Revisions are removed by hand, with `psql` connected to the database of the store as the owner of +its tables, in the schema the applications use (`SET search_path TO ;` when it is not the +default). Each statement removes the revisions of one instance, here `router`/`platform`: + +```sql +-- All revisions but the newest. +DELETE FROM tf_variable_revisions +WHERE module = 'router' AND instance = 'platform' + AND revision < (SELECT max(revision) FROM tf_variable_revisions WHERE module = 'router' AND instance = 'platform'); + +-- The revisions before revision 10. +DELETE FROM tf_variable_revisions WHERE module = 'router' AND instance = 'platform' AND revision < 10; + +-- All revisions of the instance. +DELETE FROM tf_variable_revisions WHERE module = 'router' AND instance = 'platform'; +``` + +Leaving out `AND instance = ...` applies a statement to every instance of the module. The next +write numbers its revision after the newest one left. An instance whose revisions are all removed +gets its current files back as revision 1, of the producer `unknown` and no schema, when the store +is next first used, so its schema is unknown until the next write by its configurator. A change the tables refuse throws `HttpError`: 409 for a name or a state schema in use and for a module that has instances, 404 for a module or an instance that does not exist, 400 for a name or diff --git a/package-lock.json b/package-lock.json index cc45726..6cd2781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@koszewscy/sk-react-toolkit", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@koszewscy/sk-react-toolkit", - "version": "0.6.0", + "version": "0.7.0", "devDependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/package.json b/package.json index 907bfd2..23933db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@koszewscy/sk-react-toolkit", - "version": "0.6.0", + "version": "0.7.0", "description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.", "private": true, "type": "module", diff --git a/server/index.ts b/server/index.ts index c5f7f8c..93ec11b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,6 +4,16 @@ export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts' export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts' export { exitOnSignals, gitVersion, readJsonObject } from './process.ts' export { decrypt, encrypt, encryptionKey } from './secrets.ts' -export type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts' +export type { TfInstance, TfModule, TfvarsFileStatus, TfvarsRevision } from '../shared/tfvars.ts' export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from '../shared/tfvars.ts' -export { createTfvarsStore, tfvarsText, versionOf, type InstanceVariables, type TfvarsDocument, type TfvarsFile, type TfvarsStore } from './tfvarsStore.ts' +export { + createTfvarsStore, + tfvarsText, + versionOf, + type InstanceVariables, + type RevisionMeta, + type TfvarsDocument, + type TfvarsFile, + type TfvarsRevisionFiles, + type TfvarsStore, +} from './tfvarsStore.ts' diff --git a/server/sql/tfvars_store.sql b/server/sql/tfvars_store.sql index 71eddee..e9ac616 100644 --- a/server/sql/tfvars_store.sql +++ b/server/sql/tfvars_store.sql @@ -1,6 +1,7 @@ -- The store of Terraform modules, their instances and the instances' variable files: the tables tf_modules, --- tf_instances and tf_variable_files, and the functions and procedures the store calls. The store applies the file on --- its first use in the schema its connection's search path names; every statement can run again. +-- tf_instances, tf_variable_files and tf_variable_revisions, and the functions and procedures the store calls. The +-- store applies the file on its first use in the schema its connection's search path names; every statement can run +-- again. CREATE TABLE IF NOT EXISTS tf_modules ( name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9-]*$'), @@ -28,6 +29,28 @@ CREATE TABLE IF NOT EXISTS tf_variable_files ( FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE ); +-- Every write of an instance's variable files: all its files after the write, by name, the schema version of their +-- layout, null when not known, and the application that wrote them. +CREATE TABLE IF NOT EXISTS tf_variable_revisions ( + module text NOT NULL, + instance text NOT NULL, + revision integer NOT NULL CHECK (revision > 0), + files jsonb NOT NULL CHECK (jsonb_typeof(files) = 'object'), + schema integer CHECK (schema > 0), + producer text NOT NULL CHECK (producer <> ''), + saved timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (module, instance, revision), + FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE +); + +-- The files of an instance that has no revision are its revision 1, of an unknown schema and producer. +INSERT INTO tf_variable_revisions (module, instance, revision, files, schema, producer, saved) +SELECT f.module, f.instance, 1, jsonb_object_agg(f.name, f.content), NULL, 'unknown', max(f.updated) +FROM tf_variable_files f +WHERE NOT EXISTS (SELECT 1 FROM tf_variable_revisions r WHERE r.module = f.module AND r.instance = f.instance) +GROUP BY f.module, f.instance +ON CONFLICT DO NOTHING; + -- The modules, by name. CREATE OR REPLACE FUNCTION tf_modules_list() RETURNS TABLE (name text, root text, description text, configurator_url text) @@ -115,3 +138,36 @@ SET search_path FROM CURRENT AS $$ DELETE FROM tf_variable_files WHERE module = module_name AND instance = instance_name AND name = file_name $$; + +-- Records the variable files of an instance as its next revision, with the schema of their layout and their producer; +-- called with the lock of tf_variable_files_lock held. +CREATE OR REPLACE PROCEDURE tf_variable_revision_add(module_name text, instance_name text, revision_schema integer, revision_producer text) +LANGUAGE sql +SET search_path FROM CURRENT +AS $$ + INSERT INTO tf_variable_revisions (module, instance, revision, files, schema, producer) + SELECT module_name, instance_name, + (SELECT coalesce(max(r.revision), 0) + 1 FROM tf_variable_revisions r WHERE r.module = module_name AND r.instance = instance_name), + (SELECT coalesce(jsonb_object_agg(f.name, f.content), '{}'::jsonb) FROM tf_variable_files f WHERE f.module = module_name AND f.instance = instance_name), + revision_schema, revision_producer +$$; + +-- The revisions of an instance, newest first, with the names of their files. +CREATE OR REPLACE FUNCTION tf_variable_revisions_list(module_name text, instance_name text) +RETURNS TABLE (revision integer, schema integer, producer text, saved timestamptz, names text[]) +LANGUAGE sql STABLE +SET search_path FROM CURRENT +AS $$ + SELECT r.revision, r.schema, r.producer, r.saved, ARRAY(SELECT jsonb_object_keys(r.files) ORDER BY 1) + FROM tf_variable_revisions r WHERE r.module = module_name AND r.instance = instance_name ORDER BY r.revision DESC +$$; + +-- A revision of an instance with its files. +CREATE OR REPLACE FUNCTION tf_variable_revision_get(module_name text, instance_name text, revision_number integer) +RETURNS TABLE (revision integer, schema integer, producer text, saved timestamptz, files jsonb) +LANGUAGE sql STABLE +SET search_path FROM CURRENT +AS $$ + SELECT r.revision, r.schema, r.producer, r.saved, r.files + FROM tf_variable_revisions r WHERE r.module = module_name AND r.instance = instance_name AND r.revision = revision_number +$$; diff --git a/server/tfvarsStore.test.ts b/server/tfvarsStore.test.ts index 221321b..2b636c8 100644 --- a/server/tfvarsStore.test.ts +++ b/server/tfvarsStore.test.ts @@ -60,29 +60,62 @@ test('instances are created and refused for a missing module or a name in use', assert.equal(await status(store.modules.delete('router')), 409) }) +const META = { schema: 1, producer: 'Test Configurator 1.0.0' } + test('variable files are written with their version and refused against another version', async () => { const variables = store.variables('router', 'test') const empty = await variables.read() assert.deepEqual(empty.files, []) assert.equal(empty.version, '') - assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 1 }), 'secrets.tfvars.json': tfvarsText({ s: 'x' }) }), true) + assert.equal(empty.revision, null) + assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 1 }), 'secrets.tfvars.json': tfvarsText({ s: 'x' }) }, META), true) const written = await variables.read() assert.deepEqual(written.files.map((file) => [file.name, file.content.toString('utf8')]), [ ['platform.tfvars.json', tfvarsText({ a: 1 })], ['secrets.tfvars.json', tfvarsText({ s: 'x' })], ]) assert.deepEqual(written.status.map((file) => file.previous), [false, false]) - assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }), false) - assert.equal(await variables.write(written.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }), true) + assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }, META), false) + assert.equal(await variables.write(written.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }, META), true) const changed = await variables.read() assert.deepEqual(changed.status.map((file) => file.previous), [true, false]) assert.notEqual(changed.version, written.version) - assert.equal(await variables.remove(written.version, 'secrets.tfvars.json'), false) - assert.equal(await variables.remove(changed.version, 'secrets.tfvars.json'), true) + assert.equal(await variables.remove(written.version, 'secrets.tfvars.json', META), false) + assert.equal(await variables.remove(changed.version, 'secrets.tfvars.json', META), true) assert.deepEqual((await variables.read()).files.map((file) => file.name), ['platform.tfvars.json']) assert.deepEqual((await store.variables('router', 'platform').read()).files, []) - assert.equal(await status(variables.write((await variables.read()).version, { 'notes.json': tfvarsText({}) })), 400) - assert.equal(await status(store.variables('router', 'missing').write('', { 'a.tfvars.json': tfvarsText({}) })), 404) + assert.equal(await status(variables.write((await variables.read()).version, { 'notes.json': tfvarsText({}) }, META)), 400) + assert.equal(await status(store.variables('router', 'missing').write('', { 'a.tfvars.json': tfvarsText({}) }, META)), 404) +}) + +test('every write is a revision with its files, schema and producer', async () => { + const variables = store.variables('router', 'test') + assert.deepEqual((await variables.revisions()).map((revision) => [revision.revision, revision.schema, revision.producer, revision.names]), [ + [3, 1, META.producer, ['platform.tfvars.json']], + [2, 1, META.producer, ['platform.tfvars.json', 'secrets.tfvars.json']], + [1, 1, META.producer, ['platform.tfvars.json', 'secrets.tfvars.json']], + ]) + const current = await variables.read() + assert.equal(await variables.write(current.version, {}, META), true) + assert.equal(await variables.write(current.version, { 'platform.tfvars.json': tfvarsText({ a: 3 }) }, { schema: null, producer: 'manage.mjs import' }), true) + const newest = (await variables.read()).revision + assert.deepEqual(newest && [newest.revision, newest.schema, newest.producer], [4, null, 'manage.mjs import']) + const first = await variables.revision(1) + assert.deepEqual(first?.files.map((file) => [file.name, file.content.toString('utf8')]), [ + ['platform.tfvars.json', tfvarsText({ a: 1 })], + ['secrets.tfvars.json', tfvarsText({ s: 'x' })], + ]) + assert.equal(await variables.revision(9), null) + assert.deepEqual(await store.variables('router', 'platform').revisions(), []) +}) + +test('the files of an instance without revisions become its revision 1 of an unknown producer', async () => { + await pool.query(`INSERT INTO tf_variable_files (module, instance, name, content) VALUES ('router', 'platform', 'a.tfvars.json', '{"b": 1}')`) + await pool.query('DELETE FROM tf_variable_revisions') + const again = createTfvarsStore(pool) + const revisions = await again.variables('router', 'platform').revisions() + assert.deepEqual(revisions.map((revision) => [revision.revision, revision.schema, revision.producer, revision.names]), [[1, null, 'unknown', ['a.tfvars.json']]]) + assert.deepEqual((await again.variables('router', 'test').revisions()).map((revision) => revision.revision), [1]) }) test('an instance is deleted with its variable files, then its module', async () => { @@ -90,6 +123,7 @@ test('an instance is deleted with its variable files, then its module', async () await store.instances.delete('router', 'platform') assert.deepEqual(await store.instances.list('router'), []) assert.equal((await pool.query('SELECT count(*)::int AS n FROM tf_variable_files')).rows[0].n, 0) + assert.equal((await pool.query('SELECT count(*)::int AS n FROM tf_variable_revisions')).rows[0].n, 0) await store.modules.delete('router') assert.deepEqual(await store.modules.list(), []) }) diff --git a/server/tfvarsStore.ts b/server/tfvarsStore.ts index 7733430..35ced86 100644 --- a/server/tfvarsStore.ts +++ b/server/tfvarsStore.ts @@ -1,25 +1,42 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import type { Pool, PoolClient } from 'pg' -import type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts' +import type { TfInstance, TfModule, TfvarsFileStatus, TfvarsRevision } from '../shared/tfvars.ts' import { HttpError } from './http.ts' /** A variable file of an instance: its name and its content, the object jsonb keeps written as JSON text. */ export type TfvarsFile = { name: string; content: Buffer } -/** The variable files of an instance, by name, with their version, versionOf the files. */ -export type TfvarsDocument = { files: TfvarsFile[]; status: TfvarsFileStatus[]; version: string } +/** + * The variable files of an instance, by name, with their version, versionOf the files, and their newest revision, null + * for an instance never written. + */ +export type TfvarsDocument = { files: TfvarsFile[]; status: TfvarsFileStatus[]; version: string; revision: TfvarsRevision | null } + +/** A revision with its files, by name. */ +export type TfvarsRevisionFiles = TfvarsRevision & { files: TfvarsFile[] } + +/** What a write records with the files: the schema version of their layout, null when not known, and the producer. */ +export type RevisionMeta = { schema: number | null; producer: string } /** The variable files of one instance. */ export type InstanceVariables = { read(): Promise /** * Writes the given files, name and JSON text of an object, when the files are still those of version, keeping each - * replaced content as its previous one; answers false, writing nothing, when they changed since. + * replaced content as its previous one, and records all files as the next revision with meta; answers false, writing + * nothing, when they changed since. A write of no file records no revision. */ - write(version: string, contents: Record): Promise - /** Deletes the file name when the files are still those of version; answers false, deleting nothing, otherwise. */ - remove(version: string, name: string): Promise + write(version: string, contents: Record, meta: RevisionMeta): Promise + /** + * Deletes the file name when the files are still those of version, and records the remaining files as the next + * revision with meta; answers false, deleting nothing, otherwise. + */ + remove(version: string, name: string, meta: RevisionMeta): Promise + /** The revisions, newest first. */ + revisions(): Promise + /** The revision of the number with its files; null when there is none. */ + revision(revision: number): Promise } /** The modules, the instances and their variable files in PostgreSQL. */ @@ -56,6 +73,15 @@ const SQL = new URL('./sql/tfvars_store.sql', import.meta.url) type FileRow = { name: string; content: object; previous: boolean; updated: Date } +type RevisionRow = { revision: number; schema: number | null; producer: string; saved: Date; names: string[] } + +const revisionOf = (row: Omit, names: string[]): TfvarsRevision => + ({ revision: row.revision, schema: row.schema, producer: row.producer, saved: row.saved.toISOString(), names }) + +/** The files of an object of revision files, by name, written as JSON text. */ +const filesOf = (files: Record): TfvarsFile[] => + Object.keys(files).sort().map((name) => ({ name, content: Buffer.from(tfvarsText(files[name]), 'utf8') })) + /** * The store of sql/tfvars_store.sql in the database of pool, applied at the first use in the schema of the * connection's search path. A refused change is an HttpError: 409 for a name in use or a module that has instances, @@ -72,27 +98,49 @@ export function createTfvarsStore(pool: Pool): TfvarsStore { throw refused(error) } } - const document = (rows: FileRow[]): TfvarsDocument => { + const document = (rows: FileRow[], newest: RevisionRow | undefined): TfvarsDocument => { const files = rows.map((row) => ({ name: row.name, content: Buffer.from(tfvarsText(row.content), 'utf8') })) return { files, status: rows.map((row, index) => ({ name: row.name, bytes: files[index].content.length, modified: row.updated.toISOString(), previous: row.previous })), version: versionOf(files), + revision: newest === undefined ? null : revisionOf(newest, newest.names), } } - /** Runs change in a transaction that holds the lock of the variable files, when they are still those of version. */ - const locked = async (module: string, instance: string, version: string, change: (client: PoolClient) => Promise) => { + /** The files and the newest revision of an instance, read in one snapshot. */ + const read = async (module: string, instance: string): Promise => { + await ready() + const client = await pool.connect() + try { + await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY') + const files = await client.query('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance]) + const revisions = await client.query('SELECT * FROM tf_variable_revisions_list($1, $2) LIMIT 1', [module, instance]) + await client.query('COMMIT') + return document(files.rows, revisions.rows[0]) + } catch (error) { + await client.query('ROLLBACK') + throw refused(error) + } finally { + client.release() + } + } + /** + * Runs change in a transaction that holds the lock of the variable files, when they are still those of version, and + * records the files after it as the next revision with meta. + */ + const locked = async (module: string, instance: string, version: string, meta: RevisionMeta, change: (client: PoolClient) => Promise) => { await ready() const client = await pool.connect() try { await client.query('BEGIN') await client.query('CALL tf_variable_files_lock()') const { rows } = await client.query('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance]) - if (document(rows).version !== version) { + if (document(rows, undefined).version !== version) { await client.query('ROLLBACK') return false } await change(client) + await client.query('CALL tf_variable_revision_add($1, $2, $3, $4)', [module, instance, meta.schema, meta.producer]) await client.query('COMMIT') return true } catch (error) { @@ -124,15 +172,31 @@ export function createTfvarsStore(pool: Pool): TfvarsStore { }, }, variables: (module, instance) => ({ - read: async () => document(await query('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance])), - write: (version, contents) => locked(module, instance, version, async (client) => { - for (const [name, text] of Object.entries(contents)) { - await client.query('CALL tf_variable_file_put($1, $2, $3, $4::jsonb)', [module, instance, name, text]) + read: () => read(module, instance), + write: async (version, contents, meta) => { + if (Object.keys(contents).length === 0) { + return (await read(module, instance)).version === version } - }), - remove: (version, name) => locked(module, instance, version, async (client) => { + return locked(module, instance, version, meta, async (client) => { + for (const [name, text] of Object.entries(contents)) { + await client.query('CALL tf_variable_file_put($1, $2, $3, $4::jsonb)', [module, instance, name, text]) + } + }) + }, + remove: (version, name, meta) => locked(module, instance, version, meta, async (client) => { await client.query('CALL tf_variable_file_delete($1, $2, $3)', [module, instance, name]) }), + revisions: async () => (await query('SELECT * FROM tf_variable_revisions_list($1, $2)', [module, instance])) + .map((row) => revisionOf(row, row.names)), + revision: async (revision) => { + const [row] = await query & { files: Record }>( + 'SELECT * FROM tf_variable_revision_get($1, $2, $3)', [module, instance, revision]) + if (row === undefined) { + return null + } + const files = filesOf(row.files) + return { ...revisionOf(row, files.map((file) => file.name)), files } + }, }), } } diff --git a/shared/index.ts b/shared/index.ts index 3db9876..a366d0c 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -1,3 +1,3 @@ export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from './credentials.ts' -export type { TfInstance, TfModule, TfvarsFileStatus } from './tfvars.ts' +export type { TfInstance, TfModule, TfvarsFileStatus, TfvarsRevision } from './tfvars.ts' export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from './tfvars.ts' diff --git a/shared/tfvars.ts b/shared/tfvars.ts index 1841e0a..9f13da8 100644 --- a/shared/tfvars.ts +++ b/shared/tfvars.ts @@ -26,6 +26,20 @@ export type TfvarsFileStatus = { previous: boolean } +/** A write of an instance's variable files, without their content. */ +export type TfvarsRevision = { + /** The number of the write, from 1 for each instance. */ + revision: number + /** The schema version of the files' layout, as their producer gave it; null when not known. */ + schema: number | null + /** The application that wrote the files, with its version. */ + producer: string + /** ISO 8601. */ + saved: string + /** The names of the files of the revision. */ + names: string[] +} + /** The names of modules and instances: a lower-case letter, then lower-case letters, digits and hyphens. */ export const TF_NAME_PATTERN = /^[a-z][a-z0-9-]*$/