@bezoar/kit 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,546 @@
1
+ import { basename, dirname, join, posix, resolve, win32 } from "node:path";
2
+ import { pglite } from "@bezoar/pglite";
3
+ import { PGlite } from "@electric-sql/pglite";
4
+ import { createServer } from "node:net";
5
+ import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
6
+ import { createGenerator } from "@ts-safeql/generate";
7
+ import postgres from "postgres";
8
+ import { record, within } from "objectively";
9
+ import { createHash } from "node:crypto";
10
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
11
+ import { IDENTIFIER, SCALARS } from "@bezoar/schema";
12
+ import { existsSync } from "node:fs";
13
+ import { createJiti } from "jiti";
14
+ const FILENAME = "bezoar.config.ts";
15
+ const MIGRATIONS = "src/migrations";
16
+ const SEEDS = "src/seeds";
17
+ const QUERIES = "src/queries";
18
+ const OUT_DIR = "bezoar";
19
+ const EXTENSION = ".sql";
20
+ const MIGRATION = /^(\d+)_(.+)$/;
21
+ const SCHEMA = "public";
22
+ const MANIFEST = ".bezoar.json";
23
+ const BANNER = "// Generated by @bezoar/kit. Do not edit by hand.";
24
+ const SILENT = /^\s*(--.*)?$/;
25
+ const HOST = "127.0.0.1";
26
+ const DATABASE = "postgres";
27
+ const CACHE = "bezoar";
28
+ var MissingConfigError = class extends Error {
29
+ path;
30
+ constructor(path) {
31
+ super(`@bezoar/kit: no config file at ${path}`);
32
+ this.name = "MissingConfigError";
33
+ this.path = path;
34
+ }
35
+ };
36
+ var MalformedConfigError = class extends Error {
37
+ path;
38
+ constructor(path) {
39
+ super(`@bezoar/kit: ${path} must default-export a config (defineConfig({ … }))`);
40
+ this.name = "MalformedConfigError";
41
+ this.path = path;
42
+ }
43
+ };
44
+ var InvalidConfigError = class extends Error {
45
+ issues;
46
+ constructor(issues) {
47
+ const lines = issues.map((issue) => ` ${issue}`).join("\n");
48
+ super(`@bezoar/kit: the config is invalid —\n${lines}`);
49
+ this.name = "InvalidConfigError";
50
+ this.issues = issues;
51
+ }
52
+ };
53
+ var InvalidSourceError = class extends Error {
54
+ path;
55
+ constructor(path, reason, options) {
56
+ super(`@bezoar/kit: ${path} ${reason}`, options);
57
+ this.name = "InvalidSourceError";
58
+ this.path = path;
59
+ }
60
+ };
61
+ var InvalidQueryError = class extends Error {
62
+ path;
63
+ constructor(path, reason, options) {
64
+ super(`@bezoar/kit: ${path} ${reason}`, options);
65
+ this.name = "InvalidQueryError";
66
+ this.path = path;
67
+ }
68
+ };
69
+ const reason = (error) => error instanceof Error ? error.message : String(error);
70
+ const apply = async (executor, sources) => {
71
+ for (const file of [...sources.migrations, ...sources.seeds]) try {
72
+ await executor.exec(file.sql);
73
+ } catch (cause) {
74
+ throw new InvalidSourceError(file.path, `failed: ${reason(cause)}`, { cause });
75
+ }
76
+ };
77
+ const ENUMS = `
78
+ SELECT t.typname AS name,
79
+ array_agg(e.enumlabel::text ORDER BY e.enumsortorder) AS labels
80
+ FROM pg_type t
81
+ JOIN pg_namespace n ON n.oid = t.typnamespace
82
+ JOIN pg_enum e ON e.enumtypid = t.oid
83
+ WHERE n.nspname = $1
84
+ GROUP BY t.typname
85
+ ORDER BY t.typname`;
86
+ const readEnums = async (executor) => {
87
+ const enums = {};
88
+ for (const row of (await executor.query(ENUMS, [SCHEMA])).rows) enums[String(row.name)] = Array.isArray(row.labels) ? row.labels.map(String) : [];
89
+ return enums;
90
+ };
91
+ const free = () => new Promise((resolve, reject) => {
92
+ const server = createServer();
93
+ server.listen(0, HOST, () => {
94
+ const address = server.address();
95
+ server.close(() => {
96
+ if (address === null || typeof address === "string") reject(/* @__PURE__ */ new Error("@bezoar/kit: no free port"));
97
+ else resolve(address.port);
98
+ });
99
+ });
100
+ server.on("error", reject);
101
+ });
102
+ const admitsNull = (target) => {
103
+ if (target.kind === "type") return target.type === "null";
104
+ if (target.kind === "union") return target.value.some(admitsNull);
105
+ return false;
106
+ };
107
+ const describeQueries = async (client, queries) => {
108
+ const described = /* @__PURE__ */ new Map();
109
+ if (queries.length === 0) return described;
110
+ const port = await free();
111
+ const server = new PGLiteSocketServer({
112
+ db: client,
113
+ port,
114
+ host: HOST
115
+ });
116
+ await server.start();
117
+ const sql = postgres({
118
+ host: HOST,
119
+ port,
120
+ database: DATABASE,
121
+ username: DATABASE,
122
+ max: 1,
123
+ onnotice: () => {}
124
+ });
125
+ const { generate } = createGenerator();
126
+ try {
127
+ for (const query of queries) {
128
+ const result = await generate({
129
+ sql,
130
+ query: {
131
+ text: query.sql,
132
+ sourcemaps: []
133
+ },
134
+ cacheKey: CACHE,
135
+ fieldTransform: void 0
136
+ });
137
+ if (result._tag === "Left") throw new InvalidQueryError(query.path, `failed: ${result.left.message}`, { cause: result.left });
138
+ const { stmt, output } = result.right;
139
+ const targets = new Map(output?.value ?? []);
140
+ described.set(query.name, {
141
+ params: stmt.types,
142
+ columns: stmt.columns.map((column) => {
143
+ const target = targets.get(column.name);
144
+ return {
145
+ name: column.name,
146
+ type: column.type,
147
+ nullable: target === void 0 || admitsNull(target)
148
+ };
149
+ })
150
+ });
151
+ }
152
+ } finally {
153
+ await sql.end({ timeout: 1 });
154
+ await server.stop();
155
+ }
156
+ return described;
157
+ };
158
+ const json = (value) => JSON.stringify(value, null, 2);
159
+ const literal = (value, depth = 0) => {
160
+ const pad = " ".repeat(depth);
161
+ const inner = " ".repeat(depth + 1);
162
+ if (Array.isArray(value)) {
163
+ if (value.length === 0) return "readonly []";
164
+ return `readonly [\n${value.map((item) => `${inner}${literal(item, depth + 1)},`).join("\n")}\n${pad}]`;
165
+ }
166
+ if (record(value)) return `{\n${Object.entries(value).map(([key, member]) => `${inner}readonly ${JSON.stringify(key)}: ${literal(member, depth + 1)};`).join("\n")}\n${pad}}`;
167
+ return JSON.stringify(value);
168
+ };
169
+ const pair = (name, module, declaration) => [{
170
+ path: `${name}.mjs`,
171
+ contents: `${[BANNER, ...module].join("\n")}\n`
172
+ }, {
173
+ path: `${name}.d.mts`,
174
+ contents: `${[BANNER, ...declaration].join("\n")}\n`
175
+ }];
176
+ const index = (contract) => pair("index", [
177
+ `export const contract = Object.freeze(${json(contract)});`,
178
+ "const known = new Set(Object.keys(contract.queries));",
179
+ `export const isQuery = (value) => typeof value === "string" && known.has(value);`
180
+ ], [
181
+ `export declare const contract: ${literal(contract)};`,
182
+ "export type Contract = typeof contract;",
183
+ "export type Query = keyof Contract[\"queries\"] & string;",
184
+ "export declare const isQuery: (value: unknown) => value is Query;"
185
+ ]);
186
+ const bundle = (sources) => {
187
+ const sql = {};
188
+ for (const query of sources.queries) sql[query.name] = query.sql;
189
+ return pair("queries", [`export const queries = Object.freeze(${json(sql)});`], ["import type { Query } from \"./index.mjs\";", "export declare const queries: Readonly<Record<Query, string>>;"]);
190
+ };
191
+ const migrations = (sources) => {
192
+ const entries = sources.migrations.map(({ version, name, sql, checksum }) => ({
193
+ version,
194
+ name,
195
+ sql,
196
+ checksum
197
+ }));
198
+ return pair("migrations", [`export const migrations = Object.freeze(${json(entries)});`], [
199
+ "export declare const migrations: readonly {",
200
+ " readonly version: string;",
201
+ " readonly name: string;",
202
+ " readonly sql: string;",
203
+ " readonly checksum: string;",
204
+ "}[];"
205
+ ]);
206
+ };
207
+ const seeds = (sources) => {
208
+ const entries = sources.seeds.map(({ name, sql }) => ({
209
+ name,
210
+ sql
211
+ }));
212
+ return pair("seeds", [`export const seeds = Object.freeze(${json(entries)});`], [
213
+ "export declare const seeds: readonly {",
214
+ " readonly name: string;",
215
+ " readonly sql: string;",
216
+ "}[];"
217
+ ]);
218
+ };
219
+ const emit = (sources, contract) => [
220
+ ...index(contract),
221
+ ...bundle(sources),
222
+ ...migrations(sources),
223
+ ...seeds(sources)
224
+ ];
225
+ const starts = (char) => char !== void 0 && /[A-Za-z_]/.test(char);
226
+ const continues = (char) => char !== void 0 && /[A-Za-z0-9_]/.test(char);
227
+ const quoted = (sql, start, escapes) => {
228
+ const quote = sql[start];
229
+ let index = start + 1;
230
+ while (index < sql.length) {
231
+ const char = sql[index];
232
+ if (escapes && char === "\\") {
233
+ index += 2;
234
+ continue;
235
+ }
236
+ if (char === quote) {
237
+ if (sql[index + 1] === quote) {
238
+ index += 2;
239
+ continue;
240
+ }
241
+ return index + 1;
242
+ }
243
+ index++;
244
+ }
245
+ throw new SyntaxError(`has a quote that does not close`);
246
+ };
247
+ const dollar = (sql, start) => {
248
+ const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(start));
249
+ if (!tag) return;
250
+ const close = sql.indexOf(tag[0], start + tag[0].length);
251
+ if (close === -1) throw new SyntaxError(`has a dollar quote that does not close`);
252
+ return close + tag[0].length;
253
+ };
254
+ const block = (sql, start) => {
255
+ let depth = 0;
256
+ let index = start;
257
+ while (index < sql.length) if (sql.startsWith("/*", index)) {
258
+ depth++;
259
+ index += 2;
260
+ } else if (sql.startsWith("*/", index)) {
261
+ depth--;
262
+ index += 2;
263
+ if (depth === 0) return index;
264
+ } else index++;
265
+ throw new SyntaxError(`has a comment that does not close`);
266
+ };
267
+ const rewrite = (sql) => {
268
+ const slots = [];
269
+ let text = "";
270
+ let index = 0;
271
+ const copy = (end) => {
272
+ text += sql.slice(index, end);
273
+ index = end;
274
+ };
275
+ while (index < sql.length) {
276
+ const char = sql[index];
277
+ if (char === "'") {
278
+ const before = sql[index - 1];
279
+ const escapes = (before === "E" || before === "e") && !continues(sql[index - 2]);
280
+ copy(quoted(sql, index, escapes));
281
+ } else if (char === "\"") copy(quoted(sql, index, false));
282
+ else if (char === "$") {
283
+ const end = dollar(sql, index);
284
+ if (end !== void 0) copy(end);
285
+ else if (/[0-9]/.test(sql[index + 1] ?? "")) throw new SyntaxError(`uses a $n parameter; name the slot instead`);
286
+ else copy(index + 1);
287
+ } else if (sql.startsWith("--", index)) {
288
+ const end = sql.indexOf("\n", index);
289
+ copy(end === -1 ? sql.length : end);
290
+ } else if (sql.startsWith("/*", index)) copy(block(sql, index));
291
+ else if (sql.startsWith("::", index)) copy(index + 2);
292
+ else if (char === ":" && starts(sql[index + 1])) {
293
+ let end = index + 1;
294
+ while (continues(sql[end])) end++;
295
+ const name = sql.slice(index + 1, end);
296
+ if (!slots.includes(name)) slots.push(name);
297
+ text += `$${slots.indexOf(name) + 1}`;
298
+ index = end;
299
+ } else copy(index + 1);
300
+ }
301
+ return {
302
+ text,
303
+ slots
304
+ };
305
+ };
306
+ const list$1 = async (directory) => {
307
+ let entries;
308
+ try {
309
+ entries = await readdir(directory, { recursive: true });
310
+ } catch (error) {
311
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
312
+ throw error;
313
+ }
314
+ return entries.map((entry) => entry.replaceAll("\\", "/")).filter((entry) => entry.endsWith(EXTENSION)).sort();
315
+ };
316
+ const nameOf = (path, file) => {
317
+ const segments = file.slice(0, -4).split("/");
318
+ for (const segment of segments) if (!IDENTIFIER.test(segment)) throw new InvalidQueryError(path, `has "${segment}" in its path; a query is named for its path, so each directory and the file name must be an identifier`);
319
+ return segments.join(".");
320
+ };
321
+ const queryOf = (path, name, text) => {
322
+ if (text.split("\n").every((line) => SILENT.test(line))) throw new InvalidQueryError(path, "has no SQL");
323
+ try {
324
+ const { text: sql, slots } = rewrite(text.trim().replace(/;\s*$/, ""));
325
+ return {
326
+ name,
327
+ sql,
328
+ slots,
329
+ path
330
+ };
331
+ } catch (error) {
332
+ if (error instanceof SyntaxError) throw new InvalidQueryError(path, error.message);
333
+ throw error;
334
+ }
335
+ };
336
+ const disjoint = (queries) => {
337
+ const names = new Set(queries.map((query) => query.name));
338
+ for (const query of queries) {
339
+ const segments = query.name.split(".");
340
+ for (let depth = 1; depth < segments.length; depth += 1) {
341
+ const group = segments.slice(0, depth).join(".");
342
+ if (names.has(group)) throw new InvalidQueryError(query.path, `is in the group ${group}, which is also the name of a query`);
343
+ }
344
+ }
345
+ };
346
+ const readQueries = async (root, directory) => {
347
+ const queries = [];
348
+ for (const file of await list$1(join(root, directory))) {
349
+ const path = posix.join(directory, file);
350
+ const name = nameOf(path, file);
351
+ const text = await readFile(join(root, path), "utf8");
352
+ queries.push(queryOf(path, name, text));
353
+ }
354
+ disjoint(queries);
355
+ return queries;
356
+ };
357
+ const list = async (directory) => {
358
+ let entries;
359
+ try {
360
+ entries = await readdir(directory);
361
+ } catch (error) {
362
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
363
+ throw error;
364
+ }
365
+ return entries.filter((entry) => entry.endsWith(EXTENSION)).sort();
366
+ };
367
+ const checksum = (text) => createHash("sha256").update(text).digest("hex");
368
+ const identity = (file) => {
369
+ const stem = basename(file, EXTENSION);
370
+ const match = MIGRATION.exec(stem);
371
+ if (!match) return {
372
+ version: stem,
373
+ name: stem
374
+ };
375
+ const [, version = stem, name = stem] = match;
376
+ return {
377
+ version,
378
+ name
379
+ };
380
+ };
381
+ const readMigrations = async (root, directory) => {
382
+ const migrations = [];
383
+ for (const file of await list(join(root, directory))) {
384
+ const path = posix.join(directory, file);
385
+ const sql = await readFile(join(root, path), "utf8");
386
+ migrations.push({
387
+ path,
388
+ ...identity(file),
389
+ sql,
390
+ checksum: checksum(sql)
391
+ });
392
+ }
393
+ return migrations;
394
+ };
395
+ const readSeeds = async (root, directory) => {
396
+ const seeds = [];
397
+ for (const file of await list(join(root, directory))) {
398
+ const path = posix.join(directory, file);
399
+ seeds.push({
400
+ path,
401
+ name: basename(file, EXTENSION),
402
+ sql: await readFile(join(root, path), "utf8")
403
+ });
404
+ }
405
+ return seeds;
406
+ };
407
+ const readSources = async (root, settings) => ({
408
+ migrations: await readMigrations(root, settings.migrations),
409
+ seeds: await readSeeds(root, settings.seeds),
410
+ queries: await readQueries(root, settings.queries)
411
+ });
412
+ const TYPES = `
413
+ SELECT t.oid::int AS oid, t.typname AS name, e.typname AS element
414
+ FROM pg_type t
415
+ LEFT JOIN pg_type e ON e.oid = t.typelem AND t.typname LIKE '\\_%'
416
+ WHERE t.oid = ANY($1::int[])`;
417
+ const readTypes = async (executor, described) => {
418
+ const oids = /* @__PURE__ */ new Set();
419
+ for (const { params, columns } of described.values()) {
420
+ for (const oid of params) oids.add(oid);
421
+ for (const column of columns) oids.add(column.type);
422
+ }
423
+ const names = /* @__PURE__ */ new Map();
424
+ if (oids.size === 0) return names;
425
+ const { rows } = await executor.query(TYPES, [[...oids]]);
426
+ for (const row of rows) {
427
+ const element = row.element === null ? void 0 : String(row.element);
428
+ names.set(Number(row.oid), element === void 0 ? String(row.name) : `${element}[]`);
429
+ }
430
+ return names;
431
+ };
432
+ const define = (queries, described, types, enums) => {
433
+ const statements = {};
434
+ for (const query of queries) {
435
+ const description = described.get(query.name);
436
+ if (description === void 0) throw new InvalidQueryError(query.path, "was not described");
437
+ const typeOf = (what, oid) => {
438
+ const name = types.get(oid) ?? String(oid);
439
+ const base = name.endsWith("[]") ? name.slice(0, -2) : name;
440
+ if (!within(SCALARS, base) && !(base in enums)) throw new InvalidQueryError(query.path, `${what} has the type ${name}, which the type table does not have`);
441
+ return name;
442
+ };
443
+ statements[query.name] = {
444
+ slots: query.slots.map((name, index) => ({
445
+ name,
446
+ type: typeOf(`the slot :${name}`, description.params[index] ?? 0),
447
+ nullable: false
448
+ })),
449
+ columns: description.columns.map((column) => ({
450
+ name: column.name,
451
+ type: typeOf(`the column ${column.name}`, column.type),
452
+ nullable: column.nullable
453
+ }))
454
+ };
455
+ }
456
+ return {
457
+ enums,
458
+ queries: statements
459
+ };
460
+ };
461
+ const normalize = (path) => posix.normalize(path.replaceAll("\\", "/")).replace(/\/$/, "");
462
+ const inside = (path) => {
463
+ if (posix.isAbsolute(path) || win32.isAbsolute(path)) return false;
464
+ return path !== "." && path !== ".." && !path.startsWith("../");
465
+ };
466
+ const validate = (config) => {
467
+ const issues = [];
468
+ const directory = (label, path, fallback) => {
469
+ if (path === void 0) return fallback;
470
+ if (typeof path !== "string" || !inside(normalize(path))) {
471
+ issues.push(`${label} must be a directory inside the project root`);
472
+ return fallback;
473
+ }
474
+ return normalize(path);
475
+ };
476
+ const settings = {
477
+ migrations: directory("migrations", config.migrations, MIGRATIONS),
478
+ seeds: directory("seeds", config.seeds, SEEDS),
479
+ queries: directory("queries", config.queries, QUERIES),
480
+ outDir: directory("outDir", config.outDir, OUT_DIR)
481
+ };
482
+ if (issues.length > 0) throw new InvalidConfigError(issues);
483
+ return settings;
484
+ };
485
+ const generate = async (config, root) => {
486
+ const settings = validate(config);
487
+ const sources = await readSources(root, settings);
488
+ const client = new PGlite();
489
+ const executor = pglite(client);
490
+ try {
491
+ await apply(executor, sources);
492
+ const enums = await readEnums(executor);
493
+ const described = await describeQueries(client, sources.queries);
494
+ const types = await readTypes(executor, described);
495
+ const contract = define(sources.queries, described, types, enums);
496
+ return {
497
+ outDir: settings.outDir,
498
+ files: emit(sources, contract),
499
+ sources,
500
+ enums,
501
+ contract
502
+ };
503
+ } finally {
504
+ await executor.close();
505
+ }
506
+ };
507
+ const loadConfig = async (path) => {
508
+ if (!existsSync(path)) throw new MissingConfigError(path);
509
+ const config = await createJiti(import.meta.url, { moduleCache: false }).import(path, { default: true });
510
+ if (!record(config)) throw new MalformedConfigError(path);
511
+ return config;
512
+ };
513
+ const readManifest = async (dir) => {
514
+ let files;
515
+ try {
516
+ const manifest = JSON.parse(await readFile(join(dir, MANIFEST), "utf8"));
517
+ if (record(manifest)) files = manifest.files;
518
+ } catch {
519
+ return [];
520
+ }
521
+ if (!Array.isArray(files)) return [];
522
+ return files.filter((path) => typeof path === "string" && inside(normalize(path)));
523
+ };
524
+ const writeManifest = async (dir, files) => {
525
+ await writeFile(join(dir, MANIFEST), `${JSON.stringify({ files }, null, 2)}\n`);
526
+ };
527
+ const writeOutput = async (output, root) => {
528
+ const dir = resolve(root, output.outDir);
529
+ await mkdir(dir, { recursive: true });
530
+ const produced = output.files.map((file) => file.path);
531
+ for (const path of await readManifest(dir)) if (!produced.includes(path)) await rm(join(dir, path), { force: true });
532
+ for (const file of output.files) {
533
+ const target = join(dir, file.path);
534
+ await mkdir(dirname(target), { recursive: true });
535
+ await writeFile(target, file.contents);
536
+ }
537
+ await writeManifest(dir, produced);
538
+ };
539
+ const build = async (options = {}) => {
540
+ const root = resolve(options.root ?? process.cwd());
541
+ const config = await loadConfig(resolve(root, options.config ?? "bezoar.config.ts"));
542
+ const output = await generate(config, root);
543
+ await writeOutput(output, root);
544
+ return output;
545
+ };
546
+ export { BANNER, CACHE, DATABASE, EXTENSION, FILENAME, HOST, InvalidConfigError, InvalidQueryError, InvalidSourceError, MANIFEST, MIGRATION, MIGRATIONS, MalformedConfigError, MissingConfigError, OUT_DIR, QUERIES, SCHEMA, SEEDS, SILENT, build, generate };
@@ -0,0 +1 @@
1
+ export {}
package/.dist/cli.mjs ADDED
@@ -0,0 +1,52 @@
1
+ import { build } from "./_chunks/build.mjs";
2
+ import { parseArgs } from "node:util";
3
+ const HELP = `Usage: bezoar <command> [options]
4
+
5
+ Commands:
6
+ build Apply the migrations and the seeds to a database in memory, describe
7
+ the queries, and write the output directory. Offline and
8
+ deterministic.
9
+
10
+ Options:
11
+ -c, --config <file> Config file (default: bezoar.config.ts)
12
+ -r, --root <dir> Project root (default: current directory)
13
+ -h, --help Show this help`;
14
+ const runBuild = async (config, root) => {
15
+ const output = await build({
16
+ config,
17
+ root
18
+ });
19
+ console.log(`@bezoar/kit: wrote ${output.files.length} files to ${output.outDir}`);
20
+ };
21
+ const main = async (argv) => {
22
+ const { values, positionals } = parseArgs({
23
+ args: argv,
24
+ allowPositionals: true,
25
+ options: {
26
+ config: {
27
+ type: "string",
28
+ short: "c"
29
+ },
30
+ root: {
31
+ type: "string",
32
+ short: "r"
33
+ },
34
+ help: {
35
+ type: "boolean",
36
+ short: "h"
37
+ }
38
+ }
39
+ });
40
+ if (values.help) {
41
+ console.log(HELP);
42
+ return;
43
+ }
44
+ if (positionals.length === 1 && positionals[0] === "build") return runBuild(values.config, values.root);
45
+ console.error(HELP);
46
+ process.exitCode = 1;
47
+ };
48
+ main(process.argv.slice(2)).catch((error) => {
49
+ console.error(error instanceof Error ? error.message : error);
50
+ process.exitCode = 1;
51
+ });
52
+ export {};
@@ -0,0 +1,255 @@
1
+ import { Definition } from "@bezoar/schema";
2
+ import { Migration, Seed } from "@bezoar/core";
3
+ /**
4
+ * The authored `bezoar.config.ts`: where the SQL lives and where the build
5
+ * writes. Every path is relative to the project root.
6
+ */
7
+ export interface KitConfig {
8
+ /**
9
+ * The directory of migrations. The files apply in name order, and the
10
+ * name of a file is its version. Defaults to `src/migrations`.
11
+ */
12
+ migrations?: string;
13
+ /**
14
+ * The directory of seeds. Each file is `<name>.sql`, and the files run in
15
+ * name order. Defaults to `src/seeds`.
16
+ */
17
+ seeds?: string;
18
+ /**
19
+ * The directory of queries. Each file is one query, named for its path.
20
+ * Defaults to `src/queries`.
21
+ */
22
+ queries?: string;
23
+ /**
24
+ * The output directory. Defaults to `bezoar`.
25
+ */
26
+ outDir?: string;
27
+ }
28
+ /**
29
+ * A config through validation: every default applied, every path normalized.
30
+ */
31
+ export interface Settings {
32
+ migrations: string;
33
+ seeds: string;
34
+ queries: string;
35
+ outDir: string;
36
+ }
37
+ /**
38
+ * A migration as the kit read it: the migration, and the file it came from.
39
+ */
40
+ export interface MigrationFile extends Migration {
41
+ /** The path of the file, relative to the project root. */
42
+ readonly path: string;
43
+ }
44
+ /**
45
+ * A seed as the kit read it: the seed, and the file it came from.
46
+ */
47
+ export interface SeedFile extends Seed {
48
+ /** The path of the file, relative to the project root. */
49
+ readonly path: string;
50
+ }
51
+ /**
52
+ * A query as the kit read it: its name, its SQL with the slots rewritten to
53
+ * positions, and the slots in position order.
54
+ */
55
+ export interface Query {
56
+ /** The name, from the path of the file: `events/list.sql` is `events.list`. */
57
+ readonly name: string;
58
+ /** The SQL, with `$1` in place of the first slot, and so on. */
59
+ readonly sql: string;
60
+ /** The slot names, in position order. */
61
+ readonly slots: readonly string[];
62
+ /** The path of the file, relative to the project root. */
63
+ readonly path: string;
64
+ }
65
+ /**
66
+ * The SQL of a project, as the kit read it.
67
+ */
68
+ export interface Sources {
69
+ /** The migrations, in file name order. */
70
+ migrations: MigrationFile[];
71
+ /** The seeds, in file name order. */
72
+ seeds: SeedFile[];
73
+ /** The queries, in file name order. */
74
+ queries: Query[];
75
+ }
76
+ /**
77
+ * What Postgres and SafeQL say about one query: the type OID of each slot,
78
+ * and the name, type OID, and nullability of each column.
79
+ */
80
+ export interface Described {
81
+ readonly params: readonly number[];
82
+ readonly columns: readonly {
83
+ readonly name: string;
84
+ readonly type: number;
85
+ readonly nullable: boolean;
86
+ }[];
87
+ }
88
+ /**
89
+ * The enums of the database after the migrations ran: each enum type with
90
+ * its labels, in declaration order. What the contract carries as `enums`.
91
+ */
92
+ export type Enums = Readonly<Record<string, readonly string[]>>;
93
+ /**
94
+ * One file of the output.
95
+ */
96
+ export interface OutputFile {
97
+ /** The path, relative to the output directory. */
98
+ path: string;
99
+ /** The text of the file. */
100
+ contents: string;
101
+ }
102
+ /**
103
+ * What a build produces: the files to write, and what they were made from.
104
+ */
105
+ export interface Output {
106
+ /** The output directory, normalized and relative to the project root. */
107
+ outDir: string;
108
+ /** The files of the output. */
109
+ files: OutputFile[];
110
+ /** The SQL the build read. */
111
+ sources: Sources;
112
+ /** The enums of the hermetic database after the migrations ran. */
113
+ enums: Enums;
114
+ /** The contract: every query with its slots and columns typed. */
115
+ contract: Definition;
116
+ }
117
+ /**
118
+ * The options of a build: where the project lives.
119
+ */
120
+ export interface BuildOptions {
121
+ /** The project root. Defaults to `process.cwd()`. */
122
+ root?: string;
123
+ /** The config file, relative to the root. Defaults to `bezoar.config.ts`. */
124
+ config?: string;
125
+ }
126
+ /**
127
+ * The whole build: load the config, generate, and write the output
128
+ * directory. What the CLI runs.
129
+ *
130
+ * @param options - The root and the config path.
131
+ */
132
+ export declare const build: (options?: BuildOptions) => Promise<Output>;
133
+ /**
134
+ * Identity helper that types a `bezoar.config.ts`.
135
+ *
136
+ * @param config - The kit config.
137
+ * @returns The same config.
138
+ */
139
+ export declare const defineConfig: (config: KitConfig) => KitConfig;
140
+ /**
141
+ * The config file the CLI looks for in the project root when `--config` is
142
+ * not given.
143
+ */
144
+ export declare const FILENAME = "bezoar.config.ts";
145
+ /**
146
+ * The default directory of migrations, relative to the project root.
147
+ */
148
+ export declare const MIGRATIONS = "src/migrations";
149
+ /**
150
+ * The default directory of seeds, relative to the project root.
151
+ */
152
+ export declare const SEEDS = "src/seeds";
153
+ /**
154
+ * The default directory of queries, relative to the project root.
155
+ */
156
+ export declare const QUERIES = "src/queries";
157
+ /**
158
+ * The default output directory, relative to the project root.
159
+ */
160
+ export declare const OUT_DIR = "bezoar";
161
+ /**
162
+ * The extension of a source file.
163
+ */
164
+ export declare const EXTENSION = ".sql";
165
+ /**
166
+ * Matches a migration whose file name starts with digits and an underscore,
167
+ * as in `0001_events.sql`. The digits are the version. The rest is the name.
168
+ * A file name of another form is its own version and name.
169
+ */
170
+ export declare const MIGRATION: RegExp;
171
+ /**
172
+ * The schema of the database the kit reads the enums from.
173
+ */
174
+ export declare const SCHEMA = "public";
175
+ /**
176
+ * The manifest a write leaves in the output directory: the paths it
177
+ * produced, so the next write can remove the ones it no longer produces
178
+ * without touching anything the kit did not write.
179
+ */
180
+ export declare const MANIFEST = ".bezoar.json";
181
+ /**
182
+ * The banner at the top of each emitted module.
183
+ */
184
+ export declare const BANNER = "// Generated by @bezoar/kit. Do not edit by hand.";
185
+ /**
186
+ * Matches a line that is blank or a line comment. A query file of such lines
187
+ * alone has no SQL.
188
+ */
189
+ export declare const SILENT: RegExp;
190
+ /**
191
+ * The host the hermetic database listens on for the describe of each query.
192
+ */
193
+ export declare const HOST = "127.0.0.1";
194
+ /**
195
+ * The database and the user of a PGlite instance.
196
+ */
197
+ export declare const DATABASE = "postgres";
198
+ /**
199
+ * The key of the catalog cache SafeQL keeps for a build.
200
+ */
201
+ export declare const CACHE = "bezoar";
202
+ /**
203
+ * Raised when there is no config file at the path the build was pointed at.
204
+ * Carries the `path` it looked for.
205
+ */
206
+ export declare class MissingConfigError extends Error {
207
+ readonly path: string;
208
+ constructor(path: string);
209
+ }
210
+ /**
211
+ * Raised when a config file does not default-export a config. Carries the
212
+ * `path` of the file.
213
+ */
214
+ export declare class MalformedConfigError extends Error {
215
+ readonly path: string;
216
+ constructor(path: string);
217
+ }
218
+ /**
219
+ * Raised when a config breaks the rules of the kit, such as a directory
220
+ * outside the project root. Carries every issue found. Thrown before any
221
+ * file is read.
222
+ */
223
+ export declare class InvalidConfigError extends Error {
224
+ readonly issues: string[];
225
+ constructor(issues: string[]);
226
+ }
227
+ /**
228
+ * Raised when Postgres rejects the SQL of a source file. Carries the `path`
229
+ * of the file, relative to the project root, and the error of Postgres as
230
+ * `cause`.
231
+ */
232
+ export declare class InvalidSourceError extends Error {
233
+ readonly path: string;
234
+ constructor(path: string, reason: string, options?: ErrorOptions);
235
+ }
236
+ /**
237
+ * Raised when a query cannot be built: a path that does not name a query, a
238
+ * file without SQL, a slot the SQL cannot type, SQL that Postgres rejects,
239
+ * or a column of a type the type table does not have. Carries the `path` of
240
+ * the file, relative to the project root.
241
+ */
242
+ export declare class InvalidQueryError extends Error {
243
+ readonly path: string;
244
+ constructor(path: string, reason: string, options?: ErrorOptions);
245
+ }
246
+ /**
247
+ * Generates the output of a project without writing it. The function
248
+ * validates the config, reads the SQL, applies the migrations and the seeds
249
+ * to a database in memory, reads the enums, describes each query, builds
250
+ * the contract, and emits the files.
251
+ *
252
+ * @param config - The authored config.
253
+ * @param root - The project root.
254
+ */
255
+ export declare const generate: (config: KitConfig, root: string) => Promise<Output>;
@@ -0,0 +1,3 @@
1
+ import { BANNER, CACHE, DATABASE, EXTENSION, FILENAME, HOST, InvalidConfigError, InvalidQueryError, InvalidSourceError, MANIFEST, MIGRATION, MIGRATIONS, MalformedConfigError, MissingConfigError, OUT_DIR, QUERIES, SCHEMA, SEEDS, SILENT, build, generate } from "./_chunks/build.mjs";
2
+ const defineConfig = (config) => config;
3
+ export { BANNER, CACHE, DATABASE, EXTENSION, FILENAME, HOST, InvalidConfigError, InvalidQueryError, InvalidSourceError, MANIFEST, MIGRATION, MIGRATIONS, MalformedConfigError, MissingConfigError, OUT_DIR, QUERIES, SCHEMA, SEEDS, SILENT, build, defineConfig, generate };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Thorwaldson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # @bezoar/kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The build kit of bezoar. The kit reads the SQL of a database package,
4
+ applies it to a Postgres in memory, and writes modules for the runtime.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pnpm add -D @bezoar/kit
10
+ ```
11
+
12
+ ## Config
13
+
14
+ Put `bezoar.config.ts` in the project root.
15
+
16
+ ```ts
17
+ import { defineConfig } from "@bezoar/kit";
18
+
19
+ export default defineConfig({
20
+ // Optional. The directory of migrations. Defaults to src/migrations.
21
+ migrations: "src/migrations",
22
+
23
+ // Optional. The directory of seeds. Defaults to src/seeds.
24
+ seeds: "src/seeds",
25
+
26
+ // Optional. The directory of queries. Defaults to src/queries.
27
+ queries: "src/queries",
28
+
29
+ // Optional. The output directory. Defaults to bezoar.
30
+ outDir: ".output",
31
+ });
32
+ ```
33
+
34
+ Every path is relative to the project root and must stay inside it. The kit
35
+ checks the config before it reads a file, and reports every problem
36
+ together.
37
+
38
+ ## Sources
39
+
40
+ A migration is a `.sql` file. The files apply in name order, so a name has
41
+ to sort: a counter or a timestamp first. The name of a file is its version,
42
+ which the runner records. A name of the form `<digits>_<rest>` also gives
43
+ the rest as the name for people. A migration is plain SQL and can hold many
44
+ statements.
45
+
46
+ ```
47
+ src/migrations/0001_events.sql
48
+ src/migrations/0002_staff.sql
49
+ ```
50
+
51
+ A seed is `<name>.sql`. The seeds run in name order. A seed is not tracked
52
+ by the runner, so write a seed that is safe to run again.
53
+
54
+ ## Queries
55
+
56
+ A query is one `.sql` file, named for its path. Each directory is a group,
57
+ and the file name is the name: `events/list.sql` is `events.list`, and
58
+ `admin/staff/leaders.sql` is `admin.staff.leaders`. Each directory and the
59
+ file name must be an identifier. A file at the root of the directory is a
60
+ query with no group. A file beside a directory of the same name fails the
61
+ build, since the name would be both a query and a group.
62
+
63
+ ```sql
64
+ -- src/queries/events/list.sql
65
+ SELECT id, slug, title, day
66
+ FROM events
67
+ WHERE day = :day
68
+ ORDER BY id;
69
+ ```
70
+
71
+ ```sql
72
+ -- src/queries/events/bySlug.sql
73
+ SELECT id, title FROM events WHERE slug = :slug;
74
+ ```
75
+
76
+ A file holds one statement. A comment is fine anywhere. A file of comments
77
+ and blank lines alone fails the build. What a call returns follows from the
78
+ columns of the statement: a query with a column returns its rows, and the
79
+ service offers `first` beside it for the first row or `null`. A query with
80
+ no column, a write without `RETURNING`, returns the count of the rows the
81
+ statement changed. Nothing in the file declares this.
82
+
83
+ A slot is `:name`, the syntax of psql. The build rewrites each slot to a
84
+ position, so `:day` is `$1` in the SQL the service runs, and a name used
85
+ twice binds one position. A colon inside a string, a quoted identifier, a
86
+ dollar quote, or a comment is text, and `::` is a cast. A `$1` in the source
87
+ is an error: a query names its slots.
88
+
89
+ Every lever of a query is a SQL lever. A cast sets the type of a slot. An
90
+ alias names a column. Two columns with one name fail the build.
91
+
92
+ ## Build
93
+
94
+ ```sh
95
+ bezoar build
96
+ ```
97
+
98
+ The build boots [PGlite](https://pglite.dev) in memory and applies the
99
+ migrations and the seeds in order. It reads the enums. Then it serves the
100
+ database on a local port and describes each query through
101
+ [SafeQL](https://safeql.dev): Postgres reports the type of each slot and
102
+ each column, and SafeQL reports whether each column can be null, through
103
+ outer joins, `COALESCE`, aggregates, CTEs, and views.
104
+
105
+ A file that Postgres rejects fails the build with the file and the error of
106
+ Postgres:
107
+
108
+ ```
109
+ @bezoar/kit: src/migrations/0002_staff.sql failed: relation "evnts" does not exist
110
+ @bezoar/kit: src/queries/events/bySlug.sql failed: column "slgu" does not exist
111
+ @bezoar/kit: src/queries/events/list.sql the column ip has the type inet, which the type table does not have
112
+ ```
113
+
114
+ The build writes to the output directory:
115
+
116
+ - `index.mjs` and its declaration: the `contract` and the `isQuery` guard.
117
+ The declaration carries the contract as literal types, so `Arguments`,
118
+ `Row`, and `Returns` of `@bezoar/schema` derive from it. `Contract` and
119
+ `Query` are its types. The module holds no SQL.
120
+ - `queries.mjs` and its declaration: the SQL of each query, by name, with
121
+ the slots at their positions.
122
+ - `migrations.mjs` and its declaration: each migration as data, with its
123
+ version, name, SQL, and checksum, for `migrate` of `@bezoar/core`.
124
+ - `seeds.mjs` and its declaration: each seed as data, for `seed`.
125
+
126
+ The kit writes a manifest beside the files. A later build removes the files
127
+ the manifest lists that it no longer produces, and touches nothing else.
128
+
129
+ ## API
130
+
131
+ - `defineConfig(config)` types a config file.
132
+ - `build(options?)` runs the whole build. `options.root` is the project
133
+ root, and `options.config` the config file relative to it.
134
+ - `generate(config, root)` produces the output without writing it.
135
+
136
+ ## Errors
137
+
138
+ - `MissingConfigError`: no file at the config path.
139
+ - `MalformedConfigError`: the config file does not default-export a record.
140
+ - `InvalidConfigError`: the config breaks a rule. Carries every issue.
141
+ - `InvalidSourceError`: Postgres rejected a migration or a seed. Carries its
142
+ path and the error of Postgres as `cause`.
143
+ - `InvalidQueryError`: a query cannot be built. Carries the path of its
144
+ file.
145
+
146
+ ## Related
147
+
148
+ - [`@bezoar/core`](../core): the runtime that imports the build.
149
+ - [`@bezoar/schema`](../schema): the contract the build writes.
150
+ - [`@bezoar/pglite`](../pglite): the database the build runs in.
package/bin/bezoar.mjs ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // The `bezoar` command. A committed launcher rather than the built file
3
+ // itself, so package managers can link the bin before the kit is built.
4
+ import "../.dist/cli.mjs";
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@bezoar/kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "Build kit: SQL in a hermetic Postgres to a typed contract",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zoobzio/bezoar.git",
9
+ "directory": "packages/kit"
10
+ },
11
+ "type": "module",
12
+ "main": "./.dist/index.mjs",
13
+ "types": "./.dist/index.d.mts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./.dist/index.d.mts",
17
+ "import": "./.dist/index.mjs"
18
+ }
19
+ },
20
+ "bin": {
21
+ "bezoar": "./bin/bezoar.mjs"
22
+ },
23
+ "files": [
24
+ ".dist",
25
+ "bin"
26
+ ],
27
+ "dependencies": {
28
+ "@electric-sql/pglite": "^0.5.10",
29
+ "@electric-sql/pglite-socket": "^0.2.13",
30
+ "@ts-safeql/generate": "5.4.1",
31
+ "jiti": "^2.7.0",
32
+ "libpg-query": "18.1.5",
33
+ "objectively": "^0.3.0",
34
+ "postgres": "^3.4.9",
35
+ "@bezoar/pglite": "0.0.1",
36
+ "@bezoar/core": "0.0.1",
37
+ "@bezoar/schema": "0.0.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^25.9.9"
41
+ },
42
+ "scripts": {
43
+ "build": "obuild",
44
+ "stub": "obuild --stub",
45
+ "lint": "oxlint",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ }
6
49
  }