Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ jobs:
- run: npm run typecheck
- run: npm test
- run: npm run build
- name: Verify reviewed seeds did not lose rows
if: github.event_name == 'pull_request'
shell: bash
run: |
git fetch --no-tags --depth=1 origin "$BASE_SHA"
mkdir -p "$RUNNER_TEMP/base-seeds"
for seed in smite2-gods smite2-items; do
git show "$BASE_SHA:supabase/seeds/$seed.sql" \
> "$RUNNER_TEMP/base-seeds/$seed.sql" 2>/dev/null || true
node scripts/verify-seed-growth.mjs \
"$RUNNER_TEMP/base-seeds/$seed.sql" \
"supabase/seeds/$seed.sql" \
"$seed"
done
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
- run: npm audit --audit-level=high

secret-scan:
Expand Down
5 changes: 3 additions & 2 deletions contract.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"version": "db-v1.21.0",
"version": "db-v1.21.1",
"migrationHead": "20260901120000",
"supabaseCliVersion": "2.109.1",
"typesSha256": "sha256:4f4564132dcca1d763ae72e08319bfe90b675cbdb940dfdcc730329b2fa6543d"
"typesSha256": "sha256:4f4564132dcca1d763ae72e08319bfe90b675cbdb940dfdcc730329b2fa6543d",
"smiteGodSeedMinimumRows": 88
}
20 changes: 19 additions & 1 deletion docs/runbooks/consumer-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,28 @@ The database release stores this repository-owned `contract.json`:
"version": "db-v1.0.0",
"migrationHead": "<14-digit migration version>",
"supabaseCliVersion": "2.109.1",
"typesSha256": "sha256:<generated-types hash>"
"typesSha256": "sha256:<generated-types hash>",
"smiteGodSeedMinimumRows": 88
}
```

`smiteGodSeedMinimumRows` is the floor the reviewed SMITE god seed must meet.
The catalog grows as SMITE ships gods, and `diese-tech/smite-content-sync`
proposes those additions as reviewed pull requests, so the contract asserts a
floor rather than pinning an exact count that every real addition would break.
Lowering the floor is a deliberate edit here, in the manifest where the release
is declared.

The floor alone is a backstop, not a ratchet: it does not rise when growth is
accepted, so on its own it would let a later change drop back to it and quietly
lose a god that had already been reviewed. Two further guards close that:

- CI compares each seed against the same file on the pull request's base
branch and fails if it lost rows (`scripts/verify-seed-growth.mjs`), so
"never shrinks" holds continuously without a manual bump on every addition.
- The god seed may not repeat a name under a second id, which the old exact-row
assertion only ever caught through the total.

Each consumer commits a separate `db-contract.lock.json` with this shape:

```json
Expand Down
24 changes: 24 additions & 0 deletions scripts/seed-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,27 @@ export const countSqlSeedRows = (source) =>
export const usesIdentityPreservingNameUpsert = (source) =>
/on\s+conflict\s*\(\s*name\s*\)\s+do\s+update\s+set/i.test(source) &&
!/\bid\s*=\s*excluded\.id\b/i.test(source);

// A growing seed must still name each entity once. The old exact-row assertion
// caught a duplicate only by accident, through the count; a floor would not.
export const findDuplicateSeedNames = (source) => {
const names = [...source.matchAll(/^\s*\('[^']*',\s*'((?:[^']|'')*)'/gm)]
.map((match) => match[1].replaceAll("''", "'").trim().toLowerCase());
const seen = new Set();
const duplicates = new Set();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
return [...duplicates].sort();
};

// The contract.json floor is an absolute backstop, but it does not move on its
// own: once growth is accepted, a later change could drop back to the floor and
// still pass. This compares a seed against the same seed on the base branch, so
// "never shrinks" holds continuously without a manual bump on every addition.
export const findSeedRowRegression = (previousSource, nextSource) => {
const previousRows = countSqlSeedRows(previousSource);
const nextRows = countSqlSeedRows(nextSource);
return nextRows < previousRows ? { previousRows, nextRows } : null;
};
26 changes: 23 additions & 3 deletions scripts/verify-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readDatabaseMajorVersion } from './supabase-config.mjs';
import { countSqlSeedRows, usesIdentityPreservingNameUpsert } from './seed-contract.mjs';
import {
countSqlSeedRows,
findDuplicateSeedNames,
usesIdentityPreservingNameUpsert,
} from './seed-contract.mjs';

const contract = JSON.parse(readFileSync(new URL('../contract.json', import.meta.url), 'utf8'));
const types = readFileSync(new URL('../generated/database.types.ts', import.meta.url));
Expand Down Expand Up @@ -73,8 +77,24 @@ if (missingDatabaseTests.length !== 0) {
if (contract.typesSha256 !== hash) {
throw new Error(`Generated type hash mismatch: expected ${contract.typesSha256}, received ${hash}.`);
}
if (countSqlSeedRows(godSeed) !== 88) {
throw new Error('The reviewed local SMITE god seed must contain exactly 88 rows.');
// The god catalog grows as SMITE ships gods, and diese-tech/smite-content-sync
// proposes those additions as reviewed PRs. An exact row count froze the
// catalog: every real addition failed this check until someone edited this
// script. What the guard is actually for is a seed that silently loses or
// duplicates entries, so it asserts a declared floor instead. Lowering the
// floor is a deliberate edit to contract.json, where the release is declared.
const godSeedRows = countSqlSeedRows(godSeed);
if (!Number.isInteger(contract.smiteGodSeedMinimumRows) || contract.smiteGodSeedMinimumRows < 1) {
throw new Error('contract.smiteGodSeedMinimumRows must be a positive integer.');
}
if (godSeedRows < contract.smiteGodSeedMinimumRows) {
Comment thread
diese-tech marked this conversation as resolved.
throw new Error(
`The reviewed local SMITE god seed must contain at least ${contract.smiteGodSeedMinimumRows} rows, received ${godSeedRows}.`,
);
}
const duplicateGodNames = findDuplicateSeedNames(godSeed);
if (duplicateGodNames.length !== 0) {
throw new Error(`The SMITE god seed repeats a name: ${duplicateGodNames.join(', ')}.`);
}
if (!usesIdentityPreservingNameUpsert(godSeed)) {
throw new Error('The SMITE god seed must reconcile by unique name without replacing historical IDs.');
Expand Down
27 changes: 27 additions & 0 deletions scripts/verify-seed-growth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import { findSeedRowRegression } from './seed-contract.mjs';

const [previousPath, nextPath, label = 'seed'] = process.argv.slice(2);

if (!previousPath || !nextPath) {
throw new Error('Usage: node scripts/verify-seed-growth.mjs <base-seed> <head-seed> [label]');
}

// A base branch with no such seed yet (a brand-new seed file) cannot regress.
let previousSource;
try {
previousSource = readFileSync(previousPath, 'utf8');
} catch {
console.log(`No base copy of the ${label} seed to compare against; nothing to regress from.`);
process.exit(0);
}

const regression = findSeedRowRegression(previousSource, readFileSync(nextPath, 'utf8'));
if (regression !== null) {
throw new Error(
`The reviewed ${label} seed lost rows against the base branch: ${regression.previousRows} to ${regression.nextRows}. ` +
'A previously reviewed entry may only be removed in a change that says so explicitly.',
);
}

console.log(`Verified the ${label} seed did not lose rows against the base branch.`);
53 changes: 53 additions & 0 deletions test/verification-scripts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { normalizeMigrationPlan } from '../scripts/normalize-migration-plan.mjs'
import { assertProductionTestSqlIsReadOnly } from '../scripts/production-test-contract.mjs';
import {
countSqlSeedRows,
findDuplicateSeedNames,
findSeedRowRegression,
usesIdentityPreservingNameUpsert,
} from '../scripts/seed-contract.mjs';

Expand Down Expand Up @@ -116,6 +118,57 @@ test('counts reviewed SQL seed tuples without relying on live table contents', (
);
});

test('reports a seed that lost rows against its base branch', () => {
// The contract floor does not move on its own, so once growth is accepted a
// later change could drop back to it. This is what keeps "never shrinks"
// true continuously rather than only against the declared floor.
const base = "insert into public.gods values\n ('a', 'A'),\n ('b', 'B');\n";
const shrunk = "insert into public.gods values\n ('a', 'A');\n";
assert.deepEqual(findSeedRowRegression(base, shrunk), { previousRows: 2, nextRows: 1 });
});

test('accepts a seed that grew or held steady against its base branch', () => {
const base = "insert into public.gods values\n ('a', 'A'),\n ('b', 'B');\n";
const grown = "insert into public.gods values\n ('a', 'A'),\n ('b', 'B'),\n ('c', 'C');\n";
assert.equal(findSeedRowRegression(base, grown), null);
assert.equal(findSeedRowRegression(base, base), null);
});

test('reports seed names repeated under different ids', () => {
// The old exact-row assertion caught a duplicate only through the count. A
// growth floor does not, so the duplicate check stands on its own.
assert.deepEqual(
findDuplicateSeedNames(
"insert into public.gods values\n ('zeus', 'Zeus'),\n ('zeus-2', 'Zeus'),\n ('hel', 'Hel');\n",
),
['zeus'],
);
});

test('accepts a growing seed with distinct names', () => {
assert.deepEqual(
findDuplicateSeedNames(
"insert into public.gods values\n ('a', 'A'),\n ('b', 'B'),\n ('c', 'C');\n",
),
[],
);
});

test('compares seed names case-insensitively and unescapes doubled quotes', () => {
assert.deepEqual(
findDuplicateSeedNames(
"insert into public.gods values\n ('ah', 'Ah Muzen Cab'),\n ('ah2', 'ah muzen cab');\n",
),
['ah muzen cab'],
);
assert.deepEqual(
findDuplicateSeedNames(
"insert into public.gods values\n ('x', 'Chang''e'),\n ('y', 'Chang''e');\n",
),
["chang'e"],
);
});

test('requires god seed reconciliation to preserve historical IDs', () => {
assert.equal(
usesIdentityPreservingNameUpsert(
Expand Down