@bockit/sequel 0.1.0 → 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/README.md +38 -2
- package/dist/check.js +37 -25
- package/dist/cli.js +24 -9
- package/dist/config/define-config.d.ts +1 -0
- package/dist/config/resolve-config.d.ts +1 -0
- package/dist/config/resolve-config.js +16 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,14 +158,50 @@ Collations, CHECKs, partial and expression indexes, vector and trigram indexes,
|
|
|
158
158
|
- live in hand-written migration SQL, noted in a comment on their model;
|
|
159
159
|
- appear in the committed extras snapshot, so a change to them shows in review.
|
|
160
160
|
|
|
161
|
+
### Migrations managed by another tool
|
|
162
|
+
|
|
163
|
+
If another tool manages your schema and migrations, set `externalMigrations: true`. sequel then only types your queries:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
export default defineConfig({
|
|
167
|
+
externalMigrations: true,
|
|
168
|
+
queries: 'src/**/queries',
|
|
169
|
+
databases: { test: process.env.TEST_DATABASE_URL },
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- `generate` types queries against the test database as your other tool left it. It never migrates it, and that tool's history table there, such as `_prisma_migrations`, is fine. Migrate the test database with your tool first.
|
|
174
|
+
- `check` checks only the generated modules: stale, missing or orphaned. It prints a line saying it skipped drift, lint, the extras snapshot and schema validation.
|
|
175
|
+
- `generate --watch` watches only the query folders.
|
|
176
|
+
- No schema folder or migrations are read, so `schema`, `migrations` and `snapshot` aren't needed, and are ignored if set. Only sqlc is fetched; `pg_dump` is still needed.
|
|
177
|
+
- `validate`, `format`, `migrate …` and `extras` refuse, naming the setting.
|
|
178
|
+
|
|
179
|
+
Remove the setting to have sequel manage the schema and migrations.
|
|
180
|
+
|
|
161
181
|
## Adopting sequel in a Prisma app
|
|
162
182
|
|
|
183
|
+
Move queries first, and decide about migrations last. Prisma keeps the schema, the migrations and `@prisma/client` while you port, and the app runs on both in the meantime.
|
|
184
|
+
|
|
185
|
+
### Queries first
|
|
186
|
+
|
|
187
|
+
1. Write `sequel.config.ts` with `externalMigrations: true` (see [above](#migrations-managed-by-another-tool)). The schema stays as it is, `url` included, and Prisma keeps migrating every database, the test database too.
|
|
188
|
+
2. Move queries to `.sql` files module by module, porting modules that share a transaction together. Run `sequel generate`, and add `sequel check` to your test script and CI.
|
|
189
|
+
3. Hand-written SQL now does what Prisma Client did for you:
|
|
190
|
+
- **`@updatedAt`:** Prisma Client sets it on every create and update, and the column has no database default. Set `updated_at = now()` in every `INSERT` and `UPDATE`.
|
|
191
|
+
- **`@default(cuid())` and `@default(uuid())`:** Prisma Client makes the id, and the column has no database default. Your app supplies it as a parameter.
|
|
192
|
+
- **A missing row:** Prisma's `update` and `delete` throw `P2025`. A `:one` query returns `null`, and an `:execrows` query returns `0`, so check for them.
|
|
193
|
+
- **Unique violations:** Prisma's `P2002` is now `pg`'s error with `code` `'23505'`.
|
|
194
|
+
- **Nested writes and `$transaction([...])`:** Prisma ran these in a transaction implicitly. Now take a client from the pool, run `BEGIN`, pass that client to every query in the transaction, and `COMMIT` (or `ROLLBACK` on error).
|
|
195
|
+
4. When the last query is ported, remove `@prisma/client` and the schema's `generator` block. Prisma's CLI still runs the migrations.
|
|
196
|
+
|
|
197
|
+
### Then migrations, if sequel is to run them
|
|
198
|
+
|
|
163
199
|
1. Split `schema.prisma` into a folder, and remove `url` from the `datasource` block.
|
|
164
|
-
2.
|
|
200
|
+
2. In `sequel.config.ts`, remove `externalMigrations`, and set `schema`, `migrations`, `snapshot` and the target database. Set the lint cut-over to your newest existing migration.
|
|
165
201
|
3. Run `sequel migrate adopt-prisma` and commit the renamed files. Their contents don't change, so their checksums still match.
|
|
166
202
|
4. Run `sequel migrate adopt-prisma` once against every database: production, each developer's, and the test database. It creates sequel's history from `_prisma_migrations`, checking every checksum, and leaves that table untouched. Until then, `deploy` refuses that database.
|
|
167
203
|
5. Run `sequel extras`, and commit the snapshot.
|
|
168
|
-
6.
|
|
204
|
+
6. Remove `prisma` and its install hooks.
|
|
169
205
|
7. In VS Code's Prisma extension, which still formats and validates offline, turn off the "Generate" CodeLens and the Prisma Client file watcher.
|
|
170
206
|
|
|
171
207
|
## Status
|
package/dist/check.js
CHANGED
|
@@ -15,10 +15,9 @@ import { unsupportedColumns } from './schema/unsupported.js';
|
|
|
15
15
|
// SPEC §3's `check`: brings the test database up to the latest migration, then runs every check
|
|
16
16
|
// and returns every failure, in SPEC's order. It writes no files. A check that can't run because
|
|
17
17
|
// an earlier one failed (drift needs a valid schema, say) is skipped, and the earlier failure
|
|
18
|
-
// says why.
|
|
18
|
+
// says why. With external migrations it checks only the generated modules (SPEC §2).
|
|
19
19
|
export async function runChecks(config, log) {
|
|
20
20
|
const testUrl = requireDatabase(config, 'test');
|
|
21
|
-
const shown = (file) => path.relative(config.root, file);
|
|
22
21
|
const failures = [];
|
|
23
22
|
const fail = (order, failure) => failures.push({ order, failure });
|
|
24
23
|
const expected = async (order, problem, fix, body) => {
|
|
@@ -32,6 +31,14 @@ export async function runChecks(config, log) {
|
|
|
32
31
|
return undefined;
|
|
33
32
|
}
|
|
34
33
|
};
|
|
34
|
+
// With external migrations, the test database is as the other tool left it, and only the
|
|
35
|
+
// generated modules are checked. The skip is said, never silent.
|
|
36
|
+
if (config.externalMigrations) {
|
|
37
|
+
log(`Skipped drift, lint, the extras snapshot and schema validation: this app's migrations are managed elsewhere (\`externalMigrations\` in ${path.basename(config.configFile)}).`);
|
|
38
|
+
await checkGeneratedModules(config, testUrl, expected, (failure) => fail(1, failure));
|
|
39
|
+
return failures.map((entry) => entry.failure);
|
|
40
|
+
}
|
|
41
|
+
const shown = (file) => path.relative(config.root, file);
|
|
35
42
|
// The schema folder.
|
|
36
43
|
const files = await expected(5, "The schema folder can't be read", "Fix the config's `schema`, then run `sequel check` again.", () => readSchemaFiles(config.schema, config.root));
|
|
37
44
|
let schemaValid = false;
|
|
@@ -55,29 +62,8 @@ export async function runChecks(config, log) {
|
|
|
55
62
|
return true;
|
|
56
63
|
})) === true;
|
|
57
64
|
// 1. Generated modules.
|
|
58
|
-
if (deployed)
|
|
59
|
-
|
|
60
|
-
sqlc: await ensureBinary('sqlc'),
|
|
61
|
-
databaseUrl: testUrl,
|
|
62
|
-
folders: await findQueryFolders(config.queries),
|
|
63
|
-
bigint: config.types.bigint,
|
|
64
|
-
root: config.root,
|
|
65
|
-
}));
|
|
66
|
-
if (modules !== undefined) {
|
|
67
|
-
const details = [
|
|
68
|
-
...modules.stale.map((file) => `stale: ${shown(file)}`),
|
|
69
|
-
...modules.missing.map((file) => `missing: ${shown(file)}`),
|
|
70
|
-
...modules.orphaned.map((file) => `orphaned: ${shown(file)} (its query file is gone)`),
|
|
71
|
-
];
|
|
72
|
-
if (details.length > 0) {
|
|
73
|
-
fail(1, {
|
|
74
|
-
problem: 'Generated modules are out of date',
|
|
75
|
-
details,
|
|
76
|
-
fix: 'Run `sequel generate`, and commit the result.',
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
65
|
+
if (deployed)
|
|
66
|
+
await checkGeneratedModules(config, testUrl, expected, (failure) => fail(1, failure));
|
|
81
67
|
// 2. Drift.
|
|
82
68
|
if (migrations !== undefined && files !== undefined && schemaValid) {
|
|
83
69
|
const diff = await expected(2, "Drift between the migrations and the schema folder can't be checked", 'Fix the problem named, then run `sequel check` again.', () => diffMigrationsToSchema({ config, migrations, files }));
|
|
@@ -121,6 +107,32 @@ export async function runChecks(config, log) {
|
|
|
121
107
|
}
|
|
122
108
|
return failures.sort((a, b) => a.order - b.order).map((entry) => entry.failure);
|
|
123
109
|
}
|
|
110
|
+
// Generates the modules as `generate` would, against the test database as it stands, and
|
|
111
|
+
// compares them with the modules in place: stale, missing or orphaned.
|
|
112
|
+
async function checkGeneratedModules(config, testUrl, expected, fail) {
|
|
113
|
+
const shown = (file) => path.relative(config.root, file);
|
|
114
|
+
const modules = await expected(1, "The query modules can't be generated", 'Fix the queries named. `sequel generate` reports the same errors.', async () => checkModules({
|
|
115
|
+
sqlc: await ensureBinary('sqlc'),
|
|
116
|
+
databaseUrl: testUrl,
|
|
117
|
+
folders: await findQueryFolders(config.queries),
|
|
118
|
+
bigint: config.types.bigint,
|
|
119
|
+
root: config.root,
|
|
120
|
+
}));
|
|
121
|
+
if (modules === undefined)
|
|
122
|
+
return;
|
|
123
|
+
const details = [
|
|
124
|
+
...modules.stale.map((file) => `stale: ${shown(file)}`),
|
|
125
|
+
...modules.missing.map((file) => `missing: ${shown(file)}`),
|
|
126
|
+
...modules.orphaned.map((file) => `orphaned: ${shown(file)} (its query file is gone)`),
|
|
127
|
+
];
|
|
128
|
+
if (details.length > 0) {
|
|
129
|
+
fail({
|
|
130
|
+
problem: 'Generated modules are out of date',
|
|
131
|
+
details,
|
|
132
|
+
fix: 'Run `sequel generate`, and commit the result.',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
124
136
|
// The lines that differ, from a longest-common-subsequence diff: `-` for the snapshot as
|
|
125
137
|
// committed, `+` for the database. Snapshots are small, so the quadratic table is fine.
|
|
126
138
|
export function lineChanges(current, next) {
|
package/dist/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ const OPTIONS = {
|
|
|
28
28
|
'allow-out-of-order': { type: 'boolean' },
|
|
29
29
|
};
|
|
30
30
|
async function validate(context) {
|
|
31
|
-
const config = await
|
|
31
|
+
const config = await loadManaged(context);
|
|
32
32
|
const files = await readSchemaFiles(config.schema, config.root);
|
|
33
33
|
assertValidSchema(files, config.root);
|
|
34
34
|
for (const warning of schemaWarnings(files, config.root)) {
|
|
@@ -37,7 +37,7 @@ async function validate(context) {
|
|
|
37
37
|
out(`The schema is valid: ${plural(files.length, 'file')} in ${shown(config, config.schema)}.`);
|
|
38
38
|
}
|
|
39
39
|
async function format(context) {
|
|
40
|
-
const config = await
|
|
40
|
+
const config = await loadManaged(context);
|
|
41
41
|
const changed = await formatSchemaFiles(await readSchemaFiles(config.schema, config.root), config.root);
|
|
42
42
|
for (const file of changed)
|
|
43
43
|
out(`Formatted ${shown(config, file)}`);
|
|
@@ -45,7 +45,7 @@ async function format(context) {
|
|
|
45
45
|
out(`The schema is already formatted.`);
|
|
46
46
|
}
|
|
47
47
|
async function migrateNew(context) {
|
|
48
|
-
const config = await
|
|
48
|
+
const config = await loadManaged(context);
|
|
49
49
|
const { file, findings } = await newMigration({ config, name: context.positionals[0] });
|
|
50
50
|
if (file === undefined) {
|
|
51
51
|
out('The migrations already match the schema: no migration written.');
|
|
@@ -58,7 +58,7 @@ async function migrateNew(context) {
|
|
|
58
58
|
out('Lint: no findings.');
|
|
59
59
|
}
|
|
60
60
|
async function migrateLint(context) {
|
|
61
|
-
const config = await
|
|
61
|
+
const config = await loadManaged(context);
|
|
62
62
|
const migrations = context.positionals.length > 0
|
|
63
63
|
? await Promise.all(context.positionals.map(async (given) => {
|
|
64
64
|
const file = path.resolve(given);
|
|
@@ -84,7 +84,7 @@ async function migrateLint(context) {
|
|
|
84
84
|
out(`${plural(migrations.length, 'migration file')} linted: no findings.`);
|
|
85
85
|
}
|
|
86
86
|
async function migrateAdoptPrisma(context) {
|
|
87
|
-
const config = await
|
|
87
|
+
const config = await loadManaged(context);
|
|
88
88
|
const url = requireDatabase(config, 'target');
|
|
89
89
|
const files = await adoptPrismaFiles(config.migrations, config.root);
|
|
90
90
|
for (const { from, to } of files.renamed) {
|
|
@@ -113,7 +113,7 @@ async function migrateAdoptPrisma(context) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
async function migrateDeploy(context) {
|
|
116
|
-
const config = await
|
|
116
|
+
const config = await loadManaged(context);
|
|
117
117
|
await deployTo(config, requireDatabase(config, 'target'), {
|
|
118
118
|
allowOutOfOrder: context.flags['allow-out-of-order'],
|
|
119
119
|
log: out,
|
|
@@ -123,7 +123,10 @@ async function generate(context) {
|
|
|
123
123
|
const config = await load(context);
|
|
124
124
|
const url = requireDatabase(config, 'test');
|
|
125
125
|
const once = async () => {
|
|
126
|
-
|
|
126
|
+
// With external migrations, the test database is typed as the other tool left it.
|
|
127
|
+
if (!config.externalMigrations) {
|
|
128
|
+
await deployTo(config, url, { log: out, quietWhenCurrent: true });
|
|
129
|
+
}
|
|
127
130
|
const folders = await findQueryFolders(config.queries);
|
|
128
131
|
const result = await generateModules({
|
|
129
132
|
sqlc: await ensureBinary('sqlc'),
|
|
@@ -147,7 +150,9 @@ async function generate(context) {
|
|
|
147
150
|
const stop = new AbortController();
|
|
148
151
|
process.once('SIGINT', () => stop.abort());
|
|
149
152
|
process.once('SIGTERM', () => stop.abort());
|
|
150
|
-
const folders =
|
|
153
|
+
const folders = await findQueryFolders(config.queries);
|
|
154
|
+
if (!config.externalMigrations)
|
|
155
|
+
folders.push(config.migrations);
|
|
151
156
|
await watchAndRerun({
|
|
152
157
|
folders,
|
|
153
158
|
run: once,
|
|
@@ -156,7 +161,7 @@ async function generate(context) {
|
|
|
156
161
|
});
|
|
157
162
|
}
|
|
158
163
|
async function extras(context) {
|
|
159
|
-
const config = await
|
|
164
|
+
const config = await loadManaged(context);
|
|
160
165
|
const url = requireDatabase(config, 'test');
|
|
161
166
|
await deployTo(config, url, { log: out, quietWhenCurrent: true });
|
|
162
167
|
const next = await readExtrasSnapshot(url);
|
|
@@ -179,6 +184,15 @@ async function check(context) {
|
|
|
179
184
|
function load(context) {
|
|
180
185
|
return loadConfig({ config: context.config });
|
|
181
186
|
}
|
|
187
|
+
// For the commands that act on the schema, the migrations or the extras snapshot: they refuse
|
|
188
|
+
// when another tool manages them, before connecting, reading a file or fetching a binary.
|
|
189
|
+
async function loadManaged(context) {
|
|
190
|
+
const config = await load(context);
|
|
191
|
+
if (config.externalMigrations) {
|
|
192
|
+
throw new SequelError(`\`sequel ${context.name}\` is off: ${path.basename(config.configFile)} sets \`externalMigrations\`, so another tool manages this app's schema and migrations, and sequel only types its queries. To have sequel manage them, remove \`externalMigrations\`.`);
|
|
193
|
+
}
|
|
194
|
+
return config;
|
|
195
|
+
}
|
|
182
196
|
function shown(config, file) {
|
|
183
197
|
return path.relative(config.root, file) || '.';
|
|
184
198
|
}
|
|
@@ -301,6 +315,7 @@ async function main(argv) {
|
|
|
301
315
|
throw new SequelError(`Usage: sequel ${command.usage}`);
|
|
302
316
|
}
|
|
303
317
|
await command.run({
|
|
318
|
+
name,
|
|
304
319
|
config: values.config,
|
|
305
320
|
positionals: args,
|
|
306
321
|
flags: {
|
|
@@ -9,7 +9,17 @@ export const DEFAULTS = {
|
|
|
9
9
|
bigint: 'string',
|
|
10
10
|
};
|
|
11
11
|
const KEYS = {
|
|
12
|
-
'': [
|
|
12
|
+
'': [
|
|
13
|
+
'schema',
|
|
14
|
+
'migrations',
|
|
15
|
+
'queries',
|
|
16
|
+
'snapshot',
|
|
17
|
+
'databases',
|
|
18
|
+
'timeouts',
|
|
19
|
+
'lint',
|
|
20
|
+
'types',
|
|
21
|
+
'externalMigrations',
|
|
22
|
+
],
|
|
13
23
|
databases: ['target', 'test', 'shadow'],
|
|
14
24
|
timeouts: ['lockMs', 'statementMs'],
|
|
15
25
|
lint: ['cutover'],
|
|
@@ -60,6 +70,10 @@ export function resolveConfig(value, configFile) {
|
|
|
60
70
|
if (!BIGINT_MODES.includes(bigint)) {
|
|
61
71
|
fail('types.bigint', `must be one of ${BIGINT_MODES.map((m) => `'${m}'`).join(', ')}.`);
|
|
62
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
|
+
}
|
|
63
77
|
return {
|
|
64
78
|
configFile,
|
|
65
79
|
root,
|
|
@@ -75,6 +89,7 @@ export function resolveConfig(value, configFile) {
|
|
|
75
89
|
},
|
|
76
90
|
lint: { cutover: cutover },
|
|
77
91
|
types: { bigint: bigint },
|
|
92
|
+
externalMigrations: external,
|
|
78
93
|
};
|
|
79
94
|
}
|
|
80
95
|
// Returns the connection string for a database a command needs, or explains how to set it.
|
package/package.json
CHANGED