diff --git a/CHANGELOG.md b/CHANGELOG.md index 23998d1..0aa2969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to git-cms are documented in this file. +## [1.1.3] — 2026-02-14 + +### Fixed + +- `migrate()` JSDoc now documents TOCTOU / concurrency limitation + +## [1.1.2] — 2026-02-14 + +### Fixed + +- `migrate()` computes `to` from applied migrations instead of redundant `readLayoutVersion` re-read + +## [1.1.1] — 2026-02-14 + +### Fixed + +- `readLayoutVersion` now rejects empty/whitespace-only config values (`Number('')` was silently treated as version 0) +- `writeLayoutVersion` validates input (rejects NaN, Infinity, floats, negatives) to prevent storing invalid versions +- `MIGRATIONS` array and entries frozen with `Object.freeze` for immutability consistency + +## [1.1.0] — 2026-02-14 + +### Added + +- **Layout Specification v1** (`docs/LAYOUT_SPEC.md`): Formalizes ref namespace, state derivation rules, commit format, config keys, and migration policy (M1.3) +- **Migration framework** (`src/lib/LayoutMigration.js`): `readLayoutVersion`, `writeLayoutVersion`, `pendingMigrations`, `migrate` — forward-only, idempotent layout migrations stored in `cms.layout.version` git config +- **CLI commands:** `git-cms migrate` (run pending migrations) and `git-cms layout-version` (print repo + codebase versions) + ## [Unreleased] — git-stunts branch ### Added @@ -74,3 +102,7 @@ All notable changes to git-cms are documented in this file. - **(P2) walkLimit divergence:** Extracted `HISTORY_WALK_LIMIT` as a shared exported constant used by both `_validateAncestry` and the server's history limit clamp [Unreleased]: https://github.com/flyingrobots/git-cms/compare/main...git-stunts +[1.1.3]: https://github.com/flyingrobots/git-cms/compare/v1.1.2...v1.1.3 +[1.1.2]: https://github.com/flyingrobots/git-cms/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/flyingrobots/git-cms/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/flyingrobots/git-cms/compare/v1.0.2...v1.1.0 diff --git a/bin/git-cms.js b/bin/git-cms.js index 15de585..c74b8e8 100755 --- a/bin/git-cms.js +++ b/bin/git-cms.js @@ -2,6 +2,7 @@ import CmsService from '../src/lib/CmsService.js'; import { canonicalizeSlug } from '../src/lib/ContentIdentityPolicy.js'; +import { migrate, readLayoutVersion, CURRENT_LAYOUT_VERSION } from '../src/lib/LayoutMigration.js'; const DEFAULT_REF_PREFIX = 'refs/_blog/dev'; @@ -82,8 +83,23 @@ async function main() { }); break; } + case 'migrate': { + const result = await migrate({ graph: cms.graph, refPrefix }); + if (result.applied.length === 0) { + console.log(`Already at layout version ${result.to}. Nothing to do.`); + } else { + console.log(`Migrated layout: v${result.from} → v${result.to} (applied: ${result.applied.join(', ')})`); + } + break; + } + case 'layout-version': { + const repoVersion = await readLayoutVersion(cms.graph); + console.log(`Repo: v${repoVersion}`); + console.log(`Codebase: v${CURRENT_LAYOUT_VERSION}`); + break; + } default: - console.log('Usage: git cms '); + console.log('Usage: git cms '); process.exit(1); } } catch (err) { diff --git a/docs/LAYOUT_SPEC.md b/docs/LAYOUT_SPEC.md new file mode 100644 index 0000000..85129bb --- /dev/null +++ b/docs/LAYOUT_SPEC.md @@ -0,0 +1,108 @@ +# Layout Specification — v1 + +> Canonical reference for the git-cms repository layout. +> Version changes are forward-only; see **Migration Policy** below. + +## Layout Version + +Stored in git config under the key `cms.layout.version`. + +| Key | Type | Default (if absent) | +|------------------------|--------|---------------------| +| `cms.layout.version` | string | `0` (pre-versioned) | + +Current codebase version: **1**. + +## Ref Namespace + +All CMS refs live under a configurable prefix (default `refs/_blog/dev`). + +``` +{refPrefix}/{kind}/{slug} +``` + +| Kind | Pattern | Purpose | +|--------------|------------------------------------|----------------------------| +| `articles` | `{refPrefix}/articles/{slug}` | Draft / working ref | +| `published` | `{refPrefix}/published/{slug}` | Published snapshot ref | +| `comments` | `{refPrefix}/comments/{slug}` | (Reserved for future use) | + +Slugs are canonicalized per `ContentIdentityPolicy`: lowercase, `[a-z0-9]+(-[a-z0-9]+)*`, 1–64 chars, NFKC-normalized, no reserved words. + +## State Derivation + +Effective state is derived from **two inputs**: + +1. The `status` trailer on the tip commit of `articles/{slug}` +2. The presence or absence of the `published/{slug}` ref + +| Effective State | `status` trailer | `published/{slug}` ref | +|-----------------|------------------|------------------------| +| draft | `draft` | absent | +| published | `draft` | present | +| unpublished | `unpublished` | absent | +| reverted | `reverted` | absent | + +### Allowed Transitions + +``` +draft → draft, published, reverted +published → unpublished, published +unpublished → draft, published +reverted → draft +``` + +## Commit Message Format + +Commit messages are encoded/decoded via `@git-stunts/trailer-codec`. + +``` + + +<body> + +<key>: <value> +<key>: <value> +... +``` + +### Required Trailers + +| Trailer | Description | +|--------------|------------------------------------------| +| `contentid` | Canonical slug (matches slug in v1) | +| `status` | One of: `draft`, `unpublished`, `reverted` | +| `updatedAt` | ISO 8601 timestamp of last mutation | + +### Optional Trailers + +| Trailer | Description | +|-------------------|--------------------------------------| +| `restoredFromSha` | SHA of the version that was restored | +| `restoredAt` | ISO 8601 timestamp of the restore | + +> **Note:** `trailer-codec` normalizes keys to lowercase during decode. +> Use camelCase when encoding; read lowercase when consuming decoded output. + +## Config Keys + +| Key | Description | +|------------------------|------------------------------------| +| `cms.layout.version` | Repo layout version (integer as string) | + +## Invariants + +The following invariants hold for a well-formed v1 repo. (Enforcement deferred to M1.4 `verify` command.) + +1. Every `articles/{slug}` tip commit has a valid `status` trailer. +2. Every `published/{slug}` ref points to a commit reachable from `articles/{slug}`. +3. No orphan `published/{slug}` ref exists without a corresponding `articles/{slug}`. +4. All slugs satisfy `ContentIdentityPolicy` canonicalization rules. +5. `cms.layout.version` equals `1` after migration. + +## Migration Policy + +- **Forward-only:** migrations never downgrade the layout version. +- **Idempotent:** running `migrate` on an already-current repo is a no-op. +- **Guard:** if the repo version exceeds the codebase version, migration refuses to run (`layout_version_too_new`). +- **v0 → v1:** stamps `cms.layout.version = 1` (no structural changes — formalizes existing layout). diff --git a/package.json b/package.json index c65371d..b774410 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "git-cms", - "version": "1.0.2", + "version": "1.1.3", "description": "A serverless, database-free CMS built on Git plumbing.", "type": "module", "bin": { diff --git a/src/lib/LayoutMigration.js b/src/lib/LayoutMigration.js new file mode 100644 index 0000000..f0ab893 --- /dev/null +++ b/src/lib/LayoutMigration.js @@ -0,0 +1,107 @@ +/** + * Layout migration framework for git-cms. + * + * Pure-function module — takes a `graph` adapter (GraphPersistencePort) as + * dependency. No CmsService import. + * + * Layout version is stored in git config under `cms.layout.version`. + * A missing key is treated as version 0 (pre-versioned repo). + */ + +import { CmsValidationError } from './ContentIdentityPolicy.js'; + +export const CURRENT_LAYOUT_VERSION = 1; +export const LAYOUT_VERSION_KEY = 'cms.layout.version'; + +/** + * Reads the current layout version from the graph's config. + * Returns 0 if the key is unset (pre-versioned repo). + * + * @param {import('@git-stunts/git-warp').GraphPersistencePort} graph + * @returns {Promise<number>} + */ +export async function readLayoutVersion(graph) { + const raw = await graph.configGet(LAYOUT_VERSION_KEY); + if (raw === null) return 0; + if (raw.trim() === '') { + throw new CmsValidationError( + `Invalid layout version in config: "${raw}"`, + { code: 'layout_version_invalid', field: LAYOUT_VERSION_KEY } + ); + } + const version = Number(raw); + if (!Number.isInteger(version) || version < 0) { + throw new CmsValidationError( + `Invalid layout version in config: "${raw}"`, + { code: 'layout_version_invalid', field: LAYOUT_VERSION_KEY } + ); + } + return version; +} + +/** + * Writes a layout version to the graph's config. + * + * @param {import('@git-stunts/git-warp').GraphPersistencePort} graph + * @param {number} version + * @returns {Promise<void>} + */ +export async function writeLayoutVersion(graph, version) { + if (!Number.isInteger(version) || version < 0) { + throw new CmsValidationError( + `Cannot write invalid layout version: ${version}`, + { code: 'layout_version_invalid', field: LAYOUT_VERSION_KEY } + ); + } + await graph.configSet(LAYOUT_VERSION_KEY, String(version)); +} + +/** + * Ordered list of [targetVersion, migrateFn] pairs. + * Each migrateFn receives { graph, refPrefix } and performs the migration. + * + * @type {Array<[number, (ctx: {graph: any, refPrefix: string}) => Promise<void>]>} + */ +const MIGRATIONS = Object.freeze([ + Object.freeze([1, async (_ctx) => { /* v0→v1: no-op — stamps the version */ }]), +]); + +/** + * Returns the subset of migrations that need to run given the current version. + * + * @param {number} currentVersion + * @returns {Array<[number, Function]>} + */ +export function pendingMigrations(currentVersion) { + return MIGRATIONS.filter(([target]) => target > currentVersion); +} + +/** + * Runs all pending migrations in order. + * Not concurrency-safe — caller must ensure exclusive access. + * + * @param {{ graph: any, refPrefix: string }} ctx + * @returns {Promise<{ from: number, to: number, applied: number[] }>} + */ +export async function migrate({ graph, refPrefix }) { + const from = await readLayoutVersion(graph); + + if (from > CURRENT_LAYOUT_VERSION) { + throw new CmsValidationError( + `Repo layout version (${from}) is newer than codebase version (${CURRENT_LAYOUT_VERSION}). Upgrade git-cms first.`, + { code: 'layout_version_too_new', field: LAYOUT_VERSION_KEY } + ); + } + + const pending = pendingMigrations(from); + const applied = []; + + for (const [target, migrateFn] of pending) { + await migrateFn({ graph, refPrefix }); + await writeLayoutVersion(graph, target); + applied.push(target); + } + + const to = applied.length > 0 ? applied[applied.length - 1] : from; + return { from, to, applied }; +} diff --git a/test/git-e2e-layout-migration.test.js b/test/git-e2e-layout-migration.test.js new file mode 100644 index 0000000..dbed2e3 --- /dev/null +++ b/test/git-e2e-layout-migration.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import CmsService from '../src/lib/CmsService.js'; +import { migrate, readLayoutVersion, LAYOUT_VERSION_KEY } from '../src/lib/LayoutMigration.js'; + +describe('Layout Migration (E2E — real git)', () => { + let cwd; + const refPrefix = 'refs/cms'; + + beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'git-cms-layout-e2e-')); + execFileSync('git', ['init'], { cwd }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('stamps version readable via raw git config', async () => { + const cms = new CmsService({ cwd, refPrefix }); + const result = await migrate({ graph: cms.graph, refPrefix }); + + expect(result.from).toBe(0); + expect(result.to).toBe(1); + + // Verify via raw git config (not through adapter) + const raw = execFileSync('git', ['config', '--get', LAYOUT_VERSION_KEY], { cwd }).toString().trim(); + expect(raw).toBe('1'); + }); + + it('existing articles survive migration', async () => { + const cms = new CmsService({ cwd, refPrefix }); + await cms.saveSnapshot({ slug: 'survive-test', title: 'Before', body: 'Content' }); + + await migrate({ graph: cms.graph, refPrefix }); + + const article = await cms.readArticle({ slug: 'survive-test' }); + expect(article.title).toBe('Before'); + expect(article.body).toContain('Content'); + expect(await readLayoutVersion(cms.graph)).toBe(1); + }); +}); diff --git a/test/layout-migration.test.js b/test/layout-migration.test.js new file mode 100644 index 0000000..29bc4cf --- /dev/null +++ b/test/layout-migration.test.js @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import InMemoryGraphAdapter from '#test/InMemoryGraphAdapter'; +import { + CURRENT_LAYOUT_VERSION, + LAYOUT_VERSION_KEY, + readLayoutVersion, + writeLayoutVersion, + pendingMigrations, + migrate, +} from '../src/lib/LayoutMigration.js'; +import CmsService from '../src/lib/CmsService.js'; + +describe('readLayoutVersion', () => { + let graph; + + beforeEach(() => { + graph = new InMemoryGraphAdapter(); + }); + + it('returns 0 when config key is unset', async () => { + expect(await readLayoutVersion(graph)).toBe(0); + }); + + it('returns stored integer value', async () => { + await graph.configSet(LAYOUT_VERSION_KEY, '1'); + expect(await readLayoutVersion(graph)).toBe(1); + }); + + it('throws on non-integer value', async () => { + await graph.configSet(LAYOUT_VERSION_KEY, 'abc'); + await expect(readLayoutVersion(graph)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('throws on negative value', async () => { + await graph.configSet(LAYOUT_VERSION_KEY, '-1'); + await expect(readLayoutVersion(graph)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('throws on fractional value', async () => { + await graph.configSet(LAYOUT_VERSION_KEY, '1.5'); + await expect(readLayoutVersion(graph)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('throws on empty string value', async () => { + await graph.configSet(LAYOUT_VERSION_KEY, ''); + await expect(readLayoutVersion(graph)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('throws on whitespace-only value', async () => { + await graph.configSet(LAYOUT_VERSION_KEY, ' '); + await expect(readLayoutVersion(graph)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); +}); + +describe('writeLayoutVersion', () => { + it('writes version to config', async () => { + const graph = new InMemoryGraphAdapter(); + await writeLayoutVersion(graph, 1); + expect(await graph.configGet(LAYOUT_VERSION_KEY)).toBe('1'); + }); + + it('rejects negative version', async () => { + const graph = new InMemoryGraphAdapter(); + await expect(writeLayoutVersion(graph, -1)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('rejects fractional version', async () => { + const graph = new InMemoryGraphAdapter(); + await expect(writeLayoutVersion(graph, 1.5)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('rejects NaN', async () => { + const graph = new InMemoryGraphAdapter(); + await expect(writeLayoutVersion(graph, NaN)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); + + it('rejects Infinity', async () => { + const graph = new InMemoryGraphAdapter(); + await expect(writeLayoutVersion(graph, Infinity)).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_invalid', + }); + }); +}); + +describe('pendingMigrations', () => { + it('returns all migrations for version 0', () => { + const pending = pendingMigrations(0); + expect(pending).toHaveLength(1); + expect(pending[0][0]).toBe(1); + }); + + it('returns none at current version', () => { + const pending = pendingMigrations(CURRENT_LAYOUT_VERSION); + expect(pending).toHaveLength(0); + }); +}); + +describe('migrate', () => { + let graph; + const refPrefix = 'refs/cms'; + + beforeEach(() => { + graph = new InMemoryGraphAdapter(); + }); + + it('v0 → v1 stamps version', async () => { + const result = await migrate({ graph, refPrefix }); + expect(result.from).toBe(0); + expect(result.to).toBe(1); + expect(result.applied).toEqual([1]); + expect(await readLayoutVersion(graph)).toBe(1); + }); + + it('is idempotent — second run is a no-op', async () => { + await migrate({ graph, refPrefix }); + const result = await migrate({ graph, refPrefix }); + expect(result.from).toBe(1); + expect(result.to).toBe(1); + expect(result.applied).toEqual([]); + }); + + it('throws layout_version_too_new when repo version exceeds codebase', async () => { + await writeLayoutVersion(graph, CURRENT_LAYOUT_VERSION + 1); + await expect(migrate({ graph, refPrefix })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'layout_version_too_new', + }); + }); + + it('preserves existing content during migration', async () => { + const cms = new CmsService({ refPrefix, graph }); + await cms.saveSnapshot({ slug: 'hello', title: 'Hello', body: 'World' }); + + const result = await migrate({ graph, refPrefix }); + expect(result.to).toBe(1); + + const article = await cms.readArticle({ slug: 'hello' }); + expect(article.title).toBe('Hello'); + expect(article.body).toContain('World'); + }); +});