@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 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. Write `sequel.config.ts`. Set the lint cut-over to your newest existing migration.
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. Move queries to `.sql` files module by module, porting modules that share a transaction together. Then remove `@prisma/client`, `prisma` and their install hooks.
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
- const modules = await expected(1, "The query modules can't be generated", 'Fix the queries named. `sequel generate` reports the same errors.', async () => checkModules({
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 load(context);
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 load(context);
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 load(context);
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 load(context);
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 load(context);
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 load(context);
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
- await deployTo(config, url, { log: out, quietWhenCurrent: true });
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 = [...(await findQueryFolders(config.queries)), config.migrations];
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 load(context);
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: {
@@ -18,6 +18,7 @@ export type SequelConfig = {
18
18
  types?: {
19
19
  bigint?: BigintMode;
20
20
  };
21
+ externalMigrations?: boolean;
21
22
  };
22
23
  export type BigintMode = 'string' | 'number' | 'bigint';
23
24
  export declare function defineConfig(config: SequelConfig): SequelConfig;
@@ -18,6 +18,7 @@ export type ResolvedConfig = {
18
18
  types: {
19
19
  bigint: BigintMode;
20
20
  };
21
+ externalMigrations: boolean;
21
22
  };
22
23
  export declare const DEFAULTS: {
23
24
  readonly schema: 'prisma';
@@ -9,7 +9,17 @@ export const DEFAULTS = {
9
9
  bigint: 'string',
10
10
  };
11
11
  const KEYS = {
12
- '': ['schema', 'migrations', 'queries', 'snapshot', 'databases', 'timeouts', 'lint', 'types'],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bockit/sequel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One CLI for a TypeScript app's Postgres data layer: Prisma schema, reviewed SQL migrations, typed SQL queries.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {