@pikku/cli 0.12.34 → 0.12.35
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-BOM3RFeu.js +233 -0
- package/console-app/index.html +1 -1
- 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 +1 -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.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 +180 -180
- package/dist/.pikku/function/pikku-functions.gen.js +1 -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 +4 -4
- 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 +13 -13
- 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/functions/db/annotation-parser.d.ts +27 -16
- package/dist/src/functions/db/annotation-parser.js +50 -110
- package/dist/src/functions/db/coercion-plugin.d.ts +1 -1
- package/dist/src/functions/db/coercion-plugin.js +4 -0
- package/dist/src/functions/db/db-codegen.d.ts +13 -1
- package/dist/src/functions/db/db-codegen.js +142 -31
- package/dist/src/functions/db/db-introspector.d.ts +6 -0
- package/dist/src/functions/db/local-db.js +96 -78
- package/dist/src/functions/db/postgres/postgres-introspector.js +2 -0
- package/dist/src/functions/db/zod-codegen.d.ts +38 -0
- package/dist/src/functions/db/zod-codegen.js +144 -32
- package/dist/src/scaffold/rpc-remote.gen.js +1 -1
- package/package.json +1 -1
- package/console-app/assets/index-DsW0T00Z.js +0 -233
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { loadAnnotations,
|
|
3
|
+
import { loadAnnotations, nameSuggestsKind, } from './annotation-parser.js';
|
|
4
|
+
import { ZOD_FORMATS } from './zod-codegen.js';
|
|
5
|
+
/**
|
|
6
|
+
* The column kind implied by the *real* DB type, dialect-aware. Postgres has
|
|
7
|
+
* native temporal/boolean types so we can trust them; SQLite stores dates as
|
|
8
|
+
* TEXT and booleans as INTEGER, so its declared types are indeterminate and we
|
|
9
|
+
* derive nothing (return null). Used both to auto-type Postgres dates and to
|
|
10
|
+
* detect name↔type contradictions for warnings.
|
|
11
|
+
*/
|
|
12
|
+
function realKind(dialect, sqlType) {
|
|
13
|
+
if (dialect !== 'postgres')
|
|
14
|
+
return null;
|
|
15
|
+
const u = sqlType.toUpperCase();
|
|
16
|
+
if (u.includes('TIMESTAMP') || u === 'DATE')
|
|
17
|
+
return 'date';
|
|
18
|
+
if (u === 'BOOLEAN' || u === 'BOOL')
|
|
19
|
+
return 'bool';
|
|
20
|
+
if (u === 'UUID')
|
|
21
|
+
return 'uuid';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
4
24
|
// ─── Name helpers ─────────────────────────────────────────────────────────────
|
|
5
25
|
function snakeToPascal(name) {
|
|
6
26
|
return name
|
|
@@ -11,6 +31,10 @@ function snakeToPascal(name) {
|
|
|
11
31
|
function snakeToCamel(name) {
|
|
12
32
|
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
13
33
|
}
|
|
34
|
+
/** Escape a value for embedding inside a single-quoted TS string literal. */
|
|
35
|
+
function escapeTsString(value) {
|
|
36
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
37
|
+
}
|
|
14
38
|
// ─── Type mapping ─────────────────────────────────────────────────────────────
|
|
15
39
|
function mapType(sqlType) {
|
|
16
40
|
const upper = sqlType.toUpperCase();
|
|
@@ -40,21 +64,30 @@ function mapType(sqlType) {
|
|
|
40
64
|
}
|
|
41
65
|
// ─── Type expression ─────────────────────────────────────────────────────────
|
|
42
66
|
function selectBase(annotation, col) {
|
|
67
|
+
// An explicit `tsType` is a general type override and wins over everything.
|
|
68
|
+
if (annotation?.tsType)
|
|
69
|
+
return annotation.tsType;
|
|
43
70
|
if (annotation?.kind === 'bool')
|
|
44
71
|
return 'boolean';
|
|
45
72
|
if (annotation?.kind === 'date')
|
|
46
73
|
return 'Date';
|
|
74
|
+
if (annotation?.kind === 'uuid')
|
|
75
|
+
return 'Uuid';
|
|
47
76
|
if (annotation?.kind === 'json')
|
|
48
|
-
return
|
|
77
|
+
return 'unknown';
|
|
49
78
|
return mapType(col.type);
|
|
50
79
|
}
|
|
51
80
|
function insertBase(annotation, col) {
|
|
81
|
+
if (annotation?.tsType)
|
|
82
|
+
return annotation.tsType;
|
|
52
83
|
if (annotation?.kind === 'bool')
|
|
53
84
|
return 'boolean | number';
|
|
54
85
|
if (annotation?.kind === 'date')
|
|
55
86
|
return 'Date | string';
|
|
87
|
+
if (annotation?.kind === 'uuid')
|
|
88
|
+
return 'Uuid';
|
|
56
89
|
if (annotation?.kind === 'json')
|
|
57
|
-
return
|
|
90
|
+
return 'unknown';
|
|
58
91
|
return mapType(col.type);
|
|
59
92
|
}
|
|
60
93
|
function columnTypeExpression(col, annotation, classification) {
|
|
@@ -73,14 +106,17 @@ function columnTypeExpression(col, annotation, classification) {
|
|
|
73
106
|
const rw = nullable ? 'Date | string | null' : 'Date | string';
|
|
74
107
|
return wrap(`ColumnType<${base}, ${rw}, ${rw}>`);
|
|
75
108
|
}
|
|
109
|
+
if (annotation?.kind === 'uuid') {
|
|
110
|
+
return wrap(nullable ? 'Uuid | null' : 'Uuid');
|
|
111
|
+
}
|
|
112
|
+
if (annotation?.tsType) {
|
|
113
|
+
const base = nullable
|
|
114
|
+
? `${annotation.tsType} | null`
|
|
115
|
+
: annotation.tsType;
|
|
116
|
+
return wrap(base);
|
|
117
|
+
}
|
|
76
118
|
if (annotation?.kind === 'json') {
|
|
77
|
-
const base =
|
|
78
|
-
? nullable
|
|
79
|
-
? `${annotation.tsType} | null`
|
|
80
|
-
: annotation.tsType
|
|
81
|
-
: nullable
|
|
82
|
-
? 'unknown | null'
|
|
83
|
-
: 'unknown';
|
|
119
|
+
const base = nullable ? 'unknown | null' : 'unknown';
|
|
84
120
|
return wrap(base);
|
|
85
121
|
}
|
|
86
122
|
const base = mapType(col.type);
|
|
@@ -109,18 +145,63 @@ function bareTableName(name) {
|
|
|
109
145
|
const dot = name.indexOf('.');
|
|
110
146
|
return dot >= 0 ? name.slice(dot + 1) : name;
|
|
111
147
|
}
|
|
112
|
-
function emitInterface(table, camelCase, explicitAnnotations) {
|
|
148
|
+
function emitInterface(table, camelCase, explicitAnnotations, dialect, enumByName, formatHints, warnings) {
|
|
113
149
|
const ifaceName = snakeToPascal(table.name);
|
|
114
|
-
const
|
|
150
|
+
const bare = bareTableName(table.name);
|
|
151
|
+
const tableCols = explicitAnnotations[bare] ?? {};
|
|
115
152
|
const fields = table.columns
|
|
116
153
|
.map((col) => {
|
|
117
154
|
const fieldName = camelCase ? snakeToCamel(col.name) : col.name;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
155
|
+
const ann = tableCols[col.name] ?? null;
|
|
156
|
+
// Effective typing kind: explicit annotation wins; otherwise, on Postgres
|
|
157
|
+
// the *real* column type tells us (a timestamp genuinely is a Date). On
|
|
158
|
+
// SQLite there is no native date storage (dates are TEXT), so nothing is
|
|
159
|
+
// derived — `string` unless explicitly `kind: 'date'`.
|
|
160
|
+
// On Postgres, real `timestamp`/`uuid` types carry through automatically
|
|
161
|
+
// (no annotation needed); SQLite has neither native type so derives nothing.
|
|
162
|
+
const real = realKind(dialect, col.type);
|
|
163
|
+
const derived = !ann?.tsType && (real === 'date' || real === 'uuid') ? real : undefined;
|
|
164
|
+
const typingKind = ann?.kind ?? derived;
|
|
165
|
+
// Warn (don't force) only on a genuine contradiction the real type can
|
|
166
|
+
// prove: a column NAMED like a date/bool whose actual type disagrees
|
|
167
|
+
// (e.g. `created_at` that is really a boolean in Postgres). On SQLite the
|
|
168
|
+
// type is indeterminate, so there is nothing to contradict — no warning.
|
|
169
|
+
if (!ann?.kind && !ann?.tsType) {
|
|
170
|
+
const suggested = nameSuggestsKind(col.name);
|
|
171
|
+
if (suggested && real && suggested !== real) {
|
|
172
|
+
warnings.push(`Column "${bare}.${col.name}" is named like a ${suggested} but its DB type ` +
|
|
173
|
+
`is ${col.type} (${real}). If intentional, set its kind in db/annotations.ts.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// A Postgres enum column reports `type` as 'USER-DEFINED'; its real values
|
|
177
|
+
// come from `udtName`. Type it as a union of string literals — only when
|
|
178
|
+
// no explicit `tsType`/`kind` overrides it. This reuses the `tsType`
|
|
179
|
+
// plumbing, so it flows through both the public and classified branches.
|
|
180
|
+
const enumValues = col.udtName ? enumByName.get(col.udtName) : undefined;
|
|
181
|
+
const enumUnion = enumValues && enumValues.length > 0
|
|
182
|
+
? enumValues.map((v) => `'${escapeTsString(v)}'`).join(' | ')
|
|
183
|
+
: null;
|
|
184
|
+
const effectiveTsType = ann?.tsType ?? (typingKind ? undefined : (enumUnion ?? undefined));
|
|
185
|
+
const typeAnn = typingKind || effectiveTsType
|
|
186
|
+
? { kind: typingKind, tsType: effectiveTsType }
|
|
187
|
+
: null;
|
|
188
|
+
// A `format` validator refines the zod schema only and keeps the TS type
|
|
189
|
+
// as `string`. It therefore applies only when the resolved select base is
|
|
190
|
+
// plain `string`; on anything else (Date/Uuid/boolean/enum/unknown via
|
|
191
|
+
// kind/tsType) it would contradict the type, so warn and skip.
|
|
192
|
+
if (ann?.format) {
|
|
193
|
+
const base = selectBase(typeAnn, col);
|
|
194
|
+
if (base === 'string') {
|
|
195
|
+
;
|
|
196
|
+
(formatHints[ifaceName] ??= {})[fieldName] = ann.format;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
warnings.push(`Column "${bare}.${col.name}": format '${ann.format}' ignored — its ` +
|
|
200
|
+
`resolved type is ${base}, not string. Remove the conflicting kind/tsType.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const classification = ann?.classification ?? 'private';
|
|
204
|
+
const type = columnTypeExpression(col, typeAnn, classification);
|
|
124
205
|
const safeName = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)
|
|
125
206
|
? fieldName
|
|
126
207
|
: JSON.stringify(fieldName);
|
|
@@ -168,7 +249,21 @@ function emitManifest(tables, explicitAnnotations) {
|
|
|
168
249
|
* flag added or removed columns.
|
|
169
250
|
*/
|
|
170
251
|
function emitClassificationMap(tables) {
|
|
171
|
-
const colEntry =
|
|
252
|
+
const colEntry = [
|
|
253
|
+
` /** Privacy level. Defaults to 'private' when omitted. */`,
|
|
254
|
+
` security?: 'public' | 'private' | 'pii' | 'secret' | 'encrypted'`,
|
|
255
|
+
` /** Anonymize strategy used by \`pikku db anonymize\`. */`,
|
|
256
|
+
` classification?: 'fake:email' | 'fake:name' | 'hash' | 'keep'`,
|
|
257
|
+
` /** Column kind override for codegen coercion + typing. */`,
|
|
258
|
+
` kind?: 'date' | 'bool' | 'json' | 'uuid'`,
|
|
259
|
+
` /** TypeScript type override, e.g. \`string[]\` or \`MyJson\`. Wins over \`kind\`. */`,
|
|
260
|
+
` tsType?: string`,
|
|
261
|
+
` /** Zod string-format validator (keeps the TS type as \`string\`). */`,
|
|
262
|
+
` format?: ${Object.keys(ZOD_FORMATS)
|
|
263
|
+
.map((f) => `'${f}'`)
|
|
264
|
+
.join(' | ')}`,
|
|
265
|
+
` description?: string`,
|
|
266
|
+
].join('\n');
|
|
172
267
|
// Group tables by schema (for postgres schema.table names)
|
|
173
268
|
const schemaMap = new Map();
|
|
174
269
|
for (const table of tables) {
|
|
@@ -225,20 +320,32 @@ function emitClassificationMap(tables) {
|
|
|
225
320
|
*/
|
|
226
321
|
export async function generateSchemaTypes(introspector, options) {
|
|
227
322
|
const camelCase = options.camelCase ?? true;
|
|
323
|
+
const dialect = options.dialect ?? 'sqlite';
|
|
228
324
|
const tableNames = await introspector.listTables();
|
|
229
325
|
const tables = await Promise.all(tableNames.map(async (name) => ({
|
|
230
326
|
name,
|
|
231
327
|
columns: await introspector.getColumns(name),
|
|
232
328
|
})));
|
|
233
329
|
const explicitAnnotations = options.rootDir
|
|
234
|
-
? loadAnnotations(options.rootDir
|
|
235
|
-
:
|
|
236
|
-
|
|
237
|
-
|
|
330
|
+
? loadAnnotations(options.rootDir)
|
|
331
|
+
: {};
|
|
332
|
+
// Enum types — used to auto-type enum columns as string-literal unions. Keyed
|
|
333
|
+
// by both bare and schema-qualified name; `udtName` is bare so the bare key
|
|
334
|
+
// resolves it (a one-line changeset note flags the cross-schema-name caveat).
|
|
335
|
+
const enums = await introspector.listEnums();
|
|
336
|
+
const enumByName = new Map();
|
|
337
|
+
for (const e of enums) {
|
|
338
|
+
enumByName.set(e.name, e.values);
|
|
339
|
+
enumByName.set(`${e.schema}.${e.name}`, e.values);
|
|
340
|
+
}
|
|
238
341
|
// ── schema.d.ts ─────────────────────────────────────────────────────────────
|
|
342
|
+
const warnings = [];
|
|
343
|
+
const zodFormats = {};
|
|
239
344
|
const interfaces = tables
|
|
240
|
-
.map((t) => emitInterface(t, camelCase, explicitAnnotations))
|
|
345
|
+
.map((t) => emitInterface(t, camelCase, explicitAnnotations, dialect, enumByName, zodFormats, warnings))
|
|
241
346
|
.join('\n\n');
|
|
347
|
+
for (const w of warnings)
|
|
348
|
+
console.warn(`[pikku db] ${w}`);
|
|
242
349
|
const dbEntries = tables
|
|
243
350
|
.map((t) => {
|
|
244
351
|
const tableKey = camelCase ? snakeToCamel(t.name) : t.name;
|
|
@@ -265,6 +372,9 @@ export async function generateSchemaTypes(introspector, options) {
|
|
|
265
372
|
`export type Private<T> = T & { readonly __classification__?: 'private' }`,
|
|
266
373
|
`export type Pii<T> = T & { readonly __classification__?: 'pii' }`,
|
|
267
374
|
`export type Secret<T> = T & { readonly __classification__?: 'secret' }`,
|
|
375
|
+
// Transparent alias (structurally a string, so plain strings stay
|
|
376
|
+
// interchangeable) — its name lets the zod codegen emit `z.uuid()`.
|
|
377
|
+
`export type Uuid = string`,
|
|
268
378
|
``,
|
|
269
379
|
interfaces,
|
|
270
380
|
``,
|
|
@@ -278,9 +388,11 @@ export async function generateSchemaTypes(introspector, options) {
|
|
|
278
388
|
for (const table of tables) {
|
|
279
389
|
const tableCols = explicitAnnotations[bareTableName(table.name)] ?? {};
|
|
280
390
|
for (const col of table.columns) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
391
|
+
// Coercion is driven only by an explicit `kind` in db/annotations.ts —
|
|
392
|
+
// no name inference. An unannotated `*_at` column is not coerced. `uuid`
|
|
393
|
+
// is a string in both dialects, so it needs no runtime coercion.
|
|
394
|
+
const kind = tableCols[col.name]?.kind;
|
|
395
|
+
if (kind && kind !== 'uuid') {
|
|
284
396
|
if (!coercionMap[table.name])
|
|
285
397
|
coercionMap[table.name] = {};
|
|
286
398
|
coercionMap[table.name][col.name] = kind;
|
|
@@ -315,10 +427,7 @@ export async function generateSchemaTypes(introspector, options) {
|
|
|
315
427
|
// ── pikku-db-schema.gen.json ─────────────────────────────────────────────────
|
|
316
428
|
let schemaJsonBody = null;
|
|
317
429
|
if (options.schemaJsonFile) {
|
|
318
|
-
const
|
|
319
|
-
Promise.all(tableNames.map((name) => introspector.getForeignKeys(name))),
|
|
320
|
-
introspector.listEnums(),
|
|
321
|
-
]);
|
|
430
|
+
const fkResults = await Promise.all(tableNames.map((name) => introspector.getForeignKeys(name)));
|
|
322
431
|
const fkMap = new Map(tableNames.map((name, i) => [name, fkResults[i]]));
|
|
323
432
|
const jsonTables = tables.map((t) => ({
|
|
324
433
|
name: t.name,
|
|
@@ -423,5 +532,7 @@ export async function generateSchemaTypes(introspector, options) {
|
|
|
423
532
|
manifestWritten: manifestChanged,
|
|
424
533
|
classificationMapWritten: classificationMapChanged,
|
|
425
534
|
tables: tables.map((t) => t.name),
|
|
535
|
+
warnings,
|
|
536
|
+
zodFormats,
|
|
426
537
|
};
|
|
427
538
|
}
|
|
@@ -7,6 +7,12 @@ export interface ColumnInfo {
|
|
|
7
7
|
defaultValue: string | null;
|
|
8
8
|
/** True for virtual or stored generated columns — these are read-only and never inserted. */
|
|
9
9
|
generated?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Underlying DB type name (Postgres `udt_name`). For an enum column `type` is
|
|
12
|
+
* the generic `'USER-DEFINED'`, while `udtName` holds the actual enum type
|
|
13
|
+
* name used to resolve its values. Undefined on SQLite (no native enums).
|
|
14
|
+
*/
|
|
15
|
+
udtName?: string;
|
|
10
16
|
}
|
|
11
17
|
export interface ForeignKeyInfo {
|
|
12
18
|
column: string;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, } from 'node:fs';
|
|
2
2
|
import { resolve, isAbsolute, relative, dirname, join } from 'node:path';
|
|
3
|
-
import { execSync } from 'node:child_process';
|
|
4
3
|
import { createRequire } from 'node:module';
|
|
5
|
-
import {
|
|
4
|
+
import { runInNewContext } from 'node:vm';
|
|
5
|
+
import { transformSync } from 'esbuild';
|
|
6
6
|
import { migrate } from './db-migrator.js';
|
|
7
7
|
import { generateSchemaTypes } from './db-codegen.js';
|
|
8
8
|
import { generateZodTypes } from './zod-codegen.js';
|
|
@@ -42,7 +42,11 @@ export function resolveDb(userConfig, rootDir, outDir, runtimeDir) {
|
|
|
42
42
|
classificationMapFile: join(outDir, 'db', 'classification-map.gen.d.ts'),
|
|
43
43
|
schemaJsonFile: join(outDir, 'db', 'pikku-db-schema.gen.json'),
|
|
44
44
|
classificationsFile: join(rootDir, 'db', 'annotations.ts'),
|
|
45
|
-
|
|
45
|
+
// Compiled sidecar lives beside the authored annotations.ts in db/ — this is
|
|
46
|
+
// where both consumers read it: the codegen's loadAnnotations() and the
|
|
47
|
+
// pikku-console addon (db/annotations.gen.json). Writing it into outDir
|
|
48
|
+
// (.pikku) would leave both readers looking at a file that never appears.
|
|
49
|
+
classificationsGenJsonFile: join(rootDir, 'db', 'annotations.gen.json'),
|
|
46
50
|
zodFile: join(outDir, 'db', 'zod.gen.ts'),
|
|
47
51
|
camelCase: true,
|
|
48
52
|
});
|
|
@@ -87,6 +91,9 @@ function resolveAgainst(root, p) {
|
|
|
87
91
|
export async function migrateAndCodegen(resolved) {
|
|
88
92
|
let migrateResult;
|
|
89
93
|
let codegenResult;
|
|
94
|
+
// Compile any authored db/annotations.ts → sidecar BEFORE codegen so edits
|
|
95
|
+
// reflect in a single `db migrate` (codegen reads the sidecar).
|
|
96
|
+
compileClassifications(resolved.classificationsFile, resolved.classificationsGenJsonFile);
|
|
90
97
|
if (resolved.dialect === 'sqlite') {
|
|
91
98
|
mkdirSync(dirname(resolved.dbFile), { recursive: true });
|
|
92
99
|
const runtime = await loadSqliteRuntime();
|
|
@@ -103,7 +110,7 @@ export async function migrateAndCodegen(resolved) {
|
|
|
103
110
|
schemaJsonFile: resolved.schemaJsonFile,
|
|
104
111
|
camelCase: resolved.camelCase,
|
|
105
112
|
rootDir: resolved.rootDir,
|
|
106
|
-
|
|
113
|
+
dialect: 'sqlite',
|
|
107
114
|
});
|
|
108
115
|
}
|
|
109
116
|
finally {
|
|
@@ -129,7 +136,7 @@ export async function migrateAndCodegen(resolved) {
|
|
|
129
136
|
schemaJsonFile: resolved.schemaJsonFile,
|
|
130
137
|
camelCase: resolved.camelCase,
|
|
131
138
|
rootDir: resolved.rootDir,
|
|
132
|
-
|
|
139
|
+
dialect: 'postgres',
|
|
133
140
|
});
|
|
134
141
|
}
|
|
135
142
|
finally {
|
|
@@ -143,9 +150,13 @@ export async function migrateAndCodegen(resolved) {
|
|
|
143
150
|
const zodResult = generateZodTypes({
|
|
144
151
|
schemaFile: resolved.schemaFile,
|
|
145
152
|
outFile: resolved.zodFile,
|
|
153
|
+
formats: codegenResult.zodFormats,
|
|
146
154
|
});
|
|
147
155
|
// ── Classifications step ──────────────────────────────────────────────────
|
|
148
|
-
|
|
156
|
+
// Scaffold the authored file if missing (needs the table list), then compile
|
|
157
|
+
// it to the sidecar so a freshly-scaffolded file is captured too.
|
|
158
|
+
const scaffolded = scaffoldClassificationsFile(resolved.classificationsFile, codegenResult.tables);
|
|
159
|
+
const jsonWritten = compileClassifications(resolved.classificationsFile, resolved.classificationsGenJsonFile);
|
|
149
160
|
return {
|
|
150
161
|
migrate: migrateResult,
|
|
151
162
|
codegen: codegenResult,
|
|
@@ -179,86 +190,93 @@ export function reset(resolved, rootDir) {
|
|
|
179
190
|
}
|
|
180
191
|
// ── Classification sync ───────────────────────────────────────────────────────
|
|
181
192
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* table defaulting to `private` so the developer has a starting point.
|
|
193
|
+
* If `db/annotations.ts` doesn't exist yet, write a scaffold listing every
|
|
194
|
+
* table/column so the developer has a typed starting point. Every field of
|
|
195
|
+
* `ColumnEntry` is optional, so the empty-per-table scaffold is valid and means
|
|
196
|
+
* "everything default `private`". Returns whether a scaffold was written.
|
|
187
197
|
*/
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
if (schema)
|
|
217
|
-
bodyLines.push(` },`);
|
|
198
|
+
function scaffoldClassificationsFile(classificationsFile, tableNames) {
|
|
199
|
+
if (existsSync(classificationsFile))
|
|
200
|
+
return false;
|
|
201
|
+
const relMap = join(dirname(classificationsFile), '..', '.pikku', 'db', 'classification-map.gen.d.ts');
|
|
202
|
+
const relMapPosix = relMap.replace(/\\/g, '/');
|
|
203
|
+
const groups = new Map();
|
|
204
|
+
for (const name of tableNames) {
|
|
205
|
+
const dot = name.indexOf('.');
|
|
206
|
+
const schema = dot >= 0 ? name.slice(0, dot) : '';
|
|
207
|
+
const table = dot >= 0 ? name.slice(dot + 1) : name;
|
|
208
|
+
if (!groups.has(schema))
|
|
209
|
+
groups.set(schema, []);
|
|
210
|
+
groups.get(schema).push(table);
|
|
211
|
+
}
|
|
212
|
+
const bodyLines = [
|
|
213
|
+
`import type { DbClassificationMap } from '${relMapPosix}'`,
|
|
214
|
+
``,
|
|
215
|
+
`export const classifications = {`,
|
|
216
|
+
];
|
|
217
|
+
for (const [schema, tables] of groups) {
|
|
218
|
+
if (schema)
|
|
219
|
+
bodyLines.push(` ${JSON.stringify(schema)}: {`);
|
|
220
|
+
for (const table of tables) {
|
|
221
|
+
bodyLines.push(schema
|
|
222
|
+
? ` ${JSON.stringify(table)}: {`
|
|
223
|
+
: ` ${JSON.stringify(table)}: {`);
|
|
224
|
+
bodyLines.push(schema ? ` },` : ` },`);
|
|
218
225
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
writeFileSync(classificationsFile, bodyLines.join('\n'), 'utf8');
|
|
222
|
-
scaffolded = true;
|
|
226
|
+
if (schema)
|
|
227
|
+
bodyLines.push(` },`);
|
|
223
228
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
bodyLines.push(`} satisfies DbClassificationMap`, ``);
|
|
230
|
+
mkdirSync(dirname(classificationsFile), { recursive: true });
|
|
231
|
+
writeFileSync(classificationsFile, bodyLines.join('\n'), 'utf8');
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Compile `db/annotations.ts` into the `annotations.gen.json` sidecar that the
|
|
236
|
+
* codegen and the pikku-console addon read. No-op if the authored file doesn't
|
|
237
|
+
* exist (nothing to compile yet). Returns whether the sidecar changed on disk.
|
|
238
|
+
*
|
|
239
|
+
* Uses esbuild (a CLI dependency) to transpile the TS in-process and a `vm`
|
|
240
|
+
* sandbox to evaluate it — no subprocess and no tsx. The previous `node --import
|
|
241
|
+
* tsx/esm` subprocess silently fails on Node ≥ 23 (ERR_REQUIRE_CYCLE_MODULE),
|
|
242
|
+
* which is why this sidecar never materialised before.
|
|
243
|
+
*
|
|
244
|
+
* Run BEFORE codegen so authored edits reflect in a single `db migrate` (and
|
|
245
|
+
* again after, to capture a freshly-scaffolded file).
|
|
246
|
+
*/
|
|
247
|
+
function compileClassifications(classificationsFile, genJsonFile) {
|
|
248
|
+
if (!existsSync(classificationsFile))
|
|
249
|
+
return false;
|
|
250
|
+
let value;
|
|
228
251
|
try {
|
|
229
|
-
|
|
252
|
+
const src = readFileSync(classificationsFile, 'utf8');
|
|
253
|
+
const { code } = transformSync(src, {
|
|
254
|
+
loader: 'ts',
|
|
255
|
+
format: 'cjs',
|
|
256
|
+
});
|
|
257
|
+
const mod = { exports: {} };
|
|
258
|
+
runInNewContext(code, {
|
|
259
|
+
module: mod,
|
|
260
|
+
exports: mod.exports,
|
|
261
|
+
require: createRequire(classificationsFile),
|
|
262
|
+
});
|
|
263
|
+
value = Object.values(mod.exports)[0];
|
|
230
264
|
}
|
|
231
265
|
catch {
|
|
232
|
-
|
|
266
|
+
return false; // syntax/transform error — skip JSON emit
|
|
233
267
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
encoding: 'utf8',
|
|
245
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
246
|
-
});
|
|
247
|
-
const existing = existsSync(genJsonFile)
|
|
248
|
-
? readFileSync(genJsonFile, 'utf8')
|
|
249
|
-
: null;
|
|
250
|
-
const next = JSON.stringify(JSON.parse(json), null, 2) + '\n';
|
|
251
|
-
if (existing !== next) {
|
|
252
|
-
mkdirSync(dirname(genJsonFile), { recursive: true });
|
|
253
|
-
writeFileSync(genJsonFile, next, 'utf8');
|
|
254
|
-
jsonWritten = true;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
catch {
|
|
258
|
-
// annotations file has syntax errors — skip JSON emit
|
|
259
|
-
}
|
|
268
|
+
if (value === undefined)
|
|
269
|
+
return false;
|
|
270
|
+
const next = JSON.stringify(value, null, 2) + '\n';
|
|
271
|
+
const existing = existsSync(genJsonFile)
|
|
272
|
+
? readFileSync(genJsonFile, 'utf8')
|
|
273
|
+
: null;
|
|
274
|
+
if (existing !== next) {
|
|
275
|
+
mkdirSync(dirname(genJsonFile), { recursive: true });
|
|
276
|
+
writeFileSync(genJsonFile, next, 'utf8');
|
|
277
|
+
return true;
|
|
260
278
|
}
|
|
261
|
-
return
|
|
279
|
+
return false;
|
|
262
280
|
}
|
|
263
281
|
export async function createKysely(resolved) {
|
|
264
282
|
mkdirSync(dirname(resolved.dbFile), { recursive: true });
|
|
@@ -33,6 +33,7 @@ export class PostgresIntrospector {
|
|
|
33
33
|
const result = await this.client.query(`SELECT
|
|
34
34
|
c.column_name,
|
|
35
35
|
c.data_type,
|
|
36
|
+
c.udt_name,
|
|
36
37
|
c.is_nullable,
|
|
37
38
|
c.column_default,
|
|
38
39
|
c.is_generated,
|
|
@@ -54,6 +55,7 @@ export class PostgresIntrospector {
|
|
|
54
55
|
return result.rows.map((r) => ({
|
|
55
56
|
name: r.column_name,
|
|
56
57
|
type: r.data_type,
|
|
58
|
+
udtName: r.udt_name,
|
|
57
59
|
notNull: r.is_nullable === 'NO',
|
|
58
60
|
pk: Boolean(r.is_pk),
|
|
59
61
|
defaultValue: r.column_default,
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical map of `format` tokens (authored in `db/annotations.ts`) to the zod
|
|
3
|
+
* expression they emit. These are all *string* refinements — they do not change
|
|
4
|
+
* the column's TypeScript type (it stays `string`), only the runtime validator.
|
|
5
|
+
* This is the single source of truth: `annotation-parser` imports it to validate
|
|
6
|
+
* authored values and `db-codegen` imports the key list to emit the `format`
|
|
7
|
+
* union in `ColumnEntry`. All confirmed present in zod 4.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ZOD_FORMATS: {
|
|
10
|
+
readonly email: "z.email()";
|
|
11
|
+
readonly url: "z.url()";
|
|
12
|
+
readonly emoji: "z.emoji()";
|
|
13
|
+
readonly e164: "z.e164()";
|
|
14
|
+
readonly jwt: "z.jwt()";
|
|
15
|
+
readonly cuid: "z.cuid()";
|
|
16
|
+
readonly cuid2: "z.cuid2()";
|
|
17
|
+
readonly ulid: "z.ulid()";
|
|
18
|
+
readonly nanoid: "z.nanoid()";
|
|
19
|
+
readonly base64: "z.base64()";
|
|
20
|
+
readonly base64url: "z.base64url()";
|
|
21
|
+
readonly ipv4: "z.ipv4()";
|
|
22
|
+
readonly ipv6: "z.ipv6()";
|
|
23
|
+
readonly cidrv4: "z.cidrv4()";
|
|
24
|
+
readonly cidrv6: "z.cidrv6()";
|
|
25
|
+
readonly isoDate: "z.iso.date()";
|
|
26
|
+
readonly isoTime: "z.iso.time()";
|
|
27
|
+
readonly isoDatetime: "z.iso.datetime()";
|
|
28
|
+
readonly isoDuration: "z.iso.duration()";
|
|
29
|
+
};
|
|
30
|
+
export type ZodFormat = keyof typeof ZOD_FORMATS;
|
|
1
31
|
export interface ZodCodegenOptions {
|
|
2
32
|
schemaFile: string;
|
|
3
33
|
outFile: string;
|
|
34
|
+
/**
|
|
35
|
+
* Per-interface, per-field `format` overrides. Keyed by the *interface* name
|
|
36
|
+
* (PascalCase, as it appears in `schema.d.ts`) and the *field* name (camelCase),
|
|
37
|
+
* matching how `parseTables` reads the schema. Computed by `db-codegen` (which
|
|
38
|
+
* owns the snake→Pascal/camel name mapping) so this emitter stays a dumb
|
|
39
|
+
* string→zod translator.
|
|
40
|
+
*/
|
|
41
|
+
formats?: Record<string, Record<string, ZodFormat>>;
|
|
4
42
|
}
|
|
5
43
|
export interface ZodCodegenResult {
|
|
6
44
|
outFile: string;
|