@pikku/cli 0.12.25 → 0.12.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/console-app/assets/index-BERGDBO9.js +228 -0
- package/console-app/assets/{index-C52h1B_L.css → index-CQ29NRyR.css} +1 -1
- package/console-app/index.html +2 -2
- package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-channel.js +6 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +9 -0
- package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
- package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.d.ts +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.json +140 -125
- package/dist/.pikku/function/pikku-functions.gen.js +3 -1
- package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-wirings-meta.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-wirings.gen.d.ts +1 -1
- package/dist/.pikku/http/pikku-http-wirings.gen.js +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
- package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
- package/dist/.pikku/pikku-meta-service.gen.js +1 -1
- package/dist/.pikku/pikku-services.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +11 -10
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
- package/dist/.pikku/schemas/register.gen.js +11 -9
- package/dist/.pikku/schemas/schemas/DbAuditInput.schema.json +1 -0
- package/dist/.pikku/schemas/schemas/PikkuTestsCoverageInput.schema.json +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +1 -1
- package/dist/.pikku/workflow/pikku-workflow-types.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
- package/dist/bin/pikku-bin.mjs +2 -2
- package/dist/src/cli.wiring.js +8 -0
- package/dist/src/fabric/functions/validate-core.js +6 -6
- package/dist/src/fabric/functions/validate.function.js +1 -1
- package/dist/src/functions/commands/db-audit.d.ts +1 -0
- package/dist/src/functions/commands/db-audit.js +67 -0
- package/dist/src/functions/commands/db-migrate.js +5 -8
- package/dist/src/functions/commands/db-reset.js +9 -8
- package/dist/src/functions/commands/db-seed.js +9 -8
- package/dist/src/functions/commands/db-shared.d.ts +2 -4
- package/dist/src/functions/commands/db-shared.js +15 -5
- package/dist/src/functions/commands/dev.js +14 -8
- package/dist/src/functions/commands/new-addon.js +2 -2
- package/dist/src/functions/commands/tests-coverage.d.ts +3 -0
- package/dist/src/functions/commands/tests-coverage.js +34 -0
- package/dist/src/functions/db/annotation-parser.d.ts +31 -0
- package/dist/src/functions/db/annotation-parser.js +93 -0
- package/dist/src/functions/db/db-codegen.d.ts +24 -0
- package/dist/src/functions/db/db-codegen.js +276 -0
- package/dist/src/functions/db/db-introspector.d.ts +15 -0
- package/dist/src/functions/db/db-introspector.js +1 -0
- package/dist/src/functions/db/db-migrator.d.ts +32 -0
- package/dist/src/functions/db/db-migrator.js +65 -0
- package/dist/src/functions/db/local-db.d.ts +26 -32
- package/dist/src/functions/db/local-db.js +100 -53
- package/dist/src/functions/db/postgres/postgres-introspector.d.ts +10 -0
- package/dist/src/functions/db/postgres/postgres-introspector.js +54 -0
- package/dist/src/functions/db/postgres/postgres-migrator.d.ts +9 -0
- package/dist/src/functions/db/postgres/postgres-migrator.js +32 -0
- package/dist/src/functions/db/sqlite/sqlite-introspector.d.ts +9 -0
- package/dist/src/functions/db/sqlite/sqlite-introspector.js +35 -0
- package/dist/src/functions/db/sqlite/sqlite-migrator.d.ts +10 -0
- package/dist/src/functions/db/sqlite/sqlite-migrator.js +36 -0
- package/dist/src/functions/validate/workspace-validate.js +7 -3
- package/dist/src/functions/wirings/ai-agent/serialize-public-agent.js +2 -1
- package/dist/src/functions/wirings/console/serialize-console-functions.js +4 -4
- package/dist/src/functions/wirings/functions/serialize-addon-types.js +1 -1
- package/dist/src/scaffold/rpc-remote.gen.js +1 -1
- package/dist/src/services.js +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -4
- package/skills/pikku-middleware/SKILL.md +283 -0
- package/skills/pikku-permissions/SKILL.md +165 -0
- package/skills/pikku-security/SKILL.md +38 -177
- package/skills/pikku-tag-middleware/SKILL.md +13 -0
- package/skills/pikku-testing/SKILL.md +208 -0
- package/console-app/assets/index-D4DgafuS.js +0 -232
- package/dist/src/functions/db/sql-migrator.d.ts +0 -26
- package/dist/src/functions/db/sql-migrator.js +0 -104
- package/dist/src/functions/db/sqlite-codegen.d.ts +0 -45
- package/dist/src/functions/db/sqlite-codegen.js +0 -294
- /package/dist/src/functions/db/{seed.d.ts → sqlite/seed.d.ts} +0 -0
- /package/dist/src/functions/db/{seed.js → sqlite/seed.js} +0 -0
- /package/dist/src/functions/db/{sqlite-kysely.d.ts → sqlite/sqlite-kysely.d.ts} +0 -0
- /package/dist/src/functions/db/{sqlite-kysely.js → sqlite/sqlite-kysely.js} +0 -0
- /package/dist/src/functions/db/{sqlite-runtime-bun.d.ts → sqlite/sqlite-runtime-bun.d.ts} +0 -0
- /package/dist/src/functions/db/{sqlite-runtime-bun.js → sqlite/sqlite-runtime-bun.js} +0 -0
- /package/dist/src/functions/db/{sqlite-runtime-node.d.ts → sqlite/sqlite-runtime-node.d.ts} +0 -0
- /package/dist/src/functions/db/{sqlite-runtime-node.js → sqlite/sqlite-runtime-node.js} +0 -0
- /package/dist/src/functions/db/{sqlite-runtime.d.ts → sqlite/sqlite-runtime.d.ts} +0 -0
- /package/dist/src/functions/db/{sqlite-runtime.js → sqlite/sqlite-runtime.js} +0 -0
|
@@ -14,7 +14,7 @@ import { pikkuWebsocketHandler } from '@pikku/ws';
|
|
|
14
14
|
import { PikkuNodeHTTPServer } from '@pikku/node-http-server';
|
|
15
15
|
import { WebSocketServer } from 'ws';
|
|
16
16
|
import { InMemorySchedulerService } from '@pikku/schedule';
|
|
17
|
-
import {
|
|
17
|
+
import { resolveDb, createKysely } from '../db/local-db.js';
|
|
18
18
|
import { loadUserBootstrap, loadUserModule } from './load-user-project.js';
|
|
19
19
|
export const dev = pikkuSessionlessFunc({
|
|
20
20
|
remote: true,
|
|
@@ -116,19 +116,25 @@ export const dev = pikkuSessionlessFunc({
|
|
|
116
116
|
const userCreateConfig = configModule[pikkuConfigFactory.variable];
|
|
117
117
|
const userCreateSingletonServices = servicesModule[singletonServicesFactory.variable];
|
|
118
118
|
const userConfig = await userCreateConfig();
|
|
119
|
-
const
|
|
119
|
+
const resolvedDb = resolveDb(userConfig, config.rootDir, config.outDir, config.runtimeDir);
|
|
120
|
+
const resolvedLocalDb = resolvedDb?.dialect === 'sqlite'
|
|
121
|
+
? resolvedDb
|
|
122
|
+
: userConfig.sqliteDb
|
|
123
|
+
? resolveDb({ sqliteDb: userConfig.sqliteDb }, config.rootDir, config.outDir, config.runtimeDir)
|
|
124
|
+
: undefined;
|
|
120
125
|
const kysely = resolvedLocalDb
|
|
121
126
|
? await createKysely(resolvedLocalDb)
|
|
122
127
|
: undefined;
|
|
123
128
|
const resolvedRuntimeDir = config.runtimeDir ?? join(config.rootDir, '.pikku-runtime');
|
|
124
|
-
const localContentConfig = userConfig.
|
|
125
|
-
?.content
|
|
129
|
+
const localContentConfig = userConfig.content
|
|
126
130
|
? {
|
|
127
|
-
localFileUploadPath:
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
localFileUploadPath: userConfig.content.contentPath
|
|
132
|
+
? resolve(config.rootDir, userConfig.content.contentPath)
|
|
133
|
+
: join(resolvedRuntimeDir, 'content'),
|
|
134
|
+
uploadUrlPrefix: userConfig.content.uploadUrlPrefix ?? '/upload',
|
|
135
|
+
assetUrlPrefix: userConfig.content.assetUrlPrefix ?? '/assets',
|
|
130
136
|
server: `http://${hostname}:${resolvedPort}`,
|
|
131
|
-
|
|
137
|
+
sizeLimit: userConfig.content.sizeLimit,
|
|
132
138
|
}
|
|
133
139
|
: undefined;
|
|
134
140
|
const localContent = localContentConfig
|
|
@@ -219,7 +219,7 @@ export const createSingletonServices = pikkuAddonServices(async (
|
|
|
219
219
|
config,
|
|
220
220
|
{ secrets, variables }
|
|
221
221
|
) => {
|
|
222
|
-
const creds = await secrets.
|
|
222
|
+
const creds = await secrets.getSecret<${pascalName}Secrets>('${screamingName}_CREDENTIALS')
|
|
223
223
|
const ${camelName} = new ${pascalName}Service(creds, variables)
|
|
224
224
|
|
|
225
225
|
return { ${camelName} }
|
|
@@ -717,7 +717,7 @@ import { createSingletonServices } from './services.js'
|
|
|
717
717
|
test('${name} addon', async () => {
|
|
718
718
|
const secrets = new LocalSecretService()
|
|
719
719
|
// Set up secrets for the service
|
|
720
|
-
// await secrets.
|
|
720
|
+
// await secrets.setSecret('${screamingName}_CREDENTIALS', { ... })
|
|
721
721
|
|
|
722
722
|
const singletonServices = await createSingletonServices({}, { secrets })
|
|
723
723
|
const rpc = rpcService.getContextRPCService(singletonServices as any, {})
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export declare const pikkuTestsCoverage: import("#pikku").PikkuFunctionConfig<{
|
|
2
2
|
noRun?: boolean;
|
|
3
|
+
aiOut?: string;
|
|
3
4
|
}, void, "rpc" | "session", import("#pikku").PikkuFunctionSessionless<{
|
|
4
5
|
noRun?: boolean;
|
|
6
|
+
aiOut?: string;
|
|
5
7
|
}, void, "rpc" | "session", import("#pikku").Services> | import("#pikku").PikkuFunction<{
|
|
6
8
|
noRun?: boolean;
|
|
9
|
+
aiOut?: string;
|
|
7
10
|
}, void, "rpc" | "session", import("#pikku").Services>, undefined, undefined>;
|
|
@@ -92,6 +92,7 @@ function lineCoverage(fileCov, span) {
|
|
|
92
92
|
export const pikkuTestsCoverage = pikkuSessionlessFunc({
|
|
93
93
|
func: async ({ logger, config }, input) => {
|
|
94
94
|
const noRun = input?.noRun ?? false;
|
|
95
|
+
const aiOut = input?.aiOut ?? null;
|
|
95
96
|
const packageMappings = config.packageMappings ?? {};
|
|
96
97
|
const srcDirs = config.srcDirectories ?? [];
|
|
97
98
|
const functionsRelDir = Object.keys(packageMappings).find((key) => srcDirs.some((src) => src === key || src.startsWith(key + '/'))) ?? Object.keys(packageMappings)[0];
|
|
@@ -227,5 +228,38 @@ export const pikkuTestsCoverage = pikkuSessionlessFunc({
|
|
|
227
228
|
console.log(` ${summary.covered} covered · ${summary.partial} partial · ${summary.uncovered} uncovered · ${summary.unknown} unknown (overall ${(overallRatio * 100).toFixed(1)}%)`);
|
|
228
229
|
if (uncovered.length)
|
|
229
230
|
console.log(` uncovered: ${uncovered.map((f) => f.name).join(', ')}`);
|
|
231
|
+
if (aiOut !== null) {
|
|
232
|
+
const generatedAt = new Date().toISOString();
|
|
233
|
+
const pct = Math.round(overallRatio * 100);
|
|
234
|
+
const needWork = functions.filter((f) => f.status !== 'covered');
|
|
235
|
+
const lines = [
|
|
236
|
+
`Coverage report — generated ${generatedAt}.`,
|
|
237
|
+
`Overall: ${pct}% (${summary.covered}/${summary.total} functions fully covered)`,
|
|
238
|
+
'',
|
|
239
|
+
needWork.length === 0
|
|
240
|
+
? 'All functions are fully covered — nothing to do!'
|
|
241
|
+
: `Functions needing coverage (${needWork.length}):`,
|
|
242
|
+
...needWork.flatMap((fn) => {
|
|
243
|
+
const ratio = Math.round(fn.ratio * 100);
|
|
244
|
+
const missed = fn.missedLines.length > 0 ? fn.missedLines.join(', ') : 'none';
|
|
245
|
+
return [
|
|
246
|
+
`- ${fn.name} [${fn.status}, ${ratio}% covered, ${fn.coveredLines}/${fn.totalLines} lines]`,
|
|
247
|
+
` file: ${fn.sourceFile}`,
|
|
248
|
+
` missed lines: ${missed}`,
|
|
249
|
+
];
|
|
250
|
+
}),
|
|
251
|
+
];
|
|
252
|
+
if (needWork.length > 0) {
|
|
253
|
+
lines.push('', 'Write @pikku/cucumber Gherkin scenarios (no custom steps) in tests/tests/features/ to cover the missed lines above.', 'Use pikku meta to get versioned RPC names and function schemas before writing.', 'Run pikku tests coverage after writing to verify coverage improved.');
|
|
254
|
+
}
|
|
255
|
+
const prompt = lines.join('\n') + '\n';
|
|
256
|
+
if (aiOut === '-') {
|
|
257
|
+
process.stdout.write(prompt);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
writeFileSync(aiOut, prompt, 'utf-8');
|
|
261
|
+
console.log(`AI prompt → ${relative(config.rootDir, aiOut)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
230
264
|
},
|
|
231
265
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ColumnKind } from './coercion-plugin.js';
|
|
2
|
+
type Classification = 'public' | 'private' | 'secret';
|
|
3
|
+
type AnonymizeStrategy = 'fake:email' | 'fake:name' | 'hash' | 'keep' | null;
|
|
4
|
+
export interface ColAnnotation {
|
|
5
|
+
kind?: ColumnKind;
|
|
6
|
+
/** TypeScript type string for @json columns, e.g. `string[]`. */
|
|
7
|
+
tsType?: string;
|
|
8
|
+
classification?: Classification;
|
|
9
|
+
anonymize?: AnonymizeStrategy;
|
|
10
|
+
}
|
|
11
|
+
/** Per-table, per-column annotation map built from migration SQL comments. */
|
|
12
|
+
export type AnnotationMap = Record<string, Record<string, ColAnnotation>>;
|
|
13
|
+
/**
|
|
14
|
+
* Determine column kind from naming conventions:
|
|
15
|
+
* *_at / *_on → date
|
|
16
|
+
* is_* / has_* / can_* → bool
|
|
17
|
+
*/
|
|
18
|
+
export declare function annotationFromName(colName: string): {
|
|
19
|
+
kind: ColumnKind;
|
|
20
|
+
} | null;
|
|
21
|
+
/**
|
|
22
|
+
* Parse `-- @bool | @date | @json [TsType] | @public | @private[:strategy] | @secret[:strategy]`
|
|
23
|
+
* inline annotations from migration SQL files in `migrationsDir`.
|
|
24
|
+
*
|
|
25
|
+
* Multiple annotations on the same comment line are supported, e.g.:
|
|
26
|
+
* `deleted_at TIMESTAMP -- @date @private:keep`
|
|
27
|
+
*
|
|
28
|
+
* Covers both CREATE TABLE body lines and ALTER TABLE ... ADD [COLUMN] statements.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseAnnotations(migrationsDir: string): AnnotationMap;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Determine column kind from naming conventions:
|
|
5
|
+
* *_at / *_on → date
|
|
6
|
+
* is_* / has_* / can_* → bool
|
|
7
|
+
*/
|
|
8
|
+
export function annotationFromName(colName) {
|
|
9
|
+
if (/_at$|_on$/.test(colName))
|
|
10
|
+
return { kind: 'date' };
|
|
11
|
+
if (/^is_|^has_|^can_/.test(colName))
|
|
12
|
+
return { kind: 'bool' };
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function parseStrategy(s) {
|
|
16
|
+
if (!s)
|
|
17
|
+
return null;
|
|
18
|
+
const valid = ['fake:email', 'fake:name', 'hash', 'keep'];
|
|
19
|
+
return valid.includes(s) ? s : null;
|
|
20
|
+
}
|
|
21
|
+
function parseComment(comment) {
|
|
22
|
+
const ann = {};
|
|
23
|
+
if (/@bool\b/i.test(comment)) {
|
|
24
|
+
ann.kind = 'bool';
|
|
25
|
+
}
|
|
26
|
+
else if (/@date\b/i.test(comment)) {
|
|
27
|
+
ann.kind = 'date';
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const jsonM = comment.match(/@json\b(?:\s+([^\s@]+))?/i);
|
|
31
|
+
if (jsonM) {
|
|
32
|
+
ann.kind = 'json';
|
|
33
|
+
if (jsonM[1])
|
|
34
|
+
ann.tsType = jsonM[1].trim();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const classM = comment.match(/@(public|private|secret)(?::([^\s@]+))?/i);
|
|
38
|
+
if (classM) {
|
|
39
|
+
ann.classification = classM[1].toLowerCase();
|
|
40
|
+
ann.anonymize = parseStrategy(classM[2]);
|
|
41
|
+
}
|
|
42
|
+
return ann;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse `-- @bool | @date | @json [TsType] | @public | @private[:strategy] | @secret[:strategy]`
|
|
46
|
+
* inline annotations from migration SQL files in `migrationsDir`.
|
|
47
|
+
*
|
|
48
|
+
* Multiple annotations on the same comment line are supported, e.g.:
|
|
49
|
+
* `deleted_at TIMESTAMP -- @date @private:keep`
|
|
50
|
+
*
|
|
51
|
+
* Covers both CREATE TABLE body lines and ALTER TABLE ... ADD [COLUMN] statements.
|
|
52
|
+
*/
|
|
53
|
+
export function parseAnnotations(migrationsDir) {
|
|
54
|
+
let files;
|
|
55
|
+
try {
|
|
56
|
+
files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
const result = {};
|
|
62
|
+
function merge(tableName, colName, partial) {
|
|
63
|
+
if (!partial.kind && partial.classification === undefined)
|
|
64
|
+
return;
|
|
65
|
+
if (!result[tableName])
|
|
66
|
+
result[tableName] = {};
|
|
67
|
+
result[tableName][colName] = { ...result[tableName][colName], ...partial };
|
|
68
|
+
}
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const content = readFileSync(join(migrationsDir, file), 'utf8');
|
|
71
|
+
const createTablePattern = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?(\w+)"?\s*\(([^;]+)\)/gis;
|
|
72
|
+
let tableMatch;
|
|
73
|
+
while ((tableMatch = createTablePattern.exec(content)) !== null) {
|
|
74
|
+
const tableName = tableMatch[1].toLowerCase();
|
|
75
|
+
const body = tableMatch[2];
|
|
76
|
+
for (const line of body.split('\n')) {
|
|
77
|
+
const trimmed = line.trim();
|
|
78
|
+
if (/^(PRIMARY|UNIQUE|CHECK|FOREIGN|CONSTRAINT)/i.test(trimmed))
|
|
79
|
+
continue;
|
|
80
|
+
const lineMatch = trimmed.match(/^(\w+)\s+\w[^-]*--\s*(.+?)\s*,?\s*$/);
|
|
81
|
+
if (!lineMatch)
|
|
82
|
+
continue;
|
|
83
|
+
merge(tableName, lineMatch[1].toLowerCase(), parseComment(lineMatch[2]));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const alterPattern = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+(?:COLUMN\s+)?"?(\w+)"?\s+\w[^;\n-]*(?:;\s*)?--\s*(.+?)(?:\r?\n|$)/gim;
|
|
87
|
+
let alterMatch;
|
|
88
|
+
while ((alterMatch = alterPattern.exec(content)) !== null) {
|
|
89
|
+
merge(alterMatch[1].toLowerCase(), alterMatch[2].toLowerCase(), parseComment(alterMatch[3]));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DbIntrospector } from './db-introspector.js';
|
|
2
|
+
export interface CodegenOptions {
|
|
3
|
+
outFile: string;
|
|
4
|
+
coercionFile: string;
|
|
5
|
+
manifestFile?: string;
|
|
6
|
+
camelCase?: boolean;
|
|
7
|
+
migrationsDir?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CodegenResult {
|
|
10
|
+
outFile: string;
|
|
11
|
+
coercionFile: string;
|
|
12
|
+
manifestFile?: string;
|
|
13
|
+
written: boolean;
|
|
14
|
+
coercionWritten: boolean;
|
|
15
|
+
manifestWritten: boolean;
|
|
16
|
+
tables: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Introspect `introspector` and emit:
|
|
20
|
+
* - `schema.d.ts` Kysely DB type with classification brands
|
|
21
|
+
* - `coercion.gen.ts` Runtime CoercionMap for date/bool/json coercion
|
|
22
|
+
* - `classification.gen.ts` Data-classification manifest (when manifestFile set)
|
|
23
|
+
*/
|
|
24
|
+
export declare function generateSchemaTypes(introspector: DbIntrospector, options: CodegenOptions): Promise<CodegenResult>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { parseAnnotations, annotationFromName, } from './annotation-parser.js';
|
|
4
|
+
// ─── Name helpers ─────────────────────────────────────────────────────────────
|
|
5
|
+
function snakeToPascal(name) {
|
|
6
|
+
return name
|
|
7
|
+
.split('_')
|
|
8
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
9
|
+
.join('');
|
|
10
|
+
}
|
|
11
|
+
function snakeToCamel(name) {
|
|
12
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
13
|
+
}
|
|
14
|
+
// ─── Type mapping ─────────────────────────────────────────────────────────────
|
|
15
|
+
function mapType(sqlType) {
|
|
16
|
+
const upper = sqlType.toUpperCase();
|
|
17
|
+
if (upper.includes('INT'))
|
|
18
|
+
return 'number';
|
|
19
|
+
if (upper.includes('CHAR') ||
|
|
20
|
+
upper.includes('CLOB') ||
|
|
21
|
+
upper.includes('TEXT') ||
|
|
22
|
+
upper === 'UUID')
|
|
23
|
+
return 'string';
|
|
24
|
+
if (upper.includes('BLOB') || upper === 'BYTEA')
|
|
25
|
+
return 'Buffer';
|
|
26
|
+
if (upper.includes('REAL') || upper.includes('FLOA') || upper.includes('DOUB'))
|
|
27
|
+
return 'number';
|
|
28
|
+
if (upper.includes('NUMERIC') || upper.includes('DECIMAL'))
|
|
29
|
+
return 'number';
|
|
30
|
+
// Postgres BOOLEAN type → boolean; SQLite BOOL (stores as int) → number
|
|
31
|
+
if (upper === 'BOOLEAN')
|
|
32
|
+
return 'boolean';
|
|
33
|
+
if (upper.includes('BOOL'))
|
|
34
|
+
return 'number';
|
|
35
|
+
if (upper.includes('JSON'))
|
|
36
|
+
return 'unknown';
|
|
37
|
+
return 'string';
|
|
38
|
+
}
|
|
39
|
+
// ─── Type expression ─────────────────────────────────────────────────────────
|
|
40
|
+
function selectBase(annotation, col) {
|
|
41
|
+
if (annotation?.kind === 'bool')
|
|
42
|
+
return 'boolean';
|
|
43
|
+
if (annotation?.kind === 'date')
|
|
44
|
+
return 'Date';
|
|
45
|
+
if (annotation?.kind === 'json')
|
|
46
|
+
return annotation.tsType ?? 'unknown';
|
|
47
|
+
return mapType(col.type);
|
|
48
|
+
}
|
|
49
|
+
function insertBase(annotation, col) {
|
|
50
|
+
if (annotation?.kind === 'bool')
|
|
51
|
+
return 'boolean | number';
|
|
52
|
+
if (annotation?.kind === 'date')
|
|
53
|
+
return 'Date | string';
|
|
54
|
+
if (annotation?.kind === 'json')
|
|
55
|
+
return annotation.tsType ?? 'unknown';
|
|
56
|
+
return mapType(col.type);
|
|
57
|
+
}
|
|
58
|
+
function columnTypeExpression(col, annotation, classification) {
|
|
59
|
+
const nullable = !col.notNull && !col.pk;
|
|
60
|
+
const isAutoInt = col.pk && mapType(col.type) === 'number';
|
|
61
|
+
const isOptionalInsert = col.defaultValue !== null || isAutoInt || Boolean(col.generated);
|
|
62
|
+
if (classification === 'public') {
|
|
63
|
+
const wrap = (inner) => isOptionalInsert ? `Generated<${inner}>` : inner;
|
|
64
|
+
if (annotation?.kind === 'bool') {
|
|
65
|
+
const base = nullable ? 'boolean | null' : 'boolean';
|
|
66
|
+
const rw = nullable ? 'boolean | number | null' : 'boolean | number';
|
|
67
|
+
return wrap(`ColumnType<${base}, ${rw}, ${rw}>`);
|
|
68
|
+
}
|
|
69
|
+
if (annotation?.kind === 'date') {
|
|
70
|
+
const base = nullable ? 'Date | null' : 'Date';
|
|
71
|
+
const rw = nullable ? 'Date | string | null' : 'Date | string';
|
|
72
|
+
return wrap(`ColumnType<${base}, ${rw}, ${rw}>`);
|
|
73
|
+
}
|
|
74
|
+
if (annotation?.kind === 'json') {
|
|
75
|
+
const base = annotation.tsType
|
|
76
|
+
? nullable
|
|
77
|
+
? `${annotation.tsType} | null`
|
|
78
|
+
: annotation.tsType
|
|
79
|
+
: nullable
|
|
80
|
+
? 'unknown | null'
|
|
81
|
+
: 'unknown';
|
|
82
|
+
return wrap(base);
|
|
83
|
+
}
|
|
84
|
+
const base = mapType(col.type);
|
|
85
|
+
if (isAutoInt)
|
|
86
|
+
return `Generated<${base}>`;
|
|
87
|
+
if (col.defaultValue !== null || col.generated)
|
|
88
|
+
return `Generated<${base}${nullable ? ' | null' : ''}>`;
|
|
89
|
+
return nullable ? `${base} | null` : base;
|
|
90
|
+
}
|
|
91
|
+
const B = classification === 'secret' ? 'Secret' : 'Private';
|
|
92
|
+
const sBase = selectBase(annotation, col);
|
|
93
|
+
const iBase = insertBase(annotation, col);
|
|
94
|
+
const selectT = nullable ? `${B}<${sBase}> | null` : `${B}<${sBase}>`;
|
|
95
|
+
const insertT = nullable
|
|
96
|
+
? `${iBase} | null${isOptionalInsert ? ' | undefined' : ''}`
|
|
97
|
+
: `${iBase}${isOptionalInsert ? ' | undefined' : ''}`;
|
|
98
|
+
const updateT = nullable ? `${iBase} | null` : iBase;
|
|
99
|
+
return `ColumnType<${selectT}, ${insertT}, ${updateT}>`;
|
|
100
|
+
}
|
|
101
|
+
function emitInterface(table, camelCase, explicitAnnotations) {
|
|
102
|
+
const ifaceName = snakeToPascal(table.name);
|
|
103
|
+
const tableCols = explicitAnnotations[table.name] ?? {};
|
|
104
|
+
const fields = table.columns
|
|
105
|
+
.map((col) => {
|
|
106
|
+
const fieldName = camelCase ? snakeToCamel(col.name) : col.name;
|
|
107
|
+
const sqlAnn = tableCols[col.name] ?? null;
|
|
108
|
+
const kindAnn = sqlAnn?.kind
|
|
109
|
+
? { kind: sqlAnn.kind, tsType: sqlAnn.tsType }
|
|
110
|
+
: annotationFromName(col.name);
|
|
111
|
+
const classification = sqlAnn?.classification ?? 'private';
|
|
112
|
+
const type = columnTypeExpression(col, kindAnn, classification);
|
|
113
|
+
const safeName = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)
|
|
114
|
+
? fieldName
|
|
115
|
+
: JSON.stringify(fieldName);
|
|
116
|
+
return ` ${safeName}: ${type}`;
|
|
117
|
+
})
|
|
118
|
+
.join('\n');
|
|
119
|
+
return `export interface ${ifaceName} {\n${fields}\n}`;
|
|
120
|
+
}
|
|
121
|
+
// ─── Manifest emitter ────────────────────────────────────────────────────────
|
|
122
|
+
function emitManifest(tables, explicitAnnotations) {
|
|
123
|
+
const tableEntries = tables
|
|
124
|
+
.map((table) => {
|
|
125
|
+
const tableCols = explicitAnnotations[table.name] ?? {};
|
|
126
|
+
const colEntries = table.columns
|
|
127
|
+
.map((col) => {
|
|
128
|
+
const ann = tableCols[col.name];
|
|
129
|
+
const classification = ann?.classification ?? 'private';
|
|
130
|
+
const strategy = ann?.anonymize ?? null;
|
|
131
|
+
const strategyLiteral = strategy === null ? 'null' : `'${strategy}'`;
|
|
132
|
+
return (` ${JSON.stringify(col.name)}: ` +
|
|
133
|
+
`{ classification: '${classification}', anonymize_strategy: ${strategyLiteral} }`);
|
|
134
|
+
})
|
|
135
|
+
.join(',\n');
|
|
136
|
+
return ` ${JSON.stringify(table.name)}: {\n${colEntries}\n }`;
|
|
137
|
+
})
|
|
138
|
+
.join(',\n');
|
|
139
|
+
return [
|
|
140
|
+
`// Generated by @pikku/cli — do not edit by hand.`,
|
|
141
|
+
`// Run \`pikku db migrate\` to refresh.`,
|
|
142
|
+
``,
|
|
143
|
+
`export const classificationManifest = {`,
|
|
144
|
+
` version: 1 as const,`,
|
|
145
|
+
` tables: {`,
|
|
146
|
+
tableEntries,
|
|
147
|
+
` },`,
|
|
148
|
+
`} as const`,
|
|
149
|
+
``,
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Introspect `introspector` and emit:
|
|
154
|
+
* - `schema.d.ts` Kysely DB type with classification brands
|
|
155
|
+
* - `coercion.gen.ts` Runtime CoercionMap for date/bool/json coercion
|
|
156
|
+
* - `classification.gen.ts` Data-classification manifest (when manifestFile set)
|
|
157
|
+
*/
|
|
158
|
+
export async function generateSchemaTypes(introspector, options) {
|
|
159
|
+
const camelCase = options.camelCase ?? true;
|
|
160
|
+
const tableNames = await introspector.listTables();
|
|
161
|
+
const tables = await Promise.all(tableNames.map(async (name) => ({
|
|
162
|
+
name,
|
|
163
|
+
columns: await introspector.getColumns(name),
|
|
164
|
+
})));
|
|
165
|
+
const explicitAnnotations = options.migrationsDir
|
|
166
|
+
? parseAnnotations(options.migrationsDir)
|
|
167
|
+
: {};
|
|
168
|
+
// ── schema.d.ts ─────────────────────────────────────────────────────────────
|
|
169
|
+
const interfaces = tables
|
|
170
|
+
.map((t) => emitInterface(t, camelCase, explicitAnnotations))
|
|
171
|
+
.join('\n\n');
|
|
172
|
+
const dbEntries = tables
|
|
173
|
+
.map((t) => {
|
|
174
|
+
const tableKey = camelCase ? snakeToCamel(t.name) : t.name;
|
|
175
|
+
const safe = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableKey)
|
|
176
|
+
? tableKey
|
|
177
|
+
: JSON.stringify(tableKey);
|
|
178
|
+
return ` ${safe}: ${snakeToPascal(t.name)}`;
|
|
179
|
+
})
|
|
180
|
+
.join('\n');
|
|
181
|
+
const schemaBody = [
|
|
182
|
+
`// Generated by @pikku/cli — do not edit by hand.`,
|
|
183
|
+
`// Run \`pikku db migrate\` to refresh.`,
|
|
184
|
+
``,
|
|
185
|
+
`import type { ColumnType } from 'kysely'`,
|
|
186
|
+
``,
|
|
187
|
+
`export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>`,
|
|
188
|
+
` ? ColumnType<S, I | undefined, U>`,
|
|
189
|
+
` : ColumnType<T, T | undefined, T>`,
|
|
190
|
+
``,
|
|
191
|
+
`export type Private<T> = T & { readonly __pii__: 'private' }`,
|
|
192
|
+
`export type Secret<T> = T & { readonly __pii__: 'secret' }`,
|
|
193
|
+
``,
|
|
194
|
+
interfaces,
|
|
195
|
+
``,
|
|
196
|
+
`export interface DB {`,
|
|
197
|
+
dbEntries,
|
|
198
|
+
`}`,
|
|
199
|
+
``,
|
|
200
|
+
].join('\n');
|
|
201
|
+
// ── coercion.gen.ts ──────────────────────────────────────────────────────────
|
|
202
|
+
const coercionMap = {};
|
|
203
|
+
for (const table of tables) {
|
|
204
|
+
const tableCols = explicitAnnotations[table.name] ?? {};
|
|
205
|
+
for (const col of table.columns) {
|
|
206
|
+
const sqlAnn = tableCols[col.name];
|
|
207
|
+
const kind = sqlAnn?.kind ?? annotationFromName(col.name)?.kind;
|
|
208
|
+
if (kind) {
|
|
209
|
+
if (!coercionMap[table.name])
|
|
210
|
+
coercionMap[table.name] = {};
|
|
211
|
+
coercionMap[table.name][col.name] = kind;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const coercionEntries = Object.entries(coercionMap)
|
|
216
|
+
.map(([table, cols]) => {
|
|
217
|
+
const colEntries = Object.entries(cols)
|
|
218
|
+
.map(([col, kind]) => ` "${col}": "${kind}"`)
|
|
219
|
+
.join(',\n');
|
|
220
|
+
return ` "${table}": {\n${colEntries}\n }`;
|
|
221
|
+
})
|
|
222
|
+
.join(',\n');
|
|
223
|
+
const coercionBody = [
|
|
224
|
+
`// Generated by @pikku/cli — do not edit by hand.`,
|
|
225
|
+
`// Run \`pikku db migrate\` to refresh.`,
|
|
226
|
+
``,
|
|
227
|
+
`export const coercionMap = {`,
|
|
228
|
+
coercionEntries,
|
|
229
|
+
`} as const`,
|
|
230
|
+
``,
|
|
231
|
+
].join('\n');
|
|
232
|
+
// ── classification.gen.ts ───────────────────────────────────────────────────
|
|
233
|
+
const manifestBody = options.manifestFile ? emitManifest(tables, explicitAnnotations) : null;
|
|
234
|
+
// ── write files ───────────────────────────────────────────────────────────────
|
|
235
|
+
let existingSchema = null;
|
|
236
|
+
let existingCoercion = null;
|
|
237
|
+
let existingManifest = null;
|
|
238
|
+
try {
|
|
239
|
+
existingSchema = readFileSync(options.outFile, 'utf8');
|
|
240
|
+
}
|
|
241
|
+
catch { /* ok */ }
|
|
242
|
+
try {
|
|
243
|
+
existingCoercion = readFileSync(options.coercionFile, 'utf8');
|
|
244
|
+
}
|
|
245
|
+
catch { /* ok */ }
|
|
246
|
+
if (options.manifestFile) {
|
|
247
|
+
try {
|
|
248
|
+
existingManifest = readFileSync(options.manifestFile, 'utf8');
|
|
249
|
+
}
|
|
250
|
+
catch { /* ok */ }
|
|
251
|
+
}
|
|
252
|
+
const schemaChanged = existingSchema !== schemaBody;
|
|
253
|
+
const coercionChanged = existingCoercion !== coercionBody;
|
|
254
|
+
const manifestChanged = manifestBody !== null && existingManifest !== manifestBody;
|
|
255
|
+
if (schemaChanged) {
|
|
256
|
+
mkdirSync(dirname(options.outFile), { recursive: true });
|
|
257
|
+
writeFileSync(options.outFile, schemaBody, 'utf8');
|
|
258
|
+
}
|
|
259
|
+
if (coercionChanged) {
|
|
260
|
+
mkdirSync(dirname(options.coercionFile), { recursive: true });
|
|
261
|
+
writeFileSync(options.coercionFile, coercionBody, 'utf8');
|
|
262
|
+
}
|
|
263
|
+
if (manifestChanged && options.manifestFile && manifestBody) {
|
|
264
|
+
mkdirSync(dirname(options.manifestFile), { recursive: true });
|
|
265
|
+
writeFileSync(options.manifestFile, manifestBody, 'utf8');
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
outFile: options.outFile,
|
|
269
|
+
coercionFile: options.coercionFile,
|
|
270
|
+
manifestFile: options.manifestFile,
|
|
271
|
+
written: schemaChanged,
|
|
272
|
+
coercionWritten: coercionChanged,
|
|
273
|
+
manifestWritten: manifestChanged,
|
|
274
|
+
tables: tables.map((t) => t.name),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ColumnInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
/** Raw SQL/DB type string, e.g. 'INTEGER', 'TEXT', 'boolean', 'timestamp without time zone' */
|
|
4
|
+
type: string;
|
|
5
|
+
notNull: boolean;
|
|
6
|
+
pk: boolean;
|
|
7
|
+
defaultValue: string | null;
|
|
8
|
+
/** True for virtual or stored generated columns — these are read-only and never inserted. */
|
|
9
|
+
generated?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface DbIntrospector {
|
|
12
|
+
listTables(): Promise<string[]>;
|
|
13
|
+
getColumns(table: string): Promise<ColumnInfo[]>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare class MigrationDriftError extends Error {
|
|
2
|
+
readonly file: string;
|
|
3
|
+
readonly recordedHash: string;
|
|
4
|
+
readonly currentHash: string | null;
|
|
5
|
+
readonly appliedAt: string;
|
|
6
|
+
constructor(file: string, recordedHash: string, currentHash: string | null, appliedAt: string, migrationsDir: string);
|
|
7
|
+
}
|
|
8
|
+
export interface MigrateResult {
|
|
9
|
+
applied: string[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface AppliedMigration {
|
|
13
|
+
name: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
applied_at: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Provider-agnostic migration executor. Implement this for each DB dialect.
|
|
19
|
+
* Each method maps to a single DB operation; all file I/O and hashing lives
|
|
20
|
+
* in the shared `migrate()` function above.
|
|
21
|
+
*/
|
|
22
|
+
export interface MigrationExecutor {
|
|
23
|
+
ensureTrackingTable(): Promise<void>;
|
|
24
|
+
getApplied(): Promise<AppliedMigration[]>;
|
|
25
|
+
runMigration(sql: string, name: string, hash: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Apply pending migrations from `migrationsDir/*.sql` using the supplied
|
|
29
|
+
* executor. Hashes raw file bytes on apply; subsequent runs re-hash and bail
|
|
30
|
+
* with `MigrationDriftError` if any applied file has changed on disk.
|
|
31
|
+
*/
|
|
32
|
+
export declare function migrate(executor: MigrationExecutor, migrationsDir: string): Promise<MigrateResult>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export class MigrationDriftError extends Error {
|
|
5
|
+
file;
|
|
6
|
+
recordedHash;
|
|
7
|
+
currentHash;
|
|
8
|
+
appliedAt;
|
|
9
|
+
constructor(file, recordedHash, currentHash, appliedAt, migrationsDir) {
|
|
10
|
+
const onDisk = currentHash === null
|
|
11
|
+
? 'file missing on disk'
|
|
12
|
+
: `sha256:${currentHash.slice(0, 8)}…`;
|
|
13
|
+
super(`[PKU-DB-DRIFT] ${migrationsDir}/${file}\n\n` +
|
|
14
|
+
`Migration content has changed since it was applied.\n` +
|
|
15
|
+
` recorded: sha256:${recordedHash.slice(0, 8)}… applied ${appliedAt}\n` +
|
|
16
|
+
` on disk: ${onDisk}\n\n` +
|
|
17
|
+
`If this edit was intentional, write a new forward migration to revert the change.\n` +
|
|
18
|
+
`Production migrations are immutable.`);
|
|
19
|
+
this.file = file;
|
|
20
|
+
this.recordedHash = recordedHash;
|
|
21
|
+
this.currentHash = currentHash;
|
|
22
|
+
this.appliedAt = appliedAt;
|
|
23
|
+
this.name = 'MigrationDriftError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function sha256(bytes) {
|
|
27
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Apply pending migrations from `migrationsDir/*.sql` using the supplied
|
|
31
|
+
* executor. Hashes raw file bytes on apply; subsequent runs re-hash and bail
|
|
32
|
+
* with `MigrationDriftError` if any applied file has changed on disk.
|
|
33
|
+
*/
|
|
34
|
+
export async function migrate(executor, migrationsDir) {
|
|
35
|
+
await executor.ensureTrackingTable();
|
|
36
|
+
const applied = await executor.getApplied();
|
|
37
|
+
for (const row of applied) {
|
|
38
|
+
let currentHash = null;
|
|
39
|
+
try {
|
|
40
|
+
currentHash = sha256(readFileSync(join(migrationsDir, row.name)));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
currentHash = null;
|
|
44
|
+
}
|
|
45
|
+
if (currentHash !== row.hash) {
|
|
46
|
+
throw new MigrationDriftError(row.name, row.hash, currentHash, row.applied_at, migrationsDir);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const appliedSet = new Set(applied.map((r) => r.name));
|
|
50
|
+
const files = readdirSync(migrationsDir)
|
|
51
|
+
.filter((f) => f.endsWith('.sql'))
|
|
52
|
+
.sort();
|
|
53
|
+
const result = { applied: [], skipped: [] };
|
|
54
|
+
for (const name of files) {
|
|
55
|
+
if (appliedSet.has(name)) {
|
|
56
|
+
result.skipped.push(name);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const raw = readFileSync(join(migrationsDir, name));
|
|
60
|
+
const hash = sha256(raw);
|
|
61
|
+
await executor.runMigration(raw.toString('utf8'), name, hash);
|
|
62
|
+
result.applied.push(name);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|