@bockit/sequel 0.0.0-stage → 0.1.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 +191 -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 +166 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.js +324 -0
  21. package/dist/config/define-config.d.ts +23 -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 +34 -0
  26. package/dist/config/resolve-config.js +144 -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,68 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { SequelError } from '../errors.js';
4
+ import { databaseName, quoteIdent, redactUrl, withClient, withDatabaseName } from './connection.js';
5
+ // TECH-SPEC §8: every code path that wipes or drops a database checks its name first.
6
+ export function assertShadowName(name) {
7
+ if (!name.endsWith('_shadow')) {
8
+ throw new SequelError(`Refusing to wipe the database \`${name}\`: sequel only wipes shadow databases, whose names end in \`_shadow\`.`);
9
+ }
10
+ }
11
+ // Where a command's shadow database comes from: the fixed one if the config names it, else one
12
+ // of its own on the test database's server.
13
+ export function shadowOptions(config) {
14
+ const { shadow, test } = config.databases;
15
+ if (shadow !== undefined)
16
+ return { fixedUrl: shadow };
17
+ if (test !== undefined)
18
+ return { testUrl: test };
19
+ throw new SequelError(`${path.basename(config.configFile)}: sequel needs a shadow database, and neither \`databases.test\` nor \`databases.shadow\` is set. Set \`databases.test\`, and sequel creates one on its server for each run, or set \`databases.shadow\` to a database whose name ends in \`_shadow\`.`);
20
+ }
21
+ // Runs `fn` with an empty shadow database. By default it is a new `sequel_<random>_shadow` on
22
+ // the test database's server, dropped afterwards, even on failure or interruption.
23
+ export async function withShadowDatabase(options, fn) {
24
+ if ('fixedUrl' in options) {
25
+ await wipeShadowDatabase(options.fixedUrl);
26
+ return fn(options.fixedUrl);
27
+ }
28
+ const name = `sequel_${randomBytes(6).toString('hex')}_shadow`;
29
+ try {
30
+ await withClient(options.testUrl, (client) => client.query(`CREATE DATABASE ${quoteIdent(name)}`));
31
+ }
32
+ catch (error) {
33
+ throw new SequelError(`Could not create a shadow database on the test database's server (${redactUrl(options.testUrl)}): ${error.message}\nThe test database's role needs CREATEDB. Where it can't have it, set \`databases.shadow\` to a fixed database whose name ends in \`_shadow\`.`);
34
+ }
35
+ const drop = () => dropShadowDatabase(options.testUrl, name);
36
+ const onSignal = (signal) => {
37
+ void drop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
38
+ };
39
+ process.once('SIGINT', onSignal);
40
+ process.once('SIGTERM', onSignal);
41
+ try {
42
+ return await fn(withDatabaseName(options.testUrl, name));
43
+ }
44
+ finally {
45
+ process.off('SIGINT', onSignal);
46
+ process.off('SIGTERM', onSignal);
47
+ await drop();
48
+ }
49
+ }
50
+ // Drops a shadow database through a connection to another database on its server.
51
+ export async function dropShadowDatabase(serverUrl, name) {
52
+ assertShadowName(name);
53
+ await withClient(serverUrl, (client) => client.query(`DROP DATABASE IF EXISTS ${quoteIdent(name)} WITH (FORCE)`));
54
+ }
55
+ // Empties a fixed shadow database in place, for a role that can't create databases: every
56
+ // schema but Postgres's own is dropped, extensions with it, and `public` is recreated.
57
+ export async function wipeShadowDatabase(url) {
58
+ assertShadowName(databaseName(url));
59
+ await withClient(url, async (client) => {
60
+ const { rows } = await client.query(`SELECT nspname FROM pg_namespace
61
+ WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
62
+ ORDER BY nspname`);
63
+ for (const { nspname } of rows) {
64
+ await client.query(`DROP SCHEMA ${quoteIdent(nspname)} CASCADE`);
65
+ }
66
+ await client.query('CREATE SCHEMA public');
67
+ });
68
+ }
@@ -0,0 +1,24 @@
1
+ export type EngineCall<T> = {
2
+ result: T;
3
+ printed: string[];
4
+ };
5
+ export type Engine = {
6
+ request<T>(method: string, params: unknown): Promise<EngineCall<T>>;
7
+ close(): Promise<void>;
8
+ };
9
+ export declare class EngineError extends Error {
10
+ name: string;
11
+ readonly code: number;
12
+ readonly data: {
13
+ error_code?: string;
14
+ message?: string;
15
+ is_panic?: boolean;
16
+ } | undefined;
17
+ constructor(code: number, message: string, data: EngineError['data']);
18
+ }
19
+ export declare function startSchemaEngine(binary: string, cwd: string): Engine;
20
+ export declare function startEngine(options: {
21
+ command: string;
22
+ args: string[];
23
+ cwd: string;
24
+ }): Engine;
@@ -0,0 +1,123 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ // A JSON-RPC error from the engine. `data` carries the engine's own error, such as
4
+ // `{ error_code: 'P1003', message: 'Database … does not exist' }`.
5
+ export class EngineError extends Error {
6
+ name = 'EngineError';
7
+ code;
8
+ data;
9
+ constructor(code, message, data) {
10
+ super(data?.message ?? message);
11
+ this.code = code;
12
+ this.data = data;
13
+ }
14
+ }
15
+ // Spawns the native schema engine. The 7.x engine won't start without `--datasource`; the
16
+ // connection travels in each request, so the flag is always `{}`, and `ps` never shows a URL.
17
+ export function startSchemaEngine(binary, cwd) {
18
+ return startEngine({ command: binary, args: ['--datasource', '{}'], cwd });
19
+ }
20
+ export function startEngine(options) {
21
+ const child = spawn(options.command, options.args, {
22
+ cwd: options.cwd,
23
+ stdio: ['pipe', 'pipe', 'pipe'],
24
+ });
25
+ let stderr = '';
26
+ child.stderr.setEncoding('utf8');
27
+ child.stderr.on('data', (chunk) => {
28
+ stderr += chunk;
29
+ });
30
+ // A write after the engine has gone fails here, and the call is rejected by `gone` instead.
31
+ child.stdin.on('error', () => { });
32
+ let nextId = 1;
33
+ let current;
34
+ let ended;
35
+ const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
36
+ const gone = (reason) => {
37
+ if (ended !== undefined)
38
+ return;
39
+ ended = new Error(`The schema engine ${reason}.${stderr.trim() === '' ? '' : `\nIts stderr:\n${stderr.trim()}`}`);
40
+ current?.reject(ended);
41
+ current = undefined;
42
+ };
43
+ child.on('error', (error) => gone(`could not run: ${error.message}`));
44
+ child.on('exit', (code, signal) => gone(`exited${signal ? ` on ${signal}` : ` with code ${code}`}`));
45
+ const lines = createInterface({ input: child.stdout });
46
+ lines.on('close', () => gone('closed its output'));
47
+ lines.on('line', (line) => {
48
+ if (line.trim() === '')
49
+ return;
50
+ let message;
51
+ try {
52
+ message = JSON.parse(line);
53
+ }
54
+ catch {
55
+ stderr += `${line}\n`;
56
+ return;
57
+ }
58
+ if (typeof message.method === 'string') {
59
+ // A request from the engine. A notification, with no id, needs no answer.
60
+ if (message.id === undefined || message.id === null)
61
+ return;
62
+ if (message.method === 'print') {
63
+ if (typeof message.params?.content === 'string') {
64
+ current?.printed.push(message.params.content);
65
+ }
66
+ send({ id: message.id, jsonrpc: '2.0', result: {} });
67
+ }
68
+ else {
69
+ send({
70
+ id: message.id,
71
+ jsonrpc: '2.0',
72
+ error: { code: -32601, message: `sequel does not handle ${message.method}` },
73
+ });
74
+ }
75
+ return;
76
+ }
77
+ if (current !== undefined && message.id === current.id)
78
+ current.resolve(message);
79
+ });
80
+ let queue = Promise.resolve();
81
+ const call = (method, params) => new Promise((resolve, reject) => {
82
+ if (ended !== undefined) {
83
+ reject(ended);
84
+ return;
85
+ }
86
+ const id = nextId++;
87
+ const printed = [];
88
+ current = {
89
+ id,
90
+ printed,
91
+ resolve: (message) => {
92
+ current = undefined;
93
+ if (message.error !== undefined) {
94
+ const { code, message: text, data } = message.error;
95
+ reject(new EngineError(code, text, data));
96
+ }
97
+ else {
98
+ resolve({ result: message.result, printed });
99
+ }
100
+ },
101
+ reject,
102
+ };
103
+ send({ jsonrpc: '2.0', id, method, params });
104
+ });
105
+ return {
106
+ request(method, params) {
107
+ const next = queue.then(() => call(method, params));
108
+ queue = next.catch(() => { });
109
+ return next;
110
+ },
111
+ async close() {
112
+ ended ??= new Error('The schema engine was closed.');
113
+ if (child.exitCode !== null || child.signalCode !== null)
114
+ return;
115
+ const exited = new Promise((resolve) => child.once('exit', () => resolve()));
116
+ child.stdin.end();
117
+ const timer = setTimeout(() => child.kill('SIGKILL'), 2_000);
118
+ child.kill('SIGTERM');
119
+ await exited;
120
+ clearTimeout(timer);
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,12 @@
1
+ import type { SchemaFile } from '../schema/schema.ts';
2
+ import { type Engine } from './client.ts';
3
+ export type DiffResult = {
4
+ changed: boolean;
5
+ sql: string;
6
+ };
7
+ export type DiffFrom = {
8
+ url: string;
9
+ historySchema: string;
10
+ } | 'empty';
11
+ export declare function diffToSchema(engine: Engine, from: DiffFrom, files: SchemaFile[]): Promise<DiffResult>;
12
+ export declare function engineUrl(url: string): string;
@@ -0,0 +1,51 @@
1
+ import pg from 'pg';
2
+ import { databaseName, redactUrl } from '../db/connection.js';
3
+ import { SequelError } from '../errors.js';
4
+ import { HISTORY_TABLE } from '../migrate/history.js';
5
+ import { EngineError } from './client.js';
6
+ // TECH-SPEC §7: the engine's `diff`, from a database (a shadow with the migrations replayed
7
+ // into it), or from nothing, to the schema folder.
8
+ export async function diffToSchema(engine, from, files) {
9
+ const params = {
10
+ from: from === 'empty' ? { tag: 'empty' } : { tag: 'url', url: engineUrl(from.url) },
11
+ to: {
12
+ tag: 'schemaDatamodel',
13
+ files: files.map((file) => ({ path: file.relative, content: file.content })),
14
+ },
15
+ script: true,
16
+ exitCode: true,
17
+ filters: {
18
+ // The engine ignores only `_prisma_migrations`, so it would drop sequel's history as drift.
19
+ externalTables: from === 'empty' ? [] : [`${from.historySchema}.${HISTORY_TABLE}`],
20
+ externalEnums: [],
21
+ },
22
+ };
23
+ try {
24
+ const { result, printed } = await engine.request('diff', params);
25
+ return {
26
+ changed: result.exitCode === 2,
27
+ sql: printed.map((content) => `${content}\n`).join(''),
28
+ };
29
+ }
30
+ catch (error) {
31
+ const source = from === 'empty' ? 'an empty database' : databaseName(from.url);
32
+ if (error instanceof EngineError) {
33
+ const code = error.data?.error_code;
34
+ throw new SequelError(`The schema engine could not diff ${source} against the schema folder: ${code === undefined ? '' : `${code}: `}${error.message}`);
35
+ }
36
+ throw new SequelError(`The schema engine failed while diffing ${source} against the schema folder: ${error.message}`);
37
+ }
38
+ }
39
+ // The engine doesn't default the user as `pg` does, so a URL without one gets `pg`'s default,
40
+ // such as the OS user, before the engine sees it.
41
+ export function engineUrl(url) {
42
+ const parsed = new URL(url);
43
+ if (parsed.username === '') {
44
+ const user = process.env.PGUSER || pg.defaults.user;
45
+ if (user === undefined || user === '') {
46
+ throw new SequelError(`${redactUrl(url)} names no user, and there is no default to fill in. Put the user in the URL.`);
47
+ }
48
+ parsed.username = encodeURIComponent(user);
49
+ }
50
+ return parsed.toString();
51
+ }
@@ -0,0 +1,3 @@
1
+ export declare class SequelError extends Error {
2
+ name: string;
3
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,5 @@
1
+ // An expected failure: the CLI prints its message, with no stack, and exits 1.
2
+ // The message names the file, query or statement, and what to do about it.
3
+ export class SequelError extends Error {
4
+ name = 'SequelError';
5
+ }
@@ -0,0 +1,3 @@
1
+ import type pg from 'pg';
2
+ export declare function readExtrasSnapshot(url: string): Promise<string>;
3
+ export declare function extrasSnapshot(client: pg.Client): Promise<string>;
@@ -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
+ }