@bockit/sequel 0.0.0-stage → 0.2.0

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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +227 -2
  3. package/THIRD_PARTY_NOTICES.md +268 -0
  4. package/binaries.json +106 -0
  5. package/dist/binaries/cache.d.ts +6 -0
  6. package/dist/binaries/cache.js +36 -0
  7. package/dist/binaries/ensure-binary.d.ts +10 -0
  8. package/dist/binaries/ensure-binary.js +85 -0
  9. package/dist/binaries/manifest.d.ts +30 -0
  10. package/dist/binaries/manifest.js +112 -0
  11. package/dist/binaries/platform.d.ts +3 -0
  12. package/dist/binaries/platform.js +10 -0
  13. package/dist/binaries/unpack.d.ts +2 -0
  14. package/dist/binaries/unpack.js +35 -0
  15. package/dist/binaries/verify.d.ts +2 -0
  16. package/dist/binaries/verify.js +16 -0
  17. package/dist/check.d.ts +9 -0
  18. package/dist/check.js +178 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +339 -0
  21. package/dist/config/define-config.d.ts +24 -0
  22. package/dist/config/define-config.js +4 -0
  23. package/dist/config/load-config.d.ts +6 -0
  24. package/dist/config/load-config.js +34 -0
  25. package/dist/config/resolve-config.d.ts +35 -0
  26. package/dist/config/resolve-config.js +159 -0
  27. package/dist/db/connection.d.ts +6 -0
  28. package/dist/db/connection.js +38 -0
  29. package/dist/db/shadow.d.ts +11 -0
  30. package/dist/db/shadow.js +68 -0
  31. package/dist/engine/client.d.ts +24 -0
  32. package/dist/engine/client.js +123 -0
  33. package/dist/engine/diff.d.ts +12 -0
  34. package/dist/engine/diff.js +51 -0
  35. package/dist/errors.d.ts +3 -0
  36. package/dist/errors.js +5 -0
  37. package/dist/extras/snapshot.d.ts +3 -0
  38. package/dist/extras/snapshot.js +99 -0
  39. package/dist/files.d.ts +1 -0
  40. package/dist/files.js +15 -0
  41. package/dist/generate/generate-modules.d.ts +17 -0
  42. package/dist/generate/generate-modules.js +97 -0
  43. package/dist/generate/modules.d.ts +16 -0
  44. package/dist/generate/modules.js +118 -0
  45. package/dist/generate/pg-dump.d.ts +8 -0
  46. package/dist/generate/pg-dump.js +66 -0
  47. package/dist/generate/plugin-wrapper.d.ts +3 -0
  48. package/dist/generate/plugin-wrapper.js +20 -0
  49. package/dist/generate/sqlc.d.ts +27 -0
  50. package/dist/generate/sqlc.js +113 -0
  51. package/dist/generate/watch.d.ts +16 -0
  52. package/dist/generate/watch.js +73 -0
  53. package/dist/generator/generate.d.ts +21 -0
  54. package/dist/generator/generate.js +440 -0
  55. package/dist/generator/keys.d.ts +4 -0
  56. package/dist/generator/keys.js +24 -0
  57. package/dist/generator/lexer.d.ts +12 -0
  58. package/dist/generator/lexer.js +210 -0
  59. package/dist/generator/main.d.ts +1 -0
  60. package/dist/generator/main.js +12 -0
  61. package/dist/generator/pg-array-helper.d.ts +2 -0
  62. package/dist/generator/pg-array-helper.js +53 -0
  63. package/dist/generator/plugin.d.ts +6 -0
  64. package/dist/generator/plugin.js +26 -0
  65. package/dist/generator/request.d.ts +57 -0
  66. package/dist/generator/request.js +96 -0
  67. package/dist/generator/select-list.d.ts +27 -0
  68. package/dist/generator/select-list.js +580 -0
  69. package/dist/generator/strings.d.ts +2 -0
  70. package/dist/generator/strings.js +34 -0
  71. package/dist/generator/types.d.ts +23 -0
  72. package/dist/generator/types.js +216 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +2 -0
  75. package/dist/migrate/adopt-prisma.d.ts +23 -0
  76. package/dist/migrate/adopt-prisma.js +119 -0
  77. package/dist/migrate/deploy.d.ts +7 -0
  78. package/dist/migrate/deploy.js +23 -0
  79. package/dist/migrate/drift.d.ts +10 -0
  80. package/dist/migrate/drift.js +36 -0
  81. package/dist/migrate/files.d.ts +19 -0
  82. package/dist/migrate/files.js +97 -0
  83. package/dist/migrate/history.d.ts +20 -0
  84. package/dist/migrate/history.js +54 -0
  85. package/dist/migrate/lint.d.ts +21 -0
  86. package/dist/migrate/lint.js +153 -0
  87. package/dist/migrate/new.d.ts +13 -0
  88. package/dist/migrate/new.js +41 -0
  89. package/dist/migrate/runner.d.ts +28 -0
  90. package/dist/migrate/runner.js +124 -0
  91. package/dist/migrate/sql-text.d.ts +33 -0
  92. package/dist/migrate/sql-text.js +233 -0
  93. package/dist/schema/schema.d.ts +16 -0
  94. package/dist/schema/schema.js +111 -0
  95. package/dist/schema/unsupported.d.ts +3 -0
  96. package/dist/schema/unsupported.js +36 -0
  97. package/dist/schema/wasm.d.ts +8 -0
  98. package/dist/schema/wasm.js +32 -0
  99. package/package.json +50 -4
  100. package/vendor/prisma-schema-wasm/LICENSE +201 -0
  101. package/vendor/prisma-schema-wasm/README.md +32 -0
  102. package/vendor/prisma-schema-wasm/package.json +3 -0
  103. package/vendor/prisma-schema-wasm/prisma_schema_build.js +539 -0
  104. package/vendor/prisma-schema-wasm/prisma_schema_build_bg.wasm +0 -0
@@ -0,0 +1,99 @@
1
+ import { withClient } from '../db/connection.js';
2
+ // TECH-SPEC §11: the Postgres features the schema language can't express, read from the
3
+ // catalog of the `public` schema. The committed snapshot is a record for review: never run.
4
+ // Excludes objects that belong to an extension, such as pgvector's functions.
5
+ const NOT_FROM_EXTENSION = (catalog, oid) => `NOT EXISTS (
6
+ SELECT 1 FROM pg_depend d
7
+ WHERE d.classid = '${catalog}'::regclass AND d.objid = ${oid} AND d.deptype = 'e')`;
8
+ const SECTIONS = [
9
+ {
10
+ title: 'Extensions',
11
+ query: `SELECT format('CREATE EXTENSION %I', extname) AS def
12
+ FROM pg_extension WHERE extname <> 'plpgsql'`,
13
+ },
14
+ {
15
+ title: 'Database settings',
16
+ query: `SELECT format('ALTER DATABASE … SET %s', setting) AS def
17
+ FROM pg_db_role_setting, unnest(setconfig) AS setting
18
+ WHERE setrole = 0
19
+ AND setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database())`,
20
+ },
21
+ {
22
+ title: 'Functions',
23
+ // Aggregates and window functions have no `pg_get_functiondef`.
24
+ query: `SELECT pg_get_functiondef(p.oid) AS def FROM pg_proc p
25
+ WHERE p.pronamespace = 'public'::regnamespace AND p.prokind IN ('f', 'p')
26
+ AND ${NOT_FROM_EXTENSION('pg_proc', 'p.oid')}`,
27
+ },
28
+ {
29
+ title: 'Triggers',
30
+ query: `SELECT pg_get_triggerdef(t.oid) AS def
31
+ FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
32
+ WHERE NOT t.tgisinternal AND c.relnamespace = 'public'::regnamespace`,
33
+ },
34
+ {
35
+ title: 'CHECK, exclusion and deferrable constraints',
36
+ query: `SELECT format('ALTER TABLE %I ADD CONSTRAINT %I %s', c.relname, con.conname, pg_get_constraintdef(con.oid)) AS def
37
+ FROM pg_constraint con JOIN pg_class c ON c.oid = con.conrelid
38
+ WHERE con.connamespace = 'public'::regnamespace
39
+ AND (con.contype IN ('c', 'x') OR con.condeferrable)`,
40
+ },
41
+ {
42
+ title: 'Partial, expression and non-btree indexes',
43
+ query: `SELECT pg_get_indexdef(i.indexrelid) AS def
44
+ FROM pg_index i
45
+ JOIN pg_class ic ON ic.oid = i.indexrelid
46
+ JOIN pg_am am ON am.oid = ic.relam
47
+ WHERE ic.relnamespace = 'public'::regnamespace
48
+ AND (i.indpred IS NOT NULL OR i.indexprs IS NOT NULL OR am.amname <> 'btree')`,
49
+ },
50
+ {
51
+ title: 'Generated columns',
52
+ query: `SELECT format('%I.%I GENERATED ALWAYS AS (%s) %s', c.relname, a.attname,
53
+ pg_get_expr(ad.adbin, ad.adrelid),
54
+ CASE a.attgenerated WHEN 's' THEN 'STORED' ELSE 'VIRTUAL' END) AS def
55
+ FROM pg_attribute a
56
+ JOIN pg_class c ON c.oid = a.attrelid
57
+ JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
58
+ WHERE c.relnamespace = 'public'::regnamespace
59
+ AND a.attgenerated <> '' AND NOT a.attisdropped`,
60
+ },
61
+ {
62
+ title: 'Views',
63
+ query: `SELECT format('CREATE %sVIEW %I AS %s',
64
+ CASE c.relkind WHEN 'm' THEN 'MATERIALIZED ' ELSE '' END, c.relname, pg_get_viewdef(c.oid)) AS def
65
+ FROM pg_class c
66
+ WHERE c.relnamespace = 'public'::regnamespace AND c.relkind IN ('v', 'm')
67
+ AND ${NOT_FROM_EXTENSION('pg_class', 'c.oid')}`,
68
+ },
69
+ {
70
+ title: 'Column collations',
71
+ // A column whose collation isn't its type's default.
72
+ query: `SELECT format('%I.%I COLLATE %s', c.relname, a.attname, a.attcollation::regcollation) AS def
73
+ FROM pg_attribute a
74
+ JOIN pg_class c ON c.oid = a.attrelid
75
+ JOIN pg_type t ON t.oid = a.atttypid
76
+ WHERE c.relnamespace = 'public'::regnamespace AND c.relkind IN ('r', 'p')
77
+ AND a.attnum > 0 AND NOT a.attisdropped
78
+ AND a.attcollation <> 0 AND a.attcollation <> t.typcollation`,
79
+ },
80
+ ];
81
+ const HEADER = [
82
+ "-- Postgres features outside Prisma's schema language, as the test database has them.",
83
+ '-- Written by `sequel extras`. A record for review: it is never run.',
84
+ ].join('\n');
85
+ // The snapshot of the database `url` names.
86
+ export function readExtrasSnapshot(url) {
87
+ return withClient(url, extrasSnapshot);
88
+ }
89
+ export async function extrasSnapshot(client) {
90
+ const sections = [HEADER];
91
+ for (const { title, query } of SECTIONS) {
92
+ const { rows } = await client.query(query);
93
+ const defs = rows
94
+ .map((row) => `${row.def.trim().replace(/;$/, '')};`)
95
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
96
+ sections.push([`-- ${title}`, ...(defs.length > 0 ? defs : ['-- (none)'])].join('\n'));
97
+ }
98
+ return `${sections.join('\n\n')}\n`;
99
+ }
@@ -0,0 +1 @@
1
+ export declare function writeAtomically(file: string, contents: string): Promise<void>;
package/dist/files.js ADDED
@@ -0,0 +1,15 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ // Writes beside the target, then renames over it, so a reader never sees half a file.
5
+ export async function writeAtomically(file, contents) {
6
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(6).toString('hex')}.tmp`);
7
+ try {
8
+ await writeFile(temporary, contents);
9
+ await rename(temporary, file);
10
+ }
11
+ catch (error) {
12
+ await rm(temporary, { force: true });
13
+ throw error;
14
+ }
15
+ }
@@ -0,0 +1,17 @@
1
+ import type { BigintMode } from '../config/define-config.ts';
2
+ import { type InstallResult, type ModuleCheck, type Rendered } from './modules.ts';
3
+ export type GenerateOptions = {
4
+ sqlc: string;
5
+ databaseUrl: string;
6
+ folders: string[];
7
+ bigint?: BigintMode;
8
+ root?: string;
9
+ pgDump?: string;
10
+ };
11
+ export declare function generateModules(options: GenerateOptions): Promise<InstallResult>;
12
+ export declare function checkModules(options: GenerateOptions): Promise<ModuleCheck>;
13
+ export declare function sqlcDiff(options: GenerateOptions): Promise<{
14
+ clean: boolean;
15
+ output: string;
16
+ }>;
17
+ export declare function renderModules(options: GenerateOptions): Promise<Rendered>;
@@ -0,0 +1,97 @@
1
+ import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { SequelError } from '../errors.js';
5
+ import { compareModules, hasNamedQueries, installModules, } from './modules.js';
6
+ import { dumpSchema } from './pg-dump.js';
7
+ import { writePluginWrapper } from './plugin-wrapper.js';
8
+ import { readFailures, runSqlc, sqlcConfig, writeSqlcConfig } from './sqlc.js';
9
+ // Steps 2–4 of TECH-SPEC §9 for `generate`: dumps the test database's schema, runs sqlc
10
+ // into temporary folders, and only when every block succeeds puts the modules in place and
11
+ // deletes orphans. Any failure throws, listing every failure, and changes nothing.
12
+ export async function generateModules(options) {
13
+ const rendered = await renderModules(options);
14
+ return installModules(rendered, options.folders);
15
+ }
16
+ // For `check`: the same run, compared byte for byte with the modules in place.
17
+ export async function checkModules(options) {
18
+ const rendered = await renderModules(options);
19
+ return compareModules(rendered, options.folders);
20
+ }
21
+ // `sqlc diff` with each block's `out` set to its own folder. sqlc prints a unified diff for a
22
+ // stale module, but exits 1 silently for a missing one, and doesn't see orphans.
23
+ export async function sqlcDiff(options) {
24
+ return withRun(options, async (run) => {
25
+ const blocks = await blocksFor(options.folders, () => '');
26
+ if (blocks.length === 0)
27
+ return { clean: true, output: '' };
28
+ const result = await run(blocks.map((block) => ({ ...block, out: block.queries })), 'diff');
29
+ return { clean: result.exitCode === 0, output: result.stdout + result.stderr };
30
+ });
31
+ }
32
+ // Runs sqlc generate with each block's `out` in a temporary folder, and returns what it wrote.
33
+ export async function renderModules(options) {
34
+ return withRun(options, async (run, workDir) => {
35
+ const blocks = await blocksFor(options.folders, (i) => path.join(workDir, 'out', String(i)));
36
+ const rendered = new Map();
37
+ if (blocks.length === 0)
38
+ return rendered;
39
+ for (const block of blocks)
40
+ await mkdir(block.out, { recursive: true });
41
+ const result = await run(blocks, 'generate');
42
+ const configDir = path.join(workDir, 'config');
43
+ if (result.exitCode !== 0) {
44
+ const failures = readFailures(result.stderr + result.stdout, {
45
+ configDir,
46
+ blocks,
47
+ root: options.root ?? process.cwd(),
48
+ });
49
+ throw new SequelError(`sqlc could not generate the query modules, so nothing was written:\n${failures.join('\n')}`);
50
+ }
51
+ for (const block of blocks) {
52
+ const modules = new Map();
53
+ for (const name of (await readdir(block.out)).sort()) {
54
+ modules.set(name, await readFile(path.join(block.out, name), 'utf8'));
55
+ }
56
+ rendered.set(block.queries, modules);
57
+ }
58
+ return rendered;
59
+ });
60
+ }
61
+ async function blocksFor(folders, out) {
62
+ const blocks = [];
63
+ for (const folder of folders) {
64
+ if (await hasNamedQueries(folder))
65
+ blocks.push({ queries: folder, out: out(blocks.length) });
66
+ }
67
+ return blocks;
68
+ }
69
+ // A temporary folder holding the dump, the wrapper and the config, removed afterwards.
70
+ async function withRun(options, body) {
71
+ const workDir = await mkdtemp(path.join(tmpdir(), 'sequel-generate-'));
72
+ try {
73
+ const configDir = path.join(workDir, 'config');
74
+ await mkdir(configDir);
75
+ let prepared;
76
+ const run = async (blocks, command) => {
77
+ if (prepared === undefined) {
78
+ const schema = path.join(workDir, 'schema.sql');
79
+ await dumpSchema(options.databaseUrl, schema, options.pgDump);
80
+ prepared = { schema, wrapper: await writePluginWrapper(workDir) };
81
+ }
82
+ const configFile = path.join(configDir, 'sqlc.json');
83
+ await writeSqlcConfig(configFile, sqlcConfig({
84
+ configDir,
85
+ schema: prepared.schema,
86
+ blocks,
87
+ wrapper: prepared.wrapper,
88
+ bigint: options.bigint ?? 'string',
89
+ }));
90
+ return runSqlc(options.sqlc, command, configFile, options.databaseUrl);
91
+ };
92
+ return await body(run, workDir);
93
+ }
94
+ finally {
95
+ await rm(workDir, { recursive: true, force: true });
96
+ }
97
+ }
@@ -0,0 +1,16 @@
1
+ export declare function isGeneratedModule(text: string): boolean;
2
+ export declare function findQueryFolders(patterns: string[]): Promise<string[]>;
3
+ export declare function hasNamedQueries(folder: string): Promise<boolean>;
4
+ export type Rendered = Map<string, Map<string, string>>;
5
+ export type InstallResult = {
6
+ written: string[];
7
+ unchanged: string[];
8
+ deleted: string[];
9
+ };
10
+ export declare function installModules(rendered: Rendered, folders: string[]): Promise<InstallResult>;
11
+ export type ModuleCheck = {
12
+ stale: string[];
13
+ missing: string[];
14
+ orphaned: string[];
15
+ };
16
+ export declare function compareModules(rendered: Rendered, folders: string[]): Promise<ModuleCheck>;
@@ -0,0 +1,118 @@
1
+ import { glob, readdir, readFile, rm, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { SequelError } from '../errors.js';
4
+ import { writeAtomically } from '../files.js';
5
+ // R11: the first line of every generated module. sequel recognises its modules, and their
6
+ // orphans, by it (TECH-SPEC §9).
7
+ const HEADER = /^\/\/ Generated by sequel from .+\. Do not edit\.$/;
8
+ export function isGeneratedModule(text) {
9
+ const newline = text.indexOf('\n');
10
+ return HEADER.test(newline === -1 ? text : text.slice(0, newline));
11
+ }
12
+ // Every folder a query glob matches, sorted. Each `.sql` file directly inside one is a query
13
+ // file; sqlc doesn't read subfolders, so each folder is its own block.
14
+ export async function findQueryFolders(patterns) {
15
+ const folders = new Set();
16
+ for (const pattern of patterns) {
17
+ for await (const match of glob(pattern, {
18
+ exclude: (name) => {
19
+ const base = path.basename(name);
20
+ return base === 'node_modules' || base === '.git';
21
+ },
22
+ })) {
23
+ const full = path.resolve(match);
24
+ if ((await stat(full)).isDirectory())
25
+ folders.add(full);
26
+ }
27
+ }
28
+ return [...folders].sort();
29
+ }
30
+ // Whether a folder has a query sqlc will see. A folder of `.sql` files with no named query
31
+ // makes sqlc fail ("no queries contained in paths"), so it gets no block.
32
+ export async function hasNamedQueries(folder) {
33
+ for (const name of await sqlFiles(folder)) {
34
+ const text = await readFile(path.join(folder, name), 'utf8');
35
+ if (/^\s*--\s*name:/m.test(text))
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+ async function sqlFiles(folder) {
41
+ const entries = await readdir(folder, { withFileTypes: true });
42
+ return entries.filter((e) => e.isFile() && e.name.endsWith('.sql')).map((e) => e.name);
43
+ }
44
+ // TECH-SPEC §9: puts a successful run's modules in place, and deletes generated modules the
45
+ // run didn't produce, because their query file is gone or has no named queries.
46
+ // It refuses, changing nothing, to overwrite a `.ts` file sequel didn't generate.
47
+ export async function installModules(rendered, folders) {
48
+ const plan = await planModules(rendered, folders);
49
+ const clobbered = plan.changed.filter((change) => change.existing !== undefined && !isGeneratedModule(change.existing));
50
+ if (clobbered.length > 0) {
51
+ throw new SequelError(clobbered
52
+ .map(({ file }) => `${file} was not generated by sequel, and generating would overwrite it. Rename its query file, or move ${path.basename(file)} out of the query folder.`)
53
+ .join('\n'));
54
+ }
55
+ for (const { file, contents } of plan.changed)
56
+ await writeAtomically(file, contents);
57
+ for (const file of plan.orphaned)
58
+ await rm(file, { force: true });
59
+ return {
60
+ written: plan.changed.map((change) => change.file),
61
+ unchanged: plan.unchanged,
62
+ deleted: plan.orphaned,
63
+ };
64
+ }
65
+ // For `check`: compares a run's modules with those in place, byte for byte, writing nothing.
66
+ export async function compareModules(rendered, folders) {
67
+ const plan = await planModules(rendered, folders);
68
+ return {
69
+ stale: plan.changed.filter((c) => c.existing !== undefined).map((c) => c.file),
70
+ missing: plan.changed.filter((c) => c.existing === undefined).map((c) => c.file),
71
+ orphaned: plan.orphaned,
72
+ };
73
+ }
74
+ async function planModules(rendered, folders) {
75
+ const plan = { changed: [], unchanged: [], orphaned: [] };
76
+ const allFolders = [...new Set([...folders, ...rendered.keys()])].sort();
77
+ for (const folder of allFolders) {
78
+ const modules = rendered.get(folder) ?? new Map();
79
+ for (const name of [...modules.keys()].sort()) {
80
+ const file = path.join(folder, name);
81
+ const contents = modules.get(name);
82
+ const existing = await readIfExists(file);
83
+ if (existing === contents)
84
+ plan.unchanged.push(file);
85
+ else
86
+ plan.changed.push({ file, contents, existing });
87
+ }
88
+ let entries;
89
+ try {
90
+ entries = (await readdir(folder, { withFileTypes: true }))
91
+ .filter((e) => e.isFile() && e.name.endsWith('.ts'))
92
+ .map((e) => e.name)
93
+ .sort();
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ for (const name of entries) {
99
+ if (modules.has(name))
100
+ continue;
101
+ const file = path.join(folder, name);
102
+ const text = await readIfExists(file);
103
+ if (text !== undefined && isGeneratedModule(text))
104
+ plan.orphaned.push(file);
105
+ }
106
+ }
107
+ return plan;
108
+ }
109
+ async function readIfExists(file) {
110
+ try {
111
+ return await readFile(file, 'utf8');
112
+ }
113
+ catch (error) {
114
+ if (error.code === 'ENOENT')
115
+ return undefined;
116
+ throw error;
117
+ }
118
+ }
@@ -0,0 +1,8 @@
1
+ export declare function pgDumpMajor(versionOutput: string): number | null;
2
+ export declare function checkPgDump(serverMajor: number, pgDump?: string): Promise<void>;
3
+ export declare function serverMajor(databaseUrl: string): Promise<number>;
4
+ export declare function dumpSchema(databaseUrl: string, file: string, pgDump?: string): Promise<void>;
5
+ export declare function withoutPassword(databaseUrl: string): {
6
+ url: string;
7
+ password: string | undefined;
8
+ };
@@ -0,0 +1,66 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import pg from 'pg';
4
+ import { SequelError } from '../errors.js';
5
+ const run = promisify(execFile);
6
+ // The major version in `pg_dump --version`, such as 18 from `pg_dump (PostgreSQL) 18.6`.
7
+ export function pgDumpMajor(versionOutput) {
8
+ const match = /\(PostgreSQL\)\s+(\d+)/.exec(versionOutput) ?? /\b(\d+)(?:\.\d+)?\b/.exec(versionOutput);
9
+ return match?.[1] === undefined ? null : Number(match[1]);
10
+ }
11
+ // TECH-SPEC §1: `pg_dump` must be on PATH, at the server's major version or newer.
12
+ export async function checkPgDump(serverMajor, pgDump = 'pg_dump') {
13
+ let stdout;
14
+ try {
15
+ ;
16
+ ({ stdout } = await run(pgDump, ['--version']));
17
+ }
18
+ catch (error) {
19
+ if (error.code === 'ENOENT') {
20
+ throw new SequelError(`pg_dump was not found on PATH. sequel needs it to read the test database's schema. Install the Postgres ${serverMajor} client tools, or newer, and put pg_dump on PATH.`);
21
+ }
22
+ throw error;
23
+ }
24
+ const major = pgDumpMajor(stdout);
25
+ if (major === null) {
26
+ throw new SequelError(`Could not read pg_dump's version from "${stdout.trim()}". Check that pg_dump on PATH is Postgres's.`);
27
+ }
28
+ if (major < serverMajor) {
29
+ throw new SequelError(`pg_dump on PATH is version ${major}, but the test database's server is Postgres ${serverMajor}. Install pg_dump ${serverMajor} or newer and put it first on PATH.`);
30
+ }
31
+ }
32
+ export async function serverMajor(databaseUrl) {
33
+ const client = new pg.Client({ connectionString: databaseUrl });
34
+ await client.connect();
35
+ try {
36
+ const result = await client.query('SHOW server_version_num');
37
+ return Math.floor(Number(result.rows[0]?.server_version_num) / 10_000);
38
+ }
39
+ finally {
40
+ await client.end();
41
+ }
42
+ }
43
+ // TECH-SPEC §9: `pg_dump --schema-only --no-owner --no-privileges` of the test database into
44
+ // `file`. The password travels in `PGPASSWORD`, so `ps` never shows it.
45
+ export async function dumpSchema(databaseUrl, file, pgDump = 'pg_dump') {
46
+ await checkPgDump(await serverMajor(databaseUrl), pgDump);
47
+ const { url, password } = withoutPassword(databaseUrl);
48
+ const env = { ...process.env };
49
+ if (password !== undefined)
50
+ env.PGPASSWORD = password;
51
+ try {
52
+ await run(pgDump, ['--schema-only', '--no-owner', '--no-privileges', '--file', file, '--dbname', url], { env, maxBuffer: 64 * 1024 * 1024 });
53
+ }
54
+ catch (error) {
55
+ const stderr = String(error.stderr ?? '').trim();
56
+ throw new SequelError(`pg_dump could not dump the test database's schema: ${stderr || error.message}`);
57
+ }
58
+ }
59
+ export function withoutPassword(databaseUrl) {
60
+ const parsed = new URL(databaseUrl);
61
+ if (parsed.password === '')
62
+ return { url: databaseUrl, password: undefined };
63
+ const password = decodeURIComponent(parsed.password);
64
+ parsed.password = '';
65
+ return { url: parsed.toString(), password };
66
+ }
@@ -0,0 +1,3 @@
1
+ export declare function generatorEntry(): string;
2
+ export declare function writePluginWrapper(dir: string, node?: string, entry?: string): Promise<string>;
3
+ export declare function shellQuote(value: string): string;
@@ -0,0 +1,20 @@
1
+ import { chmod, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // The generator's entry: `main.ts` from source, `main.js` from the published `dist/`.
5
+ export function generatorEntry() {
6
+ const here = fileURLToPath(import.meta.url);
7
+ return path.join(path.dirname(here), '..', 'generator', `main${path.extname(here)}`);
8
+ }
9
+ // sqlc runs a process plugin with no `PATH`, so `#!/usr/bin/env node` can't start. Each run
10
+ // writes a `#!/bin/sh` wrapper that `exec`s this Node on the generator's entry (TECH-SPEC §9,
11
+ // R1), and points the plugin's `cmd` at it.
12
+ export async function writePluginWrapper(dir, node = process.execPath, entry = generatorEntry()) {
13
+ const file = path.join(dir, 'sequel-generator.sh');
14
+ await writeFile(file, `#!/bin/sh\nexec ${shellQuote(node)} ${shellQuote(entry)} "$@"\n`);
15
+ await chmod(file, 0o755);
16
+ return file;
17
+ }
18
+ export function shellQuote(value) {
19
+ return `'${value.replaceAll("'", `'\\''`)}'`;
20
+ }
@@ -0,0 +1,27 @@
1
+ import type { BigintMode } from '../config/define-config.ts';
2
+ export type Block = {
3
+ queries: string;
4
+ out: string;
5
+ };
6
+ export declare const DATABASE_URL_VARIABLE = "SEQUEL_SQLC_DATABASE_URL";
7
+ export declare function pluginName(index: number): string;
8
+ export declare function sqlcConfig(options: {
9
+ configDir: string;
10
+ schema: string;
11
+ blocks: Block[];
12
+ wrapper: string;
13
+ bigint: BigintMode;
14
+ }): object;
15
+ export declare function writeSqlcConfig(file: string, config: object): Promise<void>;
16
+ export type SqlcRun = {
17
+ exitCode: number;
18
+ stdout: string;
19
+ stderr: string;
20
+ };
21
+ export declare function runSqlc(sqlc: string, command: 'generate' | 'diff', configFile: string, databaseUrl: string): Promise<SqlcRun>;
22
+ export declare function readFailures(output: string, options: {
23
+ configDir: string;
24
+ blocks: Block[];
25
+ root: string;
26
+ }): string[];
27
+ export declare function nameLine(file: string, query: string): number | undefined;
@@ -0,0 +1,113 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ // sqlc reads the test database's URL from this variable, through `${…}` in `database.uri`,
6
+ // so the URL, and any password in it, is never written to disk.
7
+ export const DATABASE_URL_VARIABLE = 'SEQUEL_SQLC_DATABASE_URL';
8
+ // The plugin entry for block `i`. sqlc heads each block's failures with
9
+ // `# package <plugin>`, so a plugin per block tells failures apart by folder.
10
+ export function pluginName(index) {
11
+ return `sequel-${index}`;
12
+ }
13
+ // TECH-SPEC §9: sqlc's config, one `sql` block per folder of query files, typed by the
14
+ // database analyzer against the test database. sqlc joins every path onto the config's own
15
+ // folder, even an absolute one, so paths are written relative to `configDir`.
16
+ export function sqlcConfig(options) {
17
+ const relative = (to) => path.relative(options.configDir, to) || '.';
18
+ return {
19
+ version: '2',
20
+ plugins: options.blocks.map((_, i) => ({
21
+ name: pluginName(i),
22
+ process: { cmd: options.wrapper, format: 'json' },
23
+ })),
24
+ sql: options.blocks.map((block, i) => ({
25
+ engine: 'postgresql',
26
+ schema: relative(options.schema),
27
+ queries: relative(block.queries),
28
+ database: { uri: `\${${DATABASE_URL_VARIABLE}}` },
29
+ analyzer: { database: true },
30
+ codegen: [
31
+ { plugin: pluginName(i), out: relative(block.out), options: { bigint: options.bigint } },
32
+ ],
33
+ })),
34
+ };
35
+ }
36
+ export async function writeSqlcConfig(file, config) {
37
+ await writeFile(file, `${JSON.stringify(config, null, 2)}\n`);
38
+ }
39
+ // Runs `sqlc generate` or `sqlc diff` on a config, with the test database's URL in the
40
+ // environment.
41
+ export function runSqlc(sqlc, command, configFile, databaseUrl) {
42
+ return new Promise((resolve, reject) => {
43
+ execFile(sqlc, [command, '-f', configFile], {
44
+ cwd: path.dirname(configFile),
45
+ env: { ...process.env, [DATABASE_URL_VARIABLE]: databaseUrl },
46
+ maxBuffer: 64 * 1024 * 1024,
47
+ }, (error, stdout, stderr) => {
48
+ if (error !== null && typeof error.code !== 'number') {
49
+ reject(error);
50
+ return;
51
+ }
52
+ resolve({ exitCode: error === null ? 0 : error.code, stdout, stderr });
53
+ });
54
+ });
55
+ }
56
+ const PLUGIN_PREFIX = 'error generating code: process: error running command ';
57
+ // Turns sqlc's failure output into one line per failure, each naming its file, relative to
58
+ // `root`, and line. sqlc's own errors carry `path:line:col`. The generator's carry only
59
+ // `<file>: <query>`, so the line is that of the query's `-- name:` comment (R57).
60
+ export function readFailures(output, options) {
61
+ const failures = [];
62
+ let block;
63
+ for (const raw of output.split('\n')) {
64
+ const header = /^# package (.*)$/.exec(raw);
65
+ if (header !== null) {
66
+ block = options.blocks.find((_, i) => pluginName(i) === header[1]);
67
+ continue;
68
+ }
69
+ const line = raw.startsWith(PLUGIN_PREFIX) ? raw.slice(PLUGIN_PREFIX.length) : raw;
70
+ if (line.trim() === '')
71
+ continue;
72
+ const own = /^(.+?\.sql):(\d+):(\d+): (.*)$/.exec(line);
73
+ if (own !== null) {
74
+ const [, file = '', row, column, message] = own;
75
+ const shown = path.relative(options.root, path.resolve(options.configDir, file));
76
+ failures.push(`${shown}:${row}:${column}: ${message}`);
77
+ continue;
78
+ }
79
+ const fileOnly = /^([^:]+): (query file name must end in \.sql)$/.exec(line);
80
+ if (fileOnly !== null && block !== undefined) {
81
+ const shown = path.relative(options.root, path.join(block.queries, fileOnly[1] ?? ''));
82
+ failures.push(`${shown}: ${fileOnly[2]}`);
83
+ continue;
84
+ }
85
+ const generator = /^([^:]+\.sql): ([^\s:]+): (.*)$/.exec(line);
86
+ if (generator !== null && block !== undefined) {
87
+ const [, file = '', query = '', message] = generator;
88
+ const full = path.join(block.queries, file);
89
+ const lineNumber = nameLine(full, query);
90
+ const shown = path.relative(options.root, full);
91
+ failures.push(`${shown}${lineNumber === undefined ? '' : `:${lineNumber}`}: ${query}: ${message}`);
92
+ continue;
93
+ }
94
+ failures.push(line);
95
+ }
96
+ return failures;
97
+ }
98
+ // The 1-based line of `-- name: <query>` in `file`.
99
+ export function nameLine(file, query) {
100
+ let text;
101
+ try {
102
+ text = readFileSync(file, 'utf8');
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ const pattern = new RegExp(`^\\s*--\\s*name:\\s*${escapeRegExp(query)}(\\s|$)`);
108
+ const index = text.split('\n').findIndex((line) => pattern.test(line));
109
+ return index === -1 ? undefined : index + 1;
110
+ }
111
+ function escapeRegExp(text) {
112
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
113
+ }
@@ -0,0 +1,16 @@
1
+ export declare function createRerunScheduler(options: {
2
+ run: () => Promise<void>;
3
+ onError: (error: unknown) => void;
4
+ debounceMs?: number;
5
+ }): {
6
+ trigger: () => void;
7
+ settled(): Promise<void>;
8
+ stop(): void;
9
+ };
10
+ export declare function watchAndRerun(options: {
11
+ folders: string[];
12
+ run: () => Promise<void>;
13
+ onError: (error: unknown) => void;
14
+ signal: AbortSignal;
15
+ debounceMs?: number;
16
+ }): Promise<void>;