@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.
Files changed (122) hide show
  1. package/console-app/assets/index-BERGDBO9.js +228 -0
  2. package/console-app/assets/{index-C52h1B_L.css → index-CQ29NRyR.css} +1 -1
  3. package/console-app/index.html +2 -2
  4. package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
  5. package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
  6. package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
  7. package/dist/.pikku/cli/pikku-cli-channel.js +6 -1
  8. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  9. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  10. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  11. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +9 -0
  12. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  13. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  14. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  15. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  16. package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
  17. package/dist/.pikku/function/pikku-function-types.gen.d.ts +1 -1
  18. package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
  19. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  20. package/dist/.pikku/function/pikku-functions-meta.gen.json +140 -125
  21. package/dist/.pikku/function/pikku-functions.gen.js +3 -1
  22. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  23. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  24. package/dist/.pikku/http/pikku-http-wirings-meta.gen.js +1 -1
  25. package/dist/.pikku/http/pikku-http-wirings.gen.d.ts +1 -1
  26. package/dist/.pikku/http/pikku-http-wirings.gen.js +1 -1
  27. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  28. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  29. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  30. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  31. package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
  32. package/dist/.pikku/pikku-meta-service.gen.js +1 -1
  33. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  34. package/dist/.pikku/pikku-types.gen.d.ts +1 -1
  35. package/dist/.pikku/pikku-types.gen.js +1 -1
  36. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  37. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  38. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  39. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  40. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  41. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  42. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +11 -10
  43. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  44. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  45. package/dist/.pikku/schemas/register.gen.js +11 -9
  46. package/dist/.pikku/schemas/schemas/DbAuditInput.schema.json +1 -0
  47. package/dist/.pikku/schemas/schemas/PikkuTestsCoverageInput.schema.json +1 -1
  48. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
  49. package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
  50. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  51. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  52. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  53. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  54. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
  55. package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
  56. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  57. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  58. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +1 -1
  59. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +1 -1
  60. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  61. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  62. package/dist/bin/pikku-bin.mjs +2 -2
  63. package/dist/src/cli.wiring.js +8 -0
  64. package/dist/src/fabric/functions/validate-core.js +6 -6
  65. package/dist/src/fabric/functions/validate.function.js +1 -1
  66. package/dist/src/functions/commands/db-audit.d.ts +1 -0
  67. package/dist/src/functions/commands/db-audit.js +67 -0
  68. package/dist/src/functions/commands/db-migrate.js +5 -8
  69. package/dist/src/functions/commands/db-reset.js +9 -8
  70. package/dist/src/functions/commands/db-seed.js +9 -8
  71. package/dist/src/functions/commands/db-shared.d.ts +2 -4
  72. package/dist/src/functions/commands/db-shared.js +15 -5
  73. package/dist/src/functions/commands/dev.js +14 -8
  74. package/dist/src/functions/commands/new-addon.js +2 -2
  75. package/dist/src/functions/commands/tests-coverage.d.ts +3 -0
  76. package/dist/src/functions/commands/tests-coverage.js +34 -0
  77. package/dist/src/functions/db/annotation-parser.d.ts +31 -0
  78. package/dist/src/functions/db/annotation-parser.js +93 -0
  79. package/dist/src/functions/db/db-codegen.d.ts +24 -0
  80. package/dist/src/functions/db/db-codegen.js +276 -0
  81. package/dist/src/functions/db/db-introspector.d.ts +15 -0
  82. package/dist/src/functions/db/db-introspector.js +1 -0
  83. package/dist/src/functions/db/db-migrator.d.ts +32 -0
  84. package/dist/src/functions/db/db-migrator.js +65 -0
  85. package/dist/src/functions/db/local-db.d.ts +26 -32
  86. package/dist/src/functions/db/local-db.js +100 -53
  87. package/dist/src/functions/db/postgres/postgres-introspector.d.ts +10 -0
  88. package/dist/src/functions/db/postgres/postgres-introspector.js +54 -0
  89. package/dist/src/functions/db/postgres/postgres-migrator.d.ts +9 -0
  90. package/dist/src/functions/db/postgres/postgres-migrator.js +32 -0
  91. package/dist/src/functions/db/sqlite/sqlite-introspector.d.ts +9 -0
  92. package/dist/src/functions/db/sqlite/sqlite-introspector.js +35 -0
  93. package/dist/src/functions/db/sqlite/sqlite-migrator.d.ts +10 -0
  94. package/dist/src/functions/db/sqlite/sqlite-migrator.js +36 -0
  95. package/dist/src/functions/validate/workspace-validate.js +7 -3
  96. package/dist/src/functions/wirings/ai-agent/serialize-public-agent.js +2 -1
  97. package/dist/src/functions/wirings/console/serialize-console-functions.js +4 -4
  98. package/dist/src/functions/wirings/functions/serialize-addon-types.js +1 -1
  99. package/dist/src/scaffold/rpc-remote.gen.js +1 -1
  100. package/dist/src/services.js +2 -0
  101. package/dist/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +6 -4
  103. package/skills/pikku-middleware/SKILL.md +283 -0
  104. package/skills/pikku-permissions/SKILL.md +165 -0
  105. package/skills/pikku-security/SKILL.md +38 -177
  106. package/skills/pikku-tag-middleware/SKILL.md +13 -0
  107. package/skills/pikku-testing/SKILL.md +208 -0
  108. package/console-app/assets/index-D4DgafuS.js +0 -232
  109. package/dist/src/functions/db/sql-migrator.d.ts +0 -26
  110. package/dist/src/functions/db/sql-migrator.js +0 -104
  111. package/dist/src/functions/db/sqlite-codegen.d.ts +0 -45
  112. package/dist/src/functions/db/sqlite-codegen.js +0 -294
  113. /package/dist/src/functions/db/{seed.d.ts → sqlite/seed.d.ts} +0 -0
  114. /package/dist/src/functions/db/{seed.js → sqlite/seed.js} +0 -0
  115. /package/dist/src/functions/db/{sqlite-kysely.d.ts → sqlite/sqlite-kysely.d.ts} +0 -0
  116. /package/dist/src/functions/db/{sqlite-kysely.js → sqlite/sqlite-kysely.js} +0 -0
  117. /package/dist/src/functions/db/{sqlite-runtime-bun.d.ts → sqlite/sqlite-runtime-bun.d.ts} +0 -0
  118. /package/dist/src/functions/db/{sqlite-runtime-bun.js → sqlite/sqlite-runtime-bun.js} +0 -0
  119. /package/dist/src/functions/db/{sqlite-runtime-node.d.ts → sqlite/sqlite-runtime-node.d.ts} +0 -0
  120. /package/dist/src/functions/db/{sqlite-runtime-node.js → sqlite/sqlite-runtime-node.js} +0 -0
  121. /package/dist/src/functions/db/{sqlite-runtime.d.ts → sqlite/sqlite-runtime.d.ts} +0 -0
  122. /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 { resolveLocalDb, createKysely } from '../db/local-db.js';
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 resolvedLocalDb = resolveLocalDb(userConfig.dev?.db ?? true, config.rootDir, config.outDir, config.runtimeDir);
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.dev
125
- ?.content
129
+ const localContentConfig = userConfig.content
126
130
  ? {
127
- localFileUploadPath: join(resolvedRuntimeDir, 'content'),
128
- uploadUrlPrefix: '/upload',
129
- assetUrlPrefix: '/assets',
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
- ...(userConfig.dev.content !== true ? userConfig.dev.content : {}),
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.getSecretJSON<${pascalName}Secrets>('${screamingName}_CREDENTIALS')
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.setSecretJSON('${screamingName}_CREDENTIALS', { ... })
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
+ }