Skip to content

Commit d3e2d3b

Browse files
authored
Merge pull request #6 from tokenhost/feat/schema-migrations
SPEC 6: schema migrations framework + real th migrate
2 parents 4b88127 + 92e12ca commit d3e2d3b

6 files changed

Lines changed: 251 additions & 4 deletions

File tree

packages/cli/src/index.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
computeSchemaHash,
1313
importLegacyContractsJson,
1414
lintThs,
15+
listThsMigrations,
16+
migrateThsSchema,
1517
validateThsStructural,
1618
type Issue,
1719
type ThsSchema
@@ -1283,10 +1285,71 @@ program
12831285
program
12841286
.command('migrate')
12851287
.argument('<schema>', 'Path to THS schema JSON file')
1286-
.description('Apply schema migrations locally (stub)')
1287-
.action(() => {
1288-
console.error('th migrate is not implemented yet (no migrations registry wired).');
1289-
process.exitCode = 1;
1288+
.description('Apply local THS schema migrations')
1289+
.option('--list', 'List known migrations and exit', false)
1290+
.option('--down', 'Apply down migrations (revert)', false)
1291+
.option('--steps <n>', 'Number of migrations to apply (default: all for up; 1 for down)')
1292+
.option('--in-place', 'Overwrite the input schema file', false)
1293+
.option('--out <file>', 'Write migrated schema JSON to a file (defaults to stdout)')
1294+
.action((schemaPath: string, opts: { list: boolean; down: boolean; steps?: string; inPlace: boolean; out?: string }) => {
1295+
if (opts.list) {
1296+
const migrations = listThsMigrations();
1297+
for (const m of migrations) {
1298+
console.log(`${m.id} - ${m.description}`);
1299+
}
1300+
return;
1301+
}
1302+
1303+
if (opts.inPlace && opts.out) {
1304+
console.error('ERROR: --in-place and --out are mutually exclusive.');
1305+
process.exitCode = 1;
1306+
return;
1307+
}
1308+
1309+
const input = readJsonFile(schemaPath);
1310+
const structural = validateThsStructural(input);
1311+
if (!structural.ok) {
1312+
console.error(formatIssues(structural.issues));
1313+
process.exitCode = 1;
1314+
return;
1315+
}
1316+
1317+
const schema = structural.data!;
1318+
1319+
const steps = (() => {
1320+
if (typeof opts.steps !== 'string' || opts.steps.trim() === '') return undefined;
1321+
const n = Number(opts.steps);
1322+
if (!Number.isFinite(n) || n < 0) throw new Error('Invalid --steps value. Expected a non-negative number.');
1323+
return Math.floor(n);
1324+
})();
1325+
1326+
const direction = opts.down ? 'down' : 'up';
1327+
const effectiveSteps = steps ?? (direction === 'down' ? 1 : undefined);
1328+
1329+
const res = migrateThsSchema(schema, { direction, steps: effectiveSteps });
1330+
const migrated = res.schema;
1331+
1332+
// Ensure the migrated schema still validates and lints cleanly.
1333+
const lintIssues = lintThs(migrated);
1334+
const errors = lintIssues.filter((i) => i.severity === 'error');
1335+
if (errors.length > 0) {
1336+
console.error(formatIssues(lintIssues));
1337+
process.exitCode = 1;
1338+
return;
1339+
}
1340+
1341+
const outJson = JSON.stringify(migrated, null, 2);
1342+
if (opts.inPlace) {
1343+
fs.writeFileSync(schemaPath, outJson);
1344+
} else if (opts.out) {
1345+
ensureDir(path.dirname(opts.out));
1346+
fs.writeFileSync(opts.out, outJson);
1347+
} else {
1348+
console.log(outJson);
1349+
}
1350+
1351+
const appliedMsg = res.appliedNow.length > 0 ? res.appliedNow.join(', ') : '(none)';
1352+
console.error(`migrations (${direction}) applied: ${appliedMsg}`);
12901353
});
12911354

12921355
program

packages/schema/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ export { computeSchemaHash } from './hash.js';
55
export { importLegacyContractsJson } from './importLegacy.js';
66
export { lintThs } from './lint.js';
77
export { validateThsStructural } from './validate.js';
8+
export type { ThsMigration } from './migrations/types.js';
9+
export { listThsMigrations, migrateThsSchema } from './migrations/index.js';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { ThsSchema } from '../types.js';
2+
import type { ThsMigration } from './types.js';
3+
4+
const DEFAULTS = {
5+
indexer: false,
6+
delegation: false,
7+
uploads: false,
8+
onChainIndexing: true
9+
} as const;
10+
11+
function clone<T>(v: T): T {
12+
return JSON.parse(JSON.stringify(v));
13+
}
14+
15+
export const migration001NormalizeFeatures: ThsMigration = {
16+
id: '001-normalize-features',
17+
description: 'Make implicit app.features defaults explicit (reversible).',
18+
up: (schema: ThsSchema): ThsSchema => {
19+
const out = clone(schema);
20+
out.app = out.app ?? ({} as any);
21+
const features = ((out.app as any).features ?? {}) as Record<string, unknown>;
22+
23+
for (const [k, v] of Object.entries(DEFAULTS)) {
24+
if (!(k in features)) features[k] = v;
25+
}
26+
27+
(out.app as any).features = features;
28+
return out;
29+
},
30+
down: (schema: ThsSchema): ThsSchema => {
31+
const out = clone(schema);
32+
const features = (out.app as any)?.features;
33+
if (!features || typeof features !== 'object') return out;
34+
35+
// Remove explicit defaults.
36+
for (const [k, v] of Object.entries(DEFAULTS)) {
37+
if ((features as any)[k] === v) delete (features as any)[k];
38+
}
39+
40+
if (Object.keys(features as any).length === 0) {
41+
delete (out.app as any).features;
42+
} else {
43+
(out.app as any).features = features;
44+
}
45+
46+
return out;
47+
}
48+
};
49+
50+
export default migration001NormalizeFeatures;
51+
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ThsSchema } from '../types.js';
2+
import type { ThsMigration } from './types.js';
3+
4+
import migration001 from './001-normalize-features.js';
5+
6+
export const THS_MIGRATIONS: ThsMigration[] = [migration001];
7+
8+
function clone<T>(v: T): T {
9+
return JSON.parse(JSON.stringify(v));
10+
}
11+
12+
function readApplied(schema: any): string[] {
13+
const ids = schema?.metadata?.tokenhost?.appliedMigrations;
14+
return Array.isArray(ids) ? ids.filter((x) => typeof x === 'string') : [];
15+
}
16+
17+
function writeApplied(schema: any, ids: string[]) {
18+
schema.metadata = schema.metadata ?? {};
19+
schema.metadata.tokenhost = schema.metadata.tokenhost ?? {};
20+
schema.metadata.tokenhost.appliedMigrations = ids;
21+
}
22+
23+
export function listThsMigrations(): ThsMigration[] {
24+
return [...THS_MIGRATIONS];
25+
}
26+
27+
export function migrateThsSchema(
28+
schema: ThsSchema,
29+
opts?: { direction?: 'up' | 'down'; steps?: number }
30+
): { schema: ThsSchema; appliedNow: string[]; appliedTotal: string[] } {
31+
const direction = opts?.direction ?? 'up';
32+
const maxSteps = typeof opts?.steps === 'number' && Number.isFinite(opts.steps) ? Math.max(0, Math.floor(opts.steps)) : Number.POSITIVE_INFINITY;
33+
34+
let out = clone(schema);
35+
const applied = readApplied(out);
36+
const appliedSet = new Set(applied);
37+
const appliedNow: string[] = [];
38+
39+
if (direction === 'up') {
40+
let steps = 0;
41+
for (const m of THS_MIGRATIONS) {
42+
if (appliedSet.has(m.id)) continue;
43+
if (steps >= maxSteps) break;
44+
out = m.up(out);
45+
applied.push(m.id);
46+
appliedSet.add(m.id);
47+
appliedNow.push(m.id);
48+
steps++;
49+
}
50+
} else {
51+
// Down: revert from the end of the applied list.
52+
let steps = 0;
53+
while (steps < maxSteps && applied.length > 0) {
54+
const lastId = applied[applied.length - 1]!;
55+
const m = THS_MIGRATIONS.find((x) => x.id === lastId);
56+
if (!m) {
57+
throw new Error(`Cannot down-migrate unknown migration id: ${lastId}`);
58+
}
59+
out = m.down(out);
60+
applied.pop();
61+
appliedSet.delete(lastId);
62+
appliedNow.push(lastId);
63+
steps++;
64+
}
65+
}
66+
67+
writeApplied(out, applied);
68+
return { schema: out, appliedNow, appliedTotal: applied };
69+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { ThsSchema } from '../types.js';
2+
3+
export type ThsMigration = {
4+
id: string;
5+
description: string;
6+
up: (schema: ThsSchema) => ThsSchema;
7+
down: (schema: ThsSchema) => ThsSchema;
8+
};
9+

test/testMigrations.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { expect } from 'chai';
2+
3+
import { migrateThsSchema, validateThsStructural } from '@tokenhost/schema';
4+
5+
function minimalSchema(overrides = {}) {
6+
return {
7+
thsVersion: '2025-12',
8+
schemaVersion: '0.0.1',
9+
app: { name: 'Test App', slug: 'test-app' },
10+
collections: [
11+
{
12+
name: 'Item',
13+
fields: [{ name: 'title', type: 'string', required: true }],
14+
createRules: { required: ['title'], access: 'public' },
15+
visibilityRules: { gets: ['title'], access: 'public' },
16+
updateRules: { mutable: ['title'], access: 'owner' },
17+
deleteRules: { softDelete: true, access: 'owner' },
18+
indexes: { unique: [], index: [] }
19+
}
20+
],
21+
...overrides
22+
};
23+
}
24+
25+
describe('THS schema migrations', function () {
26+
it('up migration makes app.features defaults explicit', function () {
27+
const input = minimalSchema();
28+
const structural = validateThsStructural(input);
29+
expect(structural.ok).to.equal(true);
30+
31+
const res = migrateThsSchema(structural.data, { direction: 'up' });
32+
expect(res.appliedNow).to.include('001-normalize-features');
33+
34+
expect(res.schema.app).to.have.property('features');
35+
expect(res.schema.app.features).to.deep.include({
36+
indexer: false,
37+
delegation: false,
38+
uploads: false,
39+
onChainIndexing: true
40+
});
41+
});
42+
43+
it('down migration reverts explicit defaults', function () {
44+
const input = minimalSchema();
45+
const up = migrateThsSchema(input, { direction: 'up' });
46+
const down = migrateThsSchema(up.schema, { direction: 'down', steps: 1 });
47+
48+
// Features become optional again after down.
49+
expect((down.schema.app || {}).features).to.equal(undefined);
50+
expect((down.schema.metadata || {}).tokenhost?.appliedMigrations || []).to.deep.equal([]);
51+
});
52+
});
53+

0 commit comments

Comments
 (0)