@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,153 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { SequelError } from '../errors.js';
|
|
7
|
+
import { compare } from './files.js';
|
|
8
|
+
import { addedEnumValue, buildsIndexConcurrently, indexWithoutUsing, noTransactionMarker, } from './sql-text.js';
|
|
9
|
+
// TECH-SPEC §8: sequel's default Squawk config, for an app with no `.squawk.toml`. The excluded
|
|
10
|
+
// rules fire on Prisma's own output, or on timeouts the runner sets itself.
|
|
11
|
+
export const DEFAULT_SQUAWK_CONFIG = `pg_version = "18.0"
|
|
12
|
+
excluded_rules = [
|
|
13
|
+
"prefer-bigint-over-int",
|
|
14
|
+
"prefer-identity",
|
|
15
|
+
"require-enum-value-ordering",
|
|
16
|
+
"require-lock-timeout",
|
|
17
|
+
"require-statement-timeout",
|
|
18
|
+
]
|
|
19
|
+
`;
|
|
20
|
+
export const SQUAWK_CONFIG_FILE = '.squawk.toml';
|
|
21
|
+
// The migrations `migrate lint` and `check` lint: those after the cut-over. Files at or before
|
|
22
|
+
// it are already applied, and their checksums are fixed.
|
|
23
|
+
export function migrationsToLint(migrations, cutover) {
|
|
24
|
+
return cutover === undefined
|
|
25
|
+
? migrations
|
|
26
|
+
: migrations.filter((m) => compare(m.identity, cutover) > 0);
|
|
27
|
+
}
|
|
28
|
+
// Squawk, then sequel's own rules, over the files. Transactional files are linted as running
|
|
29
|
+
// in a transaction, and no-transaction files as not.
|
|
30
|
+
export async function lintMigrations(options) {
|
|
31
|
+
const { migrations, root } = options;
|
|
32
|
+
if (migrations.length === 0)
|
|
33
|
+
return [];
|
|
34
|
+
const findings = [];
|
|
35
|
+
const appConfig = path.join(root, SQUAWK_CONFIG_FILE);
|
|
36
|
+
const workDir = existsSync(appConfig)
|
|
37
|
+
? undefined
|
|
38
|
+
: await mkdtemp(path.join(tmpdir(), 'sequel-squawk-'));
|
|
39
|
+
try {
|
|
40
|
+
let config = appConfig;
|
|
41
|
+
if (workDir !== undefined) {
|
|
42
|
+
config = path.join(workDir, SQUAWK_CONFIG_FILE);
|
|
43
|
+
await writeFile(config, DEFAULT_SQUAWK_CONFIG);
|
|
44
|
+
}
|
|
45
|
+
for (const inTransaction of [true, false]) {
|
|
46
|
+
const group = migrations.filter((m) => m.noTransaction !== inTransaction);
|
|
47
|
+
if (group.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
findings.push(...(await runSquawk(options.squawk, config, group, inTransaction, root)));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (workDir !== undefined)
|
|
54
|
+
await rm(workDir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
for (const migration of migrations)
|
|
57
|
+
findings.push(...ownRules(migration, options.unsupported));
|
|
58
|
+
return findings.sort((a, b) => compare(a.shown, b.shown) ||
|
|
59
|
+
a.line - b.line ||
|
|
60
|
+
a.column - b.column ||
|
|
61
|
+
compare(a.rule, b.rule));
|
|
62
|
+
}
|
|
63
|
+
export function formatFinding(finding) {
|
|
64
|
+
return `${finding.shown}:${finding.line}:${finding.column}: ${finding.rule}: ${finding.message}`;
|
|
65
|
+
}
|
|
66
|
+
function runSquawk(squawk, config, migrations, inTransaction, root) {
|
|
67
|
+
const byFile = new Map(migrations.map((m) => [m.file, m]));
|
|
68
|
+
const args = [
|
|
69
|
+
'--config',
|
|
70
|
+
config,
|
|
71
|
+
'--reporter',
|
|
72
|
+
'json',
|
|
73
|
+
inTransaction ? '--assume-in-transaction' : '--no-assume-in-transaction',
|
|
74
|
+
...migrations.map((m) => m.file),
|
|
75
|
+
];
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
execFile(squawk, args, { cwd: root, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
78
|
+
if (error !== null && typeof error.code !== 'number') {
|
|
79
|
+
reject(error);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
let violations;
|
|
83
|
+
try {
|
|
84
|
+
violations = stdout.trim() === '' ? [] : JSON.parse(stdout);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
reject(new SequelError(`Squawk failed (exit ${error?.code ?? 0}): ${(stderr || stdout).trim()}`));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (error !== null && violations.length === 0) {
|
|
91
|
+
reject(new SequelError(`Squawk failed (exit ${error.code}): ${(stderr || stdout).trim()}`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Squawk counts lines and columns from 0.
|
|
95
|
+
resolve(violations.map((v) => ({
|
|
96
|
+
shown: byFile.get(path.resolve(root, v.file))?.shown ?? path.relative(root, v.file),
|
|
97
|
+
line: v.line + 1,
|
|
98
|
+
column: v.column + 1,
|
|
99
|
+
rule: v.rule_name,
|
|
100
|
+
message: v.help ? `${v.message} (${v.help})` : v.message,
|
|
101
|
+
})));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// sequel's own rules. None can be suppressed: Postgres or the runner would reject the files
|
|
106
|
+
// most of them flag, and the index rule is satisfied by writing the method, even `USING btree`.
|
|
107
|
+
export function ownRules(migration, unsupported = new Map()) {
|
|
108
|
+
const findings = [];
|
|
109
|
+
const { statements } = migration.scanned;
|
|
110
|
+
const at = (line, rule, message) => findings.push({ shown: migration.shown, line, column: 1, rule, message });
|
|
111
|
+
statements.forEach((statement, i) => {
|
|
112
|
+
const value = addedEnumValue(statement);
|
|
113
|
+
if (value === undefined)
|
|
114
|
+
return;
|
|
115
|
+
for (const later of statements.slice(i + 1)) {
|
|
116
|
+
if (later.tokens.some((t) => t.kind === 'string' && t.value === value)) {
|
|
117
|
+
at(later.line, 'enum-value-used-in-same-file', `the enum value '${value}' is added on line ${statement.line} of this file and used here. Postgres won't use a new enum value until the transaction that adds it commits: move this statement into a later migration.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (migration.noTransaction && statements.length > 1) {
|
|
122
|
+
at(statements[1]?.line ?? 1, 'no-transaction-multiple-statements', `this no-transaction file holds ${statements.length} statements. A no-transaction file holds exactly one: split it into one file per statement.`);
|
|
123
|
+
}
|
|
124
|
+
// Squawk flags this only after an explicit BEGIN, not under --assume-in-transaction.
|
|
125
|
+
if (!migration.noTransaction) {
|
|
126
|
+
for (const statement of statements.filter(buildsIndexConcurrently)) {
|
|
127
|
+
at(statement.line, 'concurrent-index-in-transaction', 'this builds an index concurrently, which Postgres refuses inside a transaction, and this file runs in one. Put it alone in a file with `-- sequel:no-transaction` above its first statement.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const statement of statements) {
|
|
131
|
+
const index = indexWithoutUsing(statement);
|
|
132
|
+
const columns = index === undefined ? undefined : unsupported.get(index.table);
|
|
133
|
+
if (index === undefined || columns === undefined)
|
|
134
|
+
continue;
|
|
135
|
+
for (const column of new Set(index.identifiers)) {
|
|
136
|
+
const type = columns.get(column);
|
|
137
|
+
if (type === undefined)
|
|
138
|
+
continue;
|
|
139
|
+
at(statement.line, 'unsupported-column-index-without-using', `this index on ${index.table}.${column}, which the schema declares as Unsupported("${type}"), gives no USING, so Postgres builds a btree index. Give the access method and operator class, such as \`USING hnsw ("${column}" vector_cosine_ops)\` for a pgvector column, or write \`USING btree\` if a btree index is what you mean.`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const line of noTransactionMarker(migration.scanned).misplacedLines) {
|
|
143
|
+
at(line, 'no-transaction-marker-after-statement', 'the `-- sequel:no-transaction` marker comes after the first statement, so it marks nothing. Move it above the first statement.');
|
|
144
|
+
}
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
// The message for a lint run with findings: one line each, then how to deal with them.
|
|
148
|
+
export function lintFailureMessage(findings) {
|
|
149
|
+
return [
|
|
150
|
+
...findings.map(formatFinding),
|
|
151
|
+
`${findings.length} lint finding${findings.length === 1 ? '' : 's'}. Fix each, or suppress a Squawk finding with \`-- squawk-ignore <rule> -- <reason>\` on the line above it. sequel's own rules can't be suppressed: change the file.`,
|
|
152
|
+
].join('\n');
|
|
153
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config/resolve-config.ts';
|
|
2
|
+
import { type LintFinding } from './lint.ts';
|
|
3
|
+
export type NewMigration = {
|
|
4
|
+
file: string | undefined;
|
|
5
|
+
findings: LintFinding[];
|
|
6
|
+
};
|
|
7
|
+
export declare function newMigration(options: {
|
|
8
|
+
config: ResolvedConfig;
|
|
9
|
+
name: string;
|
|
10
|
+
now?: Date;
|
|
11
|
+
engineBinary?: string;
|
|
12
|
+
squawk?: string;
|
|
13
|
+
}): Promise<NewMigration>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureBinary } from '../binaries/ensure-binary.js';
|
|
4
|
+
import { SequelError } from '../errors.js';
|
|
5
|
+
import { assertValidSchema, readSchemaFiles } from '../schema/schema.js';
|
|
6
|
+
import { unsupportedColumns } from '../schema/unsupported.js';
|
|
7
|
+
import { diffMigrationsToSchema } from './drift.js';
|
|
8
|
+
import { findMigrations, migrationIdentity, readMigrationFile } from './files.js';
|
|
9
|
+
import { lintMigrations } from './lint.js';
|
|
10
|
+
// TECH-SPEC §8: diff the migrations, replayed in a shadow database, against the schema folder,
|
|
11
|
+
// write the difference as a new file at the top of the migrations folder, and lint it. It
|
|
12
|
+
// never connects to the target database.
|
|
13
|
+
export async function newMigration(options) {
|
|
14
|
+
const { config } = options;
|
|
15
|
+
const identity = migrationIdentity(options.now ?? new Date(), options.name);
|
|
16
|
+
const files = await readSchemaFiles(config.schema, config.root);
|
|
17
|
+
assertValidSchema(files, config.root);
|
|
18
|
+
const migrations = await findMigrations(config.migrations, { root: config.root });
|
|
19
|
+
const file = path.join(config.migrations, `${identity}.sql`);
|
|
20
|
+
const clash = migrations.find((m) => m.identity === identity);
|
|
21
|
+
if (clash !== undefined) {
|
|
22
|
+
throw new SequelError(`${clash.shown} already exists. Wait a second and run \`sequel migrate new\` again, or choose another name.`);
|
|
23
|
+
}
|
|
24
|
+
const diff = await diffMigrationsToSchema({
|
|
25
|
+
config,
|
|
26
|
+
migrations,
|
|
27
|
+
files,
|
|
28
|
+
...(options.engineBinary === undefined ? {} : { engineBinary: options.engineBinary }),
|
|
29
|
+
});
|
|
30
|
+
if (!diff.changed)
|
|
31
|
+
return { file: undefined, findings: [] };
|
|
32
|
+
await mkdir(config.migrations, { recursive: true });
|
|
33
|
+
await writeFile(file, diff.sql, { flag: 'wx' });
|
|
34
|
+
const findings = await lintMigrations({
|
|
35
|
+
squawk: options.squawk ?? (await ensureBinary('squawk')),
|
|
36
|
+
migrations: [await readMigrationFile(file, config.root)],
|
|
37
|
+
root: config.root,
|
|
38
|
+
unsupported: unsupportedColumns(files),
|
|
39
|
+
});
|
|
40
|
+
return { file, findings };
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Migration } from './files.ts';
|
|
2
|
+
export type DeployOptions = {
|
|
3
|
+
url: string;
|
|
4
|
+
migrations: Migration[];
|
|
5
|
+
timeouts: {
|
|
6
|
+
lockMs: number;
|
|
7
|
+
statementMs: number;
|
|
8
|
+
};
|
|
9
|
+
allowOutOfOrder?: boolean;
|
|
10
|
+
log?: (message: string) => void;
|
|
11
|
+
lockWaitMs?: number;
|
|
12
|
+
};
|
|
13
|
+
export type DeployResult = {
|
|
14
|
+
applied: Migration[];
|
|
15
|
+
newerApplied: string[];
|
|
16
|
+
historySchema: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function deployMigrations(options: DeployOptions): Promise<DeployResult>;
|
|
19
|
+
type Plan = {
|
|
20
|
+
pending: Migration[];
|
|
21
|
+
newerApplied: string[];
|
|
22
|
+
problems: string[];
|
|
23
|
+
};
|
|
24
|
+
export declare function planDeploy(migrations: Migration[], history: {
|
|
25
|
+
name: string;
|
|
26
|
+
sha256: string;
|
|
27
|
+
}[], database: string, allowOutOfOrder?: boolean): Plan;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { databaseName, withClient } from '../db/connection.js';
|
|
2
|
+
import { SequelError } from '../errors.js';
|
|
3
|
+
import { compare } from './files.js';
|
|
4
|
+
import { acquireLock, createHistoryTable, historyState, readHistory, recordMigration, releaseLock, } from './history.js';
|
|
5
|
+
import { buildsIndexConcurrently, lineAt } from './sql-text.js';
|
|
6
|
+
// TECH-SPEC §8's runner: one connection, sequel's advisory lock for the whole run, every check
|
|
7
|
+
// before anything is applied, then one transaction per file.
|
|
8
|
+
export async function deployMigrations(options) {
|
|
9
|
+
const database = databaseName(options.url);
|
|
10
|
+
return withClient(options.url, async (client) => {
|
|
11
|
+
client.on('error', () => { });
|
|
12
|
+
await acquireLock(client, database, options.lockWaitMs);
|
|
13
|
+
try {
|
|
14
|
+
const state = await historyState(client, database);
|
|
15
|
+
if (state.hasPrismaHistory && !state.hasHistory) {
|
|
16
|
+
throw new SequelError(`${database} has a Prisma migration history (_prisma_migrations) but no sequel history, so nothing was applied. Run \`sequel migrate adopt-prisma\` against it first.`);
|
|
17
|
+
}
|
|
18
|
+
const history = state.hasHistory ? await readHistory(client) : [];
|
|
19
|
+
const plan = planDeploy(options.migrations, history, database, options.allowOutOfOrder);
|
|
20
|
+
if (plan.problems.length > 0) {
|
|
21
|
+
throw new SequelError(`Refusing to deploy to ${database}, so nothing was applied:\n${plan.problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
22
|
+
}
|
|
23
|
+
if (!state.hasHistory)
|
|
24
|
+
await createHistoryTable(client);
|
|
25
|
+
const applied = [];
|
|
26
|
+
for (const migration of plan.pending) {
|
|
27
|
+
if (migration.noTransaction)
|
|
28
|
+
await applyWithoutTransaction(client, migration, options);
|
|
29
|
+
else
|
|
30
|
+
await applyInTransaction(client, migration, options);
|
|
31
|
+
applied.push(migration);
|
|
32
|
+
options.log?.(`Applied ${migration.shown}`);
|
|
33
|
+
}
|
|
34
|
+
return { applied, newerApplied: plan.newerApplied, historySchema: state.schema };
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await releaseLock(client).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function planDeploy(migrations, history, database, allowOutOfOrder = false) {
|
|
42
|
+
const files = new Map(migrations.map((m) => [m.identity, m]));
|
|
43
|
+
const applied = new Set(history.map((row) => row.name));
|
|
44
|
+
const newestFile = migrations.at(-1)?.identity;
|
|
45
|
+
const problems = [];
|
|
46
|
+
const newerApplied = [];
|
|
47
|
+
for (const row of history) {
|
|
48
|
+
const file = files.get(row.name);
|
|
49
|
+
if (file === undefined) {
|
|
50
|
+
if (newestFile === undefined || compare(row.name, newestFile) > 0) {
|
|
51
|
+
newerApplied.push(row.name);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
problems.push(`\`${row.name}\` is applied to ${database}, but no migration file has that name. An applied migration is never deleted or renamed: restore its file from version control.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (file.sha256 !== row.sha256) {
|
|
58
|
+
problems.push(`${file.shown} has changed since it was applied to ${database} (sha256 ${row.sha256} then, ${file.sha256} now). An applied migration is immutable: restore the file from version control, and make the change in a new migration.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const newestApplied = history
|
|
62
|
+
.map((row) => row.name)
|
|
63
|
+
.sort(compare)
|
|
64
|
+
.at(-1);
|
|
65
|
+
const pending = migrations.filter((m) => !applied.has(m.identity));
|
|
66
|
+
for (const migration of pending) {
|
|
67
|
+
if (!allowOutOfOrder &&
|
|
68
|
+
newestApplied !== undefined &&
|
|
69
|
+
compare(migration.identity, newestApplied) < 0) {
|
|
70
|
+
problems.push(`${migration.shown} is pending, but sorts before \`${newestApplied}\`, the newest migration applied to ${database}. Give it a newer timestamp, or deploy with --allow-out-of-order if it is safe to apply out of order.`);
|
|
71
|
+
}
|
|
72
|
+
const statements = migration.scanned.statements.length;
|
|
73
|
+
if (migration.noTransaction && statements > 1) {
|
|
74
|
+
problems.push(`${migration.shown} is marked \`-- sequel:no-transaction\` but holds ${statements} statements. A no-transaction file holds exactly one: split it into one file per statement.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { pending, newerApplied, problems };
|
|
78
|
+
}
|
|
79
|
+
async function applyInTransaction(client, migration, options) {
|
|
80
|
+
await client.query('BEGIN');
|
|
81
|
+
try {
|
|
82
|
+
await client.query(`SET LOCAL lock_timeout = '${options.timeouts.lockMs}ms'`);
|
|
83
|
+
await client.query(`SET LOCAL statement_timeout = '${options.timeouts.statementMs}ms'`);
|
|
84
|
+
// One simple-protocol query: pg sends a query with no parameters as text, as psql would.
|
|
85
|
+
await client.query(migration.text);
|
|
86
|
+
await recordMigration(client, migration.identity, migration.sha256);
|
|
87
|
+
await client.query('COMMIT');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
await client.query('ROLLBACK').catch(() => { });
|
|
91
|
+
throw failure(migration, error, 'Nothing of it was applied');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function applyWithoutTransaction(client, migration, options) {
|
|
95
|
+
await client.query(`SET lock_timeout = '${options.timeouts.lockMs}ms'`);
|
|
96
|
+
await client.query('SET statement_timeout = 0');
|
|
97
|
+
try {
|
|
98
|
+
await client.query(migration.text);
|
|
99
|
+
await recordMigration(client, migration.identity, migration.sha256);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
const concurrent = migration.scanned.statements.some(buildsIndexConcurrently);
|
|
103
|
+
throw failure(migration, error, concurrent
|
|
104
|
+
? 'It is not recorded. A failed concurrent index build can leave an invalid index behind: drop it with `DROP INDEX CONCURRENTLY` before deploying again'
|
|
105
|
+
: 'It is not recorded');
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await client.query('RESET lock_timeout').catch(() => { });
|
|
109
|
+
await client.query('RESET statement_timeout').catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function failure(migration, error, outcome) {
|
|
113
|
+
const pgError = error;
|
|
114
|
+
if (pgError.message === undefined)
|
|
115
|
+
return error;
|
|
116
|
+
const position = Number(pgError.position);
|
|
117
|
+
const at = Number.isInteger(position) && position > 0 ? `:${lineAt(migration.text, position - 1)}` : '';
|
|
118
|
+
const extra = [pgError.detail, pgError.hint].filter((s) => Boolean(s));
|
|
119
|
+
return new SequelError([
|
|
120
|
+
`${migration.shown}${at}: ${pgError.message}`,
|
|
121
|
+
...extra.map((line) => ` ${line}`),
|
|
122
|
+
`${outcome}; the files before it stay applied, and the deploy stopped there. Fix the file and deploy again.`,
|
|
123
|
+
].join('\n'));
|
|
124
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type Token = {
|
|
2
|
+
kind: 'word' | 'string' | 'qident' | 'semicolon' | 'other';
|
|
3
|
+
value: string;
|
|
4
|
+
offset: number;
|
|
5
|
+
};
|
|
6
|
+
export type Comment = {
|
|
7
|
+
text: string;
|
|
8
|
+
offset: number;
|
|
9
|
+
block: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type Statement = {
|
|
12
|
+
text: string;
|
|
13
|
+
offset: number;
|
|
14
|
+
line: number;
|
|
15
|
+
tokens: Token[];
|
|
16
|
+
};
|
|
17
|
+
export type ScannedSql = {
|
|
18
|
+
source: string;
|
|
19
|
+
statements: Statement[];
|
|
20
|
+
comments: Comment[];
|
|
21
|
+
};
|
|
22
|
+
export declare function scanSql(text: string): ScannedSql;
|
|
23
|
+
export declare function lineAt(text: string, offset: number): number;
|
|
24
|
+
export declare function noTransactionMarker(scanned: ScannedSql): {
|
|
25
|
+
noTransaction: boolean;
|
|
26
|
+
misplacedLines: number[];
|
|
27
|
+
};
|
|
28
|
+
export declare function buildsIndexConcurrently(statement: Statement): boolean;
|
|
29
|
+
export declare function addedEnumValue(statement: Statement): string | undefined;
|
|
30
|
+
export declare function indexWithoutUsing(statement: Statement): {
|
|
31
|
+
table: string;
|
|
32
|
+
identifiers: string[];
|
|
33
|
+
} | undefined;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Just enough of Postgres's lexical rules to read a migration: comments (nested block comments
|
|
2
|
+
// included), strings ('…', E'…' with backslash escapes, and dollar quotes), quoted identifiers,
|
|
3
|
+
// and the semicolons between statements. Nothing here parses SQL.
|
|
4
|
+
export function scanSql(text) {
|
|
5
|
+
const tokens = [];
|
|
6
|
+
const comments = [];
|
|
7
|
+
let i = 0;
|
|
8
|
+
const n = text.length;
|
|
9
|
+
while (i < n) {
|
|
10
|
+
const c = text[i];
|
|
11
|
+
const next = text[i + 1];
|
|
12
|
+
if (/\s/.test(c)) {
|
|
13
|
+
i++;
|
|
14
|
+
}
|
|
15
|
+
else if (c === '-' && next === '-') {
|
|
16
|
+
const end = text.indexOf('\n', i);
|
|
17
|
+
const stop = end === -1 ? n : end;
|
|
18
|
+
comments.push({ text: text.slice(i, stop), offset: i, block: false });
|
|
19
|
+
i = stop;
|
|
20
|
+
}
|
|
21
|
+
else if (c === '/' && next === '*') {
|
|
22
|
+
const start = i;
|
|
23
|
+
let depth = 0;
|
|
24
|
+
while (i < n) {
|
|
25
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
26
|
+
depth++;
|
|
27
|
+
i += 2;
|
|
28
|
+
}
|
|
29
|
+
else if (text[i] === '*' && text[i + 1] === '/') {
|
|
30
|
+
depth--;
|
|
31
|
+
i += 2;
|
|
32
|
+
if (depth === 0)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
comments.push({ text: text.slice(start, i), offset: start, block: true });
|
|
39
|
+
}
|
|
40
|
+
else if (c === "'" || ((c === 'E' || c === 'e') && next === "'")) {
|
|
41
|
+
const start = i;
|
|
42
|
+
const escapes = c !== "'";
|
|
43
|
+
i += escapes ? 2 : 1;
|
|
44
|
+
let value = '';
|
|
45
|
+
while (i < n) {
|
|
46
|
+
const ch = text[i];
|
|
47
|
+
if (escapes && ch === '\\') {
|
|
48
|
+
value += text[i + 1] ?? '';
|
|
49
|
+
i += 2;
|
|
50
|
+
}
|
|
51
|
+
else if (ch === "'" && text[i + 1] === "'") {
|
|
52
|
+
value += "'";
|
|
53
|
+
i += 2;
|
|
54
|
+
}
|
|
55
|
+
else if (ch === "'") {
|
|
56
|
+
i++;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
value += ch;
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
tokens.push({ kind: 'string', value, offset: start });
|
|
65
|
+
}
|
|
66
|
+
else if (c === '"') {
|
|
67
|
+
const start = i;
|
|
68
|
+
i++;
|
|
69
|
+
let value = '';
|
|
70
|
+
while (i < n) {
|
|
71
|
+
if (text[i] === '"' && text[i + 1] === '"') {
|
|
72
|
+
value += '"';
|
|
73
|
+
i += 2;
|
|
74
|
+
}
|
|
75
|
+
else if (text[i] === '"') {
|
|
76
|
+
i++;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
value += text[i++];
|
|
81
|
+
}
|
|
82
|
+
tokens.push({ kind: 'qident', value, offset: start });
|
|
83
|
+
}
|
|
84
|
+
else if (c === '$' && /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.test(text.slice(i, i + 64))) {
|
|
85
|
+
const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(text.slice(i))[0];
|
|
86
|
+
const start = i;
|
|
87
|
+
const close = text.indexOf(tag, i + tag.length);
|
|
88
|
+
const end = close === -1 ? n : close + tag.length;
|
|
89
|
+
tokens.push({
|
|
90
|
+
kind: 'string',
|
|
91
|
+
value: text.slice(i + tag.length, close === -1 ? n : close),
|
|
92
|
+
offset: start,
|
|
93
|
+
});
|
|
94
|
+
i = end;
|
|
95
|
+
}
|
|
96
|
+
else if (/[\p{L}_]/u.test(c)) {
|
|
97
|
+
const start = i;
|
|
98
|
+
while (i < n && /[\p{L}\p{N}_$]/u.test(text[i]))
|
|
99
|
+
i++;
|
|
100
|
+
tokens.push({ kind: 'word', value: text.slice(start, i), offset: start });
|
|
101
|
+
}
|
|
102
|
+
else if (c === ';') {
|
|
103
|
+
tokens.push({ kind: 'semicolon', value: ';', offset: i });
|
|
104
|
+
i++;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
tokens.push({ kind: 'other', value: c, offset: i });
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const statements = [];
|
|
112
|
+
let current = [];
|
|
113
|
+
const finish = (end) => {
|
|
114
|
+
const first = current[0];
|
|
115
|
+
if (first !== undefined) {
|
|
116
|
+
statements.push({
|
|
117
|
+
text: text.slice(first.offset, end),
|
|
118
|
+
offset: first.offset,
|
|
119
|
+
line: lineAt(text, first.offset),
|
|
120
|
+
tokens: current,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
current = [];
|
|
124
|
+
};
|
|
125
|
+
for (const token of tokens) {
|
|
126
|
+
if (token.kind === 'semicolon')
|
|
127
|
+
finish(token.offset + 1);
|
|
128
|
+
else
|
|
129
|
+
current.push(token);
|
|
130
|
+
}
|
|
131
|
+
finish(n);
|
|
132
|
+
return { source: text, statements, comments };
|
|
133
|
+
}
|
|
134
|
+
// The 1-based line of a 0-based offset.
|
|
135
|
+
export function lineAt(text, offset) {
|
|
136
|
+
let line = 1;
|
|
137
|
+
for (let i = 0; i < offset && i < text.length; i++)
|
|
138
|
+
if (text[i] === '\n')
|
|
139
|
+
line++;
|
|
140
|
+
return line;
|
|
141
|
+
}
|
|
142
|
+
const NO_TRANSACTION = /^--\s*sequel:no-transaction\s*$/;
|
|
143
|
+
// TECH-SPEC §8: `-- sequel:no-transaction` on a line before the first statement.
|
|
144
|
+
export function noTransactionMarker(scanned) {
|
|
145
|
+
const firstOffset = scanned.statements[0]?.offset ?? Infinity;
|
|
146
|
+
let noTransaction = false;
|
|
147
|
+
const misplaced = [];
|
|
148
|
+
for (const comment of scanned.comments) {
|
|
149
|
+
if (comment.block || !NO_TRANSACTION.test(comment.text.trim()))
|
|
150
|
+
continue;
|
|
151
|
+
if (comment.offset < firstOffset)
|
|
152
|
+
noTransaction = true;
|
|
153
|
+
else
|
|
154
|
+
misplaced.push(lineAt(scanned.source, comment.offset));
|
|
155
|
+
}
|
|
156
|
+
return { noTransaction, misplacedLines: misplaced };
|
|
157
|
+
}
|
|
158
|
+
const upper = (token) => token?.kind === 'word' ? token.value.toUpperCase() : undefined;
|
|
159
|
+
// `CREATE [UNIQUE] INDEX CONCURRENTLY` or `REINDEX … CONCURRENTLY`.
|
|
160
|
+
export function buildsIndexConcurrently(statement) {
|
|
161
|
+
const words = statement.tokens.map(upper);
|
|
162
|
+
const first = words[0];
|
|
163
|
+
return ((first === 'CREATE' || first === 'REINDEX') &&
|
|
164
|
+
words.includes('CONCURRENTLY') &&
|
|
165
|
+
(first === 'REINDEX' || words.includes('INDEX')));
|
|
166
|
+
}
|
|
167
|
+
// The value an `ALTER TYPE … ADD VALUE [IF NOT EXISTS] '<value>'` statement adds, if it is one.
|
|
168
|
+
export function addedEnumValue(statement) {
|
|
169
|
+
const t = statement.tokens;
|
|
170
|
+
if (upper(t[0]) !== 'ALTER' || upper(t[1]) !== 'TYPE')
|
|
171
|
+
return undefined;
|
|
172
|
+
for (let i = 2; i < t.length - 1; i++) {
|
|
173
|
+
if (upper(t[i]) === 'ADD' && upper(t[i + 1]) === 'VALUE') {
|
|
174
|
+
let j = i + 2;
|
|
175
|
+
if (upper(t[j]) === 'IF' && upper(t[j + 1]) === 'NOT' && upper(t[j + 2]) === 'EXISTS')
|
|
176
|
+
j += 3;
|
|
177
|
+
const value = t[j];
|
|
178
|
+
return value?.kind === 'string' ? value.value : undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
// A `CREATE [UNIQUE] INDEX … ON <table> (…)` that gives no `USING`, so Postgres builds a btree
|
|
184
|
+
// index: its table, without any schema, and the identifiers in its column list.
|
|
185
|
+
export function indexWithoutUsing(statement) {
|
|
186
|
+
const t = statement.tokens;
|
|
187
|
+
if (upper(t[0]) !== 'CREATE')
|
|
188
|
+
return undefined;
|
|
189
|
+
const index = upper(t[1]) === 'UNIQUE' ? 2 : 1;
|
|
190
|
+
if (upper(t[index]) !== 'INDEX')
|
|
191
|
+
return undefined;
|
|
192
|
+
let i = t.findIndex((token, k) => k > index && upper(token) === 'ON');
|
|
193
|
+
if (i === -1)
|
|
194
|
+
return undefined;
|
|
195
|
+
i++;
|
|
196
|
+
if (upper(t[i]) === 'ONLY')
|
|
197
|
+
i++;
|
|
198
|
+
let table = name(t[i]);
|
|
199
|
+
while (t[i + 1]?.value === '.' && t[i + 2] !== undefined) {
|
|
200
|
+
i += 2;
|
|
201
|
+
table = name(t[i]);
|
|
202
|
+
}
|
|
203
|
+
i++;
|
|
204
|
+
if (table === undefined || upper(t[i]) === 'USING' || t[i]?.value !== '(')
|
|
205
|
+
return undefined;
|
|
206
|
+
const identifiers = [];
|
|
207
|
+
let depth = 0;
|
|
208
|
+
for (; i < t.length; i++) {
|
|
209
|
+
const token = t[i];
|
|
210
|
+
if (token.value === '(' && token.kind === 'other')
|
|
211
|
+
depth++;
|
|
212
|
+
else if (token.value === ')' && token.kind === 'other') {
|
|
213
|
+
depth--;
|
|
214
|
+
if (depth === 0)
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const identifier = name(token);
|
|
219
|
+
if (identifier !== undefined)
|
|
220
|
+
identifiers.push(identifier);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { table, identifiers };
|
|
224
|
+
}
|
|
225
|
+
// An identifier as Postgres sees it: a quoted one as written, an unquoted one folded to
|
|
226
|
+
// lower case.
|
|
227
|
+
function name(token) {
|
|
228
|
+
if (token?.kind === 'qident')
|
|
229
|
+
return token.value;
|
|
230
|
+
if (token?.kind === 'word')
|
|
231
|
+
return token.value.toLowerCase();
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type SchemaFile = {
|
|
2
|
+
file: string;
|
|
3
|
+
relative: string;
|
|
4
|
+
content: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function readSchemaFiles(folder: string, root: string): Promise<SchemaFile[]>;
|
|
7
|
+
export declare function validateSchema(files: SchemaFile[], root: string): string | undefined;
|
|
8
|
+
export type SchemaWarning = {
|
|
9
|
+
shown: string;
|
|
10
|
+
line: number;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function schemaWarnings(files: SchemaFile[], root: string): SchemaWarning[];
|
|
14
|
+
export declare function assertValidSchema(files: SchemaFile[], root: string): void;
|
|
15
|
+
export declare function invalidSchemaMessage(message: string): string;
|
|
16
|
+
export declare function formatSchemaFiles(files: SchemaFile[], root: string): Promise<string[]>;
|