@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.
- package/LICENSE +202 -0
- package/README.md +227 -2
- package/THIRD_PARTY_NOTICES.md +268 -0
- package/binaries.json +106 -0
- package/dist/binaries/cache.d.ts +6 -0
- package/dist/binaries/cache.js +36 -0
- package/dist/binaries/ensure-binary.d.ts +10 -0
- package/dist/binaries/ensure-binary.js +85 -0
- package/dist/binaries/manifest.d.ts +30 -0
- package/dist/binaries/manifest.js +112 -0
- package/dist/binaries/platform.d.ts +3 -0
- package/dist/binaries/platform.js +10 -0
- package/dist/binaries/unpack.d.ts +2 -0
- package/dist/binaries/unpack.js +35 -0
- package/dist/binaries/verify.d.ts +2 -0
- package/dist/binaries/verify.js +16 -0
- package/dist/check.d.ts +9 -0
- package/dist/check.js +178 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +339 -0
- package/dist/config/define-config.d.ts +24 -0
- package/dist/config/define-config.js +4 -0
- package/dist/config/load-config.d.ts +6 -0
- package/dist/config/load-config.js +34 -0
- package/dist/config/resolve-config.d.ts +35 -0
- package/dist/config/resolve-config.js +159 -0
- package/dist/db/connection.d.ts +6 -0
- package/dist/db/connection.js +38 -0
- package/dist/db/shadow.d.ts +11 -0
- package/dist/db/shadow.js +68 -0
- package/dist/engine/client.d.ts +24 -0
- package/dist/engine/client.js +123 -0
- package/dist/engine/diff.d.ts +12 -0
- package/dist/engine/diff.js +51 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +5 -0
- package/dist/extras/snapshot.d.ts +3 -0
- package/dist/extras/snapshot.js +99 -0
- package/dist/files.d.ts +1 -0
- package/dist/files.js +15 -0
- package/dist/generate/generate-modules.d.ts +17 -0
- package/dist/generate/generate-modules.js +97 -0
- package/dist/generate/modules.d.ts +16 -0
- package/dist/generate/modules.js +118 -0
- package/dist/generate/pg-dump.d.ts +8 -0
- package/dist/generate/pg-dump.js +66 -0
- package/dist/generate/plugin-wrapper.d.ts +3 -0
- package/dist/generate/plugin-wrapper.js +20 -0
- package/dist/generate/sqlc.d.ts +27 -0
- package/dist/generate/sqlc.js +113 -0
- package/dist/generate/watch.d.ts +16 -0
- package/dist/generate/watch.js +73 -0
- package/dist/generator/generate.d.ts +21 -0
- package/dist/generator/generate.js +440 -0
- package/dist/generator/keys.d.ts +4 -0
- package/dist/generator/keys.js +24 -0
- package/dist/generator/lexer.d.ts +12 -0
- package/dist/generator/lexer.js +210 -0
- package/dist/generator/main.d.ts +1 -0
- package/dist/generator/main.js +12 -0
- package/dist/generator/pg-array-helper.d.ts +2 -0
- package/dist/generator/pg-array-helper.js +53 -0
- package/dist/generator/plugin.d.ts +6 -0
- package/dist/generator/plugin.js +26 -0
- package/dist/generator/request.d.ts +57 -0
- package/dist/generator/request.js +96 -0
- package/dist/generator/select-list.d.ts +27 -0
- package/dist/generator/select-list.js +580 -0
- package/dist/generator/strings.d.ts +2 -0
- package/dist/generator/strings.js +34 -0
- package/dist/generator/types.d.ts +23 -0
- package/dist/generator/types.js +216 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/migrate/adopt-prisma.d.ts +23 -0
- package/dist/migrate/adopt-prisma.js +119 -0
- package/dist/migrate/deploy.d.ts +7 -0
- package/dist/migrate/deploy.js +23 -0
- package/dist/migrate/drift.d.ts +10 -0
- package/dist/migrate/drift.js +36 -0
- package/dist/migrate/files.d.ts +19 -0
- package/dist/migrate/files.js +97 -0
- package/dist/migrate/history.d.ts +20 -0
- package/dist/migrate/history.js +54 -0
- package/dist/migrate/lint.d.ts +21 -0
- package/dist/migrate/lint.js +153 -0
- package/dist/migrate/new.d.ts +13 -0
- package/dist/migrate/new.js +41 -0
- package/dist/migrate/runner.d.ts +28 -0
- package/dist/migrate/runner.js +124 -0
- package/dist/migrate/sql-text.d.ts +33 -0
- package/dist/migrate/sql-text.js +233 -0
- package/dist/schema/schema.d.ts +16 -0
- package/dist/schema/schema.js +111 -0
- package/dist/schema/unsupported.d.ts +3 -0
- package/dist/schema/unsupported.js +36 -0
- package/dist/schema/wasm.d.ts +8 -0
- package/dist/schema/wasm.js +32 -0
- package/package.json +50 -4
- package/vendor/prisma-schema-wasm/LICENSE +201 -0
- package/vendor/prisma-schema-wasm/README.md +32 -0
- package/vendor/prisma-schema-wasm/package.json +3 -0
- package/vendor/prisma-schema-wasm/prisma_schema_build.js +539 -0
- package/vendor/prisma-schema-wasm/prisma_schema_build_bg.wasm +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { SequelError } from '../errors.js';
|
|
13
|
+
import { resolveConfig } from './resolve-config.js';
|
|
14
|
+
export const CONFIG_FILE = 'sequel.config.ts';
|
|
15
|
+
// Loads `sequel.config.ts` from `cwd`, or the file `--config` names, by dynamic import under
|
|
16
|
+
// Node's type stripping, then validates it and resolves its paths.
|
|
17
|
+
export async function loadConfig(options = {}) {
|
|
18
|
+
const cwd = options.cwd ?? process.cwd();
|
|
19
|
+
const file = path.resolve(cwd, options.config ?? CONFIG_FILE);
|
|
20
|
+
if (!existsSync(file)) {
|
|
21
|
+
throw new SequelError(options.config === undefined
|
|
22
|
+
? `No ${CONFIG_FILE} in ${cwd}. Create one that default-exports \`defineConfig({ … })\`, or pass --config <file>.`
|
|
23
|
+
: `The config file ${file}, named by --config, does not exist.`);
|
|
24
|
+
}
|
|
25
|
+
let module;
|
|
26
|
+
try {
|
|
27
|
+
module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
31
|
+
throw new SequelError(`${file} failed to load: ${reason}\nIt runs under Node's type stripping, so it may use only erasable TypeScript syntax.`);
|
|
32
|
+
}
|
|
33
|
+
return resolveConfig(module.default, file);
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BigintMode } from './define-config.ts';
|
|
2
|
+
export type DatabaseRole = 'target' | 'test' | 'shadow';
|
|
3
|
+
export type ResolvedConfig = {
|
|
4
|
+
configFile: string;
|
|
5
|
+
root: string;
|
|
6
|
+
schema: string;
|
|
7
|
+
migrations: string;
|
|
8
|
+
queries: string[];
|
|
9
|
+
snapshot: string;
|
|
10
|
+
databases: Record<DatabaseRole, string | undefined>;
|
|
11
|
+
timeouts: {
|
|
12
|
+
lockMs: number;
|
|
13
|
+
statementMs: number;
|
|
14
|
+
};
|
|
15
|
+
lint: {
|
|
16
|
+
cutover: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
types: {
|
|
19
|
+
bigint: BigintMode;
|
|
20
|
+
};
|
|
21
|
+
externalMigrations: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare const DEFAULTS: {
|
|
24
|
+
readonly schema: 'prisma';
|
|
25
|
+
readonly migrations: 'prisma/migrations';
|
|
26
|
+
readonly queries: readonly ['src/**/queries'];
|
|
27
|
+
readonly snapshotFile: 'snapshot.sql';
|
|
28
|
+
readonly timeouts: {
|
|
29
|
+
readonly lockMs: 5000;
|
|
30
|
+
readonly statementMs: 60000;
|
|
31
|
+
};
|
|
32
|
+
readonly bigint: 'string';
|
|
33
|
+
};
|
|
34
|
+
export declare function resolveConfig(value: unknown, configFile: string): ResolvedConfig;
|
|
35
|
+
export declare function requireDatabase(config: ResolvedConfig, role: DatabaseRole): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { SequelError } from '../errors.js';
|
|
3
|
+
export const DEFAULTS = {
|
|
4
|
+
schema: 'prisma',
|
|
5
|
+
migrations: 'prisma/migrations',
|
|
6
|
+
queries: ['src/**/queries'],
|
|
7
|
+
snapshotFile: 'snapshot.sql',
|
|
8
|
+
timeouts: { lockMs: 5_000, statementMs: 60_000 },
|
|
9
|
+
bigint: 'string',
|
|
10
|
+
};
|
|
11
|
+
const KEYS = {
|
|
12
|
+
'': [
|
|
13
|
+
'schema',
|
|
14
|
+
'migrations',
|
|
15
|
+
'queries',
|
|
16
|
+
'snapshot',
|
|
17
|
+
'databases',
|
|
18
|
+
'timeouts',
|
|
19
|
+
'lint',
|
|
20
|
+
'types',
|
|
21
|
+
'externalMigrations',
|
|
22
|
+
],
|
|
23
|
+
databases: ['target', 'test', 'shadow'],
|
|
24
|
+
timeouts: ['lockMs', 'statementMs'],
|
|
25
|
+
lint: ['cutover'],
|
|
26
|
+
types: ['bigint'],
|
|
27
|
+
};
|
|
28
|
+
const BIGINT_MODES = ['string', 'number', 'bigint'];
|
|
29
|
+
const IDENTITY = /^\d{14}_[a-z0-9_]+$/;
|
|
30
|
+
// Validates a loaded config and applies the defaults. `configFile` is absolute, and every
|
|
31
|
+
// path in the config resolves against its folder.
|
|
32
|
+
export function resolveConfig(value, configFile) {
|
|
33
|
+
const file = path.basename(configFile);
|
|
34
|
+
const fail = (key, problem) => {
|
|
35
|
+
throw new SequelError(`${file}: \`${key}\` ${problem}`);
|
|
36
|
+
};
|
|
37
|
+
const config = section(value, '', file, fail);
|
|
38
|
+
const databases = section(config.databases, 'databases', file, fail);
|
|
39
|
+
const timeouts = section(config.timeouts, 'timeouts', file, fail);
|
|
40
|
+
const lint = section(config.lint, 'lint', file, fail);
|
|
41
|
+
const types = section(config.types, 'types', file, fail);
|
|
42
|
+
const root = path.dirname(configFile);
|
|
43
|
+
const resolve = (relative) => path.resolve(root, relative);
|
|
44
|
+
const schema = resolve(pathValue(config.schema, 'schema', fail) ?? DEFAULTS.schema);
|
|
45
|
+
const migrations = resolve(pathValue(config.migrations, 'migrations', fail) ?? DEFAULTS.migrations);
|
|
46
|
+
const snapshotPath = pathValue(config.snapshot, 'snapshot', fail);
|
|
47
|
+
const snapshot = snapshotPath ? resolve(snapshotPath) : path.join(schema, DEFAULTS.snapshotFile);
|
|
48
|
+
const queryGlobs = config.queries === undefined ? DEFAULTS.queries : config.queries;
|
|
49
|
+
const queryList = typeof queryGlobs === 'string' ? [queryGlobs] : queryGlobs;
|
|
50
|
+
if (!Array.isArray(queryList) || queryList.length === 0) {
|
|
51
|
+
fail('queries', 'must be a glob or a non-empty list of globs, such as `src/**/queries`.');
|
|
52
|
+
}
|
|
53
|
+
const queries = queryList.map((glob, i) => resolve(pathValue(glob, `queries[${i}]`, fail)));
|
|
54
|
+
const resolvedDatabases = {
|
|
55
|
+
target: connection(databases.target, 'target', fail),
|
|
56
|
+
test: connection(databases.test, 'test', fail),
|
|
57
|
+
shadow: connection(databases.shadow, 'shadow', fail),
|
|
58
|
+
};
|
|
59
|
+
if (resolvedDatabases.shadow !== undefined) {
|
|
60
|
+
const name = databaseName(resolvedDatabases.shadow);
|
|
61
|
+
if (!name.endsWith('_shadow')) {
|
|
62
|
+
fail('databases.shadow', `names the database \`${name}\`, but a shadow database's name must end in \`_shadow\`, because sequel wipes it at each use. Point it at a database such as \`${name || 'app'}_shadow\`.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const cutover = lint.cutover;
|
|
66
|
+
if (cutover !== undefined && (typeof cutover !== 'string' || !IDENTITY.test(cutover))) {
|
|
67
|
+
fail('lint.cutover', 'must be a migration identity, `<YYYYMMDDHHMMSS>_<name>`: the file name of the newest migration not to lint, without `.sql`.');
|
|
68
|
+
}
|
|
69
|
+
const bigint = types.bigint ?? DEFAULTS.bigint;
|
|
70
|
+
if (!BIGINT_MODES.includes(bigint)) {
|
|
71
|
+
fail('types.bigint', `must be one of ${BIGINT_MODES.map((m) => `'${m}'`).join(', ')}.`);
|
|
72
|
+
}
|
|
73
|
+
const external = config.externalMigrations ?? false;
|
|
74
|
+
if (typeof external !== 'boolean') {
|
|
75
|
+
fail('externalMigrations', 'must be `true` or `false`: `true` when another tool manages the schema and migrations.');
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
configFile,
|
|
79
|
+
root,
|
|
80
|
+
schema,
|
|
81
|
+
migrations,
|
|
82
|
+
queries,
|
|
83
|
+
snapshot,
|
|
84
|
+
databases: resolvedDatabases,
|
|
85
|
+
timeouts: {
|
|
86
|
+
lockMs: milliseconds(timeouts.lockMs, 'timeouts.lockMs', fail) ?? DEFAULTS.timeouts.lockMs,
|
|
87
|
+
statementMs: milliseconds(timeouts.statementMs, 'timeouts.statementMs', fail) ??
|
|
88
|
+
DEFAULTS.timeouts.statementMs,
|
|
89
|
+
},
|
|
90
|
+
lint: { cutover: cutover },
|
|
91
|
+
types: { bigint: bigint },
|
|
92
|
+
externalMigrations: external,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Returns the connection string for a database a command needs, or explains how to set it.
|
|
96
|
+
export function requireDatabase(config, role) {
|
|
97
|
+
const url = config.databases[role];
|
|
98
|
+
if (url === undefined) {
|
|
99
|
+
throw new SequelError(`${path.basename(config.configFile)}: \`databases.${role}\` is not set. Set the environment variable the config reads it from.`);
|
|
100
|
+
}
|
|
101
|
+
return url;
|
|
102
|
+
}
|
|
103
|
+
function section(value, key, file, fail) {
|
|
104
|
+
if (value === undefined && key !== '')
|
|
105
|
+
return {};
|
|
106
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
107
|
+
if (key === '') {
|
|
108
|
+
throw new SequelError(`${file} must default-export its config: \`export default defineConfig({ … })\`.`);
|
|
109
|
+
}
|
|
110
|
+
fail(key, 'must be an object.');
|
|
111
|
+
}
|
|
112
|
+
const record = value;
|
|
113
|
+
const known = KEYS[key] ?? [];
|
|
114
|
+
for (const name of Object.keys(record).sort()) {
|
|
115
|
+
if (!known.includes(name)) {
|
|
116
|
+
const full = key ? `${key}.${name}` : name;
|
|
117
|
+
fail(full, `is not a config key. Known keys${key ? ` of \`${key}\`` : ''}: ${known.join(', ')}.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return record;
|
|
121
|
+
}
|
|
122
|
+
function pathValue(value, key, fail) {
|
|
123
|
+
if (value === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
126
|
+
fail(key, 'must be a non-empty path.');
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
function connection(value, role, fail) {
|
|
130
|
+
// An unset environment variable reads as undefined, or as '' in some shells and CI systems.
|
|
131
|
+
if (value === undefined || value === '')
|
|
132
|
+
return undefined;
|
|
133
|
+
const key = `databases.${role}`;
|
|
134
|
+
if (typeof value !== 'string')
|
|
135
|
+
fail(key, 'must be a connection string.');
|
|
136
|
+
const url = value;
|
|
137
|
+
let protocol;
|
|
138
|
+
try {
|
|
139
|
+
protocol = new URL(url).protocol;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return fail(key, 'is not a valid connection URL, such as `postgres://user@host:5432/db`.');
|
|
143
|
+
}
|
|
144
|
+
if (protocol !== 'postgres:' && protocol !== 'postgresql:') {
|
|
145
|
+
fail(key, 'must be a `postgres://` or `postgresql://` URL.');
|
|
146
|
+
}
|
|
147
|
+
return url;
|
|
148
|
+
}
|
|
149
|
+
function databaseName(url) {
|
|
150
|
+
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
151
|
+
}
|
|
152
|
+
function milliseconds(value, key, fail) {
|
|
153
|
+
if (value === undefined)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
156
|
+
fail(key, 'must be a positive whole number of milliseconds.');
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
export declare function withClient<T>(url: string, fn: (client: pg.Client) => Promise<T>): Promise<T>;
|
|
3
|
+
export declare function databaseName(url: string): string;
|
|
4
|
+
export declare function withDatabaseName(url: string, name: string): string;
|
|
5
|
+
export declare function quoteIdent(name: string): string;
|
|
6
|
+
export declare function redactUrl(url: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
// Runs `fn` with one connection to `url`, and always closes it.
|
|
3
|
+
export async function withClient(url, fn) {
|
|
4
|
+
const client = new pg.Client({ connectionString: url });
|
|
5
|
+
await client.connect();
|
|
6
|
+
try {
|
|
7
|
+
return await fn(client);
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
await client.end().catch(() => { });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// The database a connection URL names, such as `app_test` in `postgres://host/app_test`.
|
|
14
|
+
export function databaseName(url) {
|
|
15
|
+
return decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
|
|
16
|
+
}
|
|
17
|
+
// The same server and credentials, connecting to another database.
|
|
18
|
+
export function withDatabaseName(url, name) {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
parsed.pathname = `/${encodeURIComponent(name)}`;
|
|
21
|
+
return parsed.toString();
|
|
22
|
+
}
|
|
23
|
+
// A Postgres identifier, quoted, for the statements that can't take parameters.
|
|
24
|
+
export function quoteIdent(name) {
|
|
25
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
26
|
+
}
|
|
27
|
+
// The URL with any password replaced, for messages.
|
|
28
|
+
export function redactUrl(url) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new URL(url);
|
|
31
|
+
if (parsed.password !== '')
|
|
32
|
+
parsed.password = '***';
|
|
33
|
+
return parsed.toString();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return '<invalid URL>';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config/resolve-config.ts';
|
|
2
|
+
export declare function assertShadowName(name: string): void;
|
|
3
|
+
export type ShadowOptions = {
|
|
4
|
+
fixedUrl: string;
|
|
5
|
+
} | {
|
|
6
|
+
testUrl: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function shadowOptions(config: ResolvedConfig): ShadowOptions;
|
|
9
|
+
export declare function withShadowDatabase<T>(options: ShadowOptions, fn: (url: string) => Promise<T>): Promise<T>;
|
|
10
|
+
export declare function dropShadowDatabase(serverUrl: string, name: string): Promise<void>;
|
|
11
|
+
export declare function wipeShadowDatabase(url: string): Promise<void>;
|
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED