@ultimat3/db 15.0.0 → 16.0.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/CLAUDE.md +183 -22
- package/package.json +2 -2
- package/src/dependent-view.ts +224 -0
- package/src/errors.ts +2 -100
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +9 -6
- package/src/migrate.ts +11 -1
- package/src/migration-errors.ts +132 -0
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +28 -10
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Single responsibility: the refusals a MIGRATION earns — the lock it could not take, a ledger that
|
|
2
|
+
// disagrees with this build, a plan that cannot be reversed, a plan that destroys rows without
|
|
3
|
+
// saying so, a snapshot that was never written, and a view standing in a retype's way. Split out of
|
|
4
|
+
// `errors.ts` only because that file reached the 500-line ceiling, exactly as `invariant-errors.ts`
|
|
5
|
+
// was: every code below is still declared, titled and registered there, and `DbError` is still the
|
|
6
|
+
// one class. One direction only — nothing here is imported back.
|
|
7
|
+
|
|
8
|
+
import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
|
|
9
|
+
import { DbError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
|
|
13
|
+
* no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
|
|
14
|
+
* left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
|
|
15
|
+
* failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
|
|
16
|
+
* exit code.
|
|
17
|
+
*/
|
|
18
|
+
export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
|
|
19
|
+
new DbError({
|
|
20
|
+
code: 'X_MIGRATE_CONCURRENT',
|
|
21
|
+
cause:
|
|
22
|
+
`another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
|
|
23
|
+
'so this migrator refused rather than block a deploy forever',
|
|
24
|
+
fix:
|
|
25
|
+
'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
|
|
26
|
+
"join pg_locks using (pid) where locktype = 'advisory'\"" +
|
|
27
|
+
' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
|
|
28
|
+
meta: { lockKey, waitedMs },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
32
|
+
new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
|
|
33
|
+
|
|
34
|
+
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
35
|
+
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A view standing in the way of a retype, refused one statement before Postgres would have.
|
|
39
|
+
*
|
|
40
|
+
* `restore` is the caller's, the way `migrationIrreversible`'s `fix:` is: it is a pair of
|
|
41
|
+
* `psql "$DATABASE_URL" -c '…'` invocations — the shape `migrationConflict` above already writes —
|
|
42
|
+
* built out of live catalog values through `identifier()`. This file may not import `sql.ts`: that
|
|
43
|
+
* module imports `identifierUnsafe` from here, and an import cycle around the module whose
|
|
44
|
+
* evaluation REGISTERS every code is not a cycle worth having for one quoted name.
|
|
45
|
+
*/
|
|
46
|
+
export const migrationViewDepends = (
|
|
47
|
+
view: string,
|
|
48
|
+
table: string,
|
|
49
|
+
column: string,
|
|
50
|
+
restore: string,
|
|
51
|
+
): DbError =>
|
|
52
|
+
new DbError({
|
|
53
|
+
code: 'X_MIGRATION_VIEW_DEPENDS',
|
|
54
|
+
cause:
|
|
55
|
+
`view "${view}" is compiled against "${table}"."${column}", which this migration retypes; ` +
|
|
56
|
+
'Postgres answers 0A000 and rolls the whole migration back',
|
|
57
|
+
fix: restore,
|
|
58
|
+
meta: { view, table, column },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
|
|
63
|
+
* negative count counts from the END: `steps: -1` selected every applied migration except the
|
|
64
|
+
* newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
|
|
65
|
+
* Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
|
|
66
|
+
* as a different one is the failure a validated argument exists to prevent.
|
|
67
|
+
*/
|
|
68
|
+
export const rollbackStepsInvalid = (received: number): DbError =>
|
|
69
|
+
new DbError({
|
|
70
|
+
code: 'X_INVARIANT',
|
|
71
|
+
cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
|
|
72
|
+
fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
|
|
73
|
+
meta: { steps: received },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
|
|
78
|
+
* every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
|
|
79
|
+
* rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
|
|
80
|
+
* keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
|
|
81
|
+
*/
|
|
82
|
+
const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
|
|
86
|
+
* and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
|
|
87
|
+
* the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
|
|
88
|
+
*/
|
|
89
|
+
const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The sidecar every generated migration writes is what the *next* generation diffs against, so a
|
|
93
|
+
* newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
|
|
94
|
+
* schema, which would generate `create table` for every table the database already holds.
|
|
95
|
+
*/
|
|
96
|
+
export const migrationSnapshotMissing = (id: string, file: string): DbError =>
|
|
97
|
+
new DbError({
|
|
98
|
+
code: 'X_MIGRATION_SNAPSHOT_MISSING',
|
|
99
|
+
cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
|
|
100
|
+
// Two remedies, both commands, in the order they are safe to try. "restore from version
|
|
101
|
+
// control" alone was neither: on a scaffolded app the sidecar was never written, so there is
|
|
102
|
+
// nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
|
|
103
|
+
// the two errors pointed at each other and an app's first migration had no way out.
|
|
104
|
+
// `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)}"`,
|
|
108
|
+
meta: { id, file },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One error per file, never one per statement: the marker declares the whole migration, so a
|
|
113
|
+
* second finding would repeat an instruction the first already gave. `file` is app-relative and
|
|
114
|
+
* arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
|
|
115
|
+
*
|
|
116
|
+
* Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
|
|
117
|
+
* a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
|
|
118
|
+
* destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
|
|
119
|
+
*/
|
|
120
|
+
export const migrationDestructive = (
|
|
121
|
+
file: string,
|
|
122
|
+
first: DestructiveStatement,
|
|
123
|
+
more = 0,
|
|
124
|
+
): DbError =>
|
|
125
|
+
new DbError({
|
|
126
|
+
code: 'X_MIGRATION_DESTRUCTIVE',
|
|
127
|
+
cause:
|
|
128
|
+
`${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
|
|
129
|
+
`${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
|
|
130
|
+
fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
|
|
131
|
+
meta: { file, kind: first.kind, statements: more + 1 },
|
|
132
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Single responsibility: which columns this migration RETYPES, and which recorded FOREIGN KEYS
|
|
2
|
+
// that breaks — the one answer to both, computed over the whole schema and above `diffTable`.
|
|
3
|
+
//
|
|
4
|
+
// **Why above `diffTable`, and not inside it like every other dependent.** Postgres re-checks a
|
|
5
|
+
// key's two ends against each other on every `alter column … type` and cannot rebuild one whose
|
|
6
|
+
// sides stopped matching: measured on 18.4, `42804 foreign key constraint "rk_posts_org_code_fkey"
|
|
7
|
+
// cannot be implemented — Key columns "org_code" … and "code" … are of incompatible types: integer
|
|
8
|
+
// and text`, thrown by the ALTER itself, inside `ROLE=migrate`, with the ledger recording nothing.
|
|
9
|
+
// The constraint that breaks is recorded on the table that OWNS it, which for a retype of the
|
|
10
|
+
// key's TARGET is a different entity's record — `diffTable(orgs)` is handed `orgs`'s row and can
|
|
11
|
+
// never see `posts.foreignKeys`. So the retype set is derived once, from `entities` and `current`
|
|
12
|
+
// together, and `retypeColumn` READS it rather than deciding again: two answers to "is this column
|
|
13
|
+
// being retyped" is the axiom-1 split this package has spent the week closing.
|
|
14
|
+
//
|
|
15
|
+
// **Over-approximated on purpose, the rule `retype-dependents.ts` states.** Whether two types keep
|
|
16
|
+
// an equality operator between them is operator resolution, which is exactly the knowledge a
|
|
17
|
+
// generator with no database cannot have — `varchar(80)` and `text` share one, `integer` and
|
|
18
|
+
// `text` do not. A key moved aside that did not need to be is one `add constraint` re-validating a
|
|
19
|
+
// table the ALTER beside it is already rewriting under ACCESS EXCLUSIVE; a key missed is the
|
|
20
|
+
// release phase failing with the server's words and none of the entity's.
|
|
21
|
+
//
|
|
22
|
+
// **What it cannot see.** A key the recorded schema does not hold — a hand-written migration's,
|
|
23
|
+
// or one from a sidecar written before `foreignKeys` was recorded — is invisible here and still
|
|
24
|
+
// `42804`, the same construction limit `x db gen` has against a hand-added expression index. And
|
|
25
|
+
// re-adding the key is still the SERVER's judgement: an entity that retypes one end and not the
|
|
26
|
+
// other declares a pairing Postgres has no operator for, and the `add constraint` at the end of
|
|
27
|
+
// `up` is where that is said. Refusing it here would need the type knowledge two paragraphs up.
|
|
28
|
+
|
|
29
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
30
|
+
import { addForeignKey, dropForeignKey, keyId, unrestorableNote } from './foreign-key';
|
|
31
|
+
import type { Plan } from './foreign-key-plan';
|
|
32
|
+
import { isGenerated } from './generated-column';
|
|
33
|
+
import type { ForeignKeyDescription, SchemaDescription, TableDescription } from './introspect';
|
|
34
|
+
import { findTable } from './introspect';
|
|
35
|
+
import { sqlType } from './sql-type';
|
|
36
|
+
|
|
37
|
+
/** Table name to the columns whose physical type this migration moves. Empty entries are omitted. */
|
|
38
|
+
export type RetypedColumns = ReadonlyMap<string, ReadonlySet<string>>;
|
|
39
|
+
|
|
40
|
+
/** The columns of one table this migration retypes — `retypeColumn`'s own read of the set above. */
|
|
41
|
+
export function retypedIn(retyped: RetypedColumns, table: string): ReadonlySet<string> {
|
|
42
|
+
return retyped.get(table) ?? new Set<string>();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every plain `alter column … type` this migration will emit, before any of them is written.
|
|
47
|
+
*
|
|
48
|
+
* A GENERATED column is deliberately absent: `generated-column.ts` owns every statement one of
|
|
49
|
+
* them produces, and its plain -> generated path is a `drop column` that takes the key with it
|
|
50
|
+
* rather than an ALTER that trips over it. That gap is real and is named in `generated-column.ts`.
|
|
51
|
+
*/
|
|
52
|
+
export function retypedColumns(
|
|
53
|
+
entities: readonly EntityDescriptionLike[],
|
|
54
|
+
current: SchemaDescription,
|
|
55
|
+
): RetypedColumns {
|
|
56
|
+
const moved = new Map<string, ReadonlySet<string>>();
|
|
57
|
+
for (const entity of entities) {
|
|
58
|
+
const live = findTable(current, entity.table);
|
|
59
|
+
if (live === undefined) continue;
|
|
60
|
+
const recorded = new Map(live.columns.map((column) => [column.name, column]));
|
|
61
|
+
const columns = new Set<string>();
|
|
62
|
+
for (const column of entity.columns) {
|
|
63
|
+
const held = recorded.get(column.column);
|
|
64
|
+
if (held === undefined) continue;
|
|
65
|
+
if (isGenerated(column) || held.generated !== undefined) continue;
|
|
66
|
+
if (held.dataType !== sqlType(column.kind)) columns.add(column.column);
|
|
67
|
+
}
|
|
68
|
+
if (columns.size > 0) moved.set(entity.table, columns);
|
|
69
|
+
}
|
|
70
|
+
return moved;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether either end of `key` sits on a column this migration retypes. `owner` owns the key. */
|
|
74
|
+
function breaksOn(key: ForeignKeyDescription, owner: string, retyped: RetypedColumns): boolean {
|
|
75
|
+
const own = retyped.get(owner);
|
|
76
|
+
if (own !== undefined && key.columns.some((column) => own.has(column))) return true;
|
|
77
|
+
const target = retyped.get(key.referencedTable);
|
|
78
|
+
return target !== undefined && key.referencedColumns.some((column) => target.has(column));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Drop every recorded key a retype breaks, restore it in `down`, and answer which names were moved.
|
|
83
|
+
*
|
|
84
|
+
* The two statements go in the plan's OWN buckets and not beside the ALTER, because the drop has
|
|
85
|
+
* to precede every alter in the migration and the restore has to follow every one of them — both
|
|
86
|
+
* ends of a key can move, in two different entities' diffs. `preAlters` is merged at the very top
|
|
87
|
+
* of `up` and at the very FRONT of `down`, which reversal turns into the very end: so the reversed
|
|
88
|
+
* script reads drop-the-new-key, retype both ends back, add the recorded key. Restoring it any
|
|
89
|
+
* earlier is `42804` in the other direction.
|
|
90
|
+
*
|
|
91
|
+
* What comes back in `up` is not written here at all: `foreignKeyPlan` reads the returned set,
|
|
92
|
+
* treats a moved key as one the schema does not record, and adds the DECLARED key in the
|
|
93
|
+
* `constraints` bucket that already runs after every table statement. That is what makes the three
|
|
94
|
+
* outcomes fall out of code that already exists — still declared (added back), no longer declared
|
|
95
|
+
* (gone, exactly as the removal arm would have left it), and declared with a new `on delete` rule
|
|
96
|
+
* (added back carrying it) — instead of three branches restating them here.
|
|
97
|
+
*/
|
|
98
|
+
export function moveKeysAside(
|
|
99
|
+
current: SchemaDescription,
|
|
100
|
+
retyped: RetypedColumns,
|
|
101
|
+
doomed: ReadonlySet<string>,
|
|
102
|
+
preAlters: Plan,
|
|
103
|
+
): ReadonlySet<string> {
|
|
104
|
+
const moved = new Set<string>();
|
|
105
|
+
for (const table of current.tables) {
|
|
106
|
+
for (const key of table.foreignKeys) {
|
|
107
|
+
if (!breaksOn(key, table.name, retyped)) continue;
|
|
108
|
+
moved.add(keyId(table.name, key.name));
|
|
109
|
+
preAlters.up.push(dropForeignKey(table.name, key.name));
|
|
110
|
+
preAlters.down.push(restore(table, key, doomed));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return moved;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `down` half. A key whose own table or whose target is being dropped has no `add constraint`
|
|
118
|
+
* that could run at all, so it gets the SAME note `unrestorableDrop` gives one — the text is
|
|
119
|
+
* `unrestorableNote`'s, in `foreign-key.ts`, and not a second spelling here. One failed rollback
|
|
120
|
+
* has one wording whichever module emitted it (axiom 2); these two had already drifted, this one
|
|
121
|
+
* naming no table at all while `foreign-key-plan.ts` named the target.
|
|
122
|
+
*
|
|
123
|
+
* The table it names is the key's OWN when that is the one going: a constraint whose table is
|
|
124
|
+
* gone is the more proximate reason there is nothing to add it back onto.
|
|
125
|
+
*/
|
|
126
|
+
function restore(
|
|
127
|
+
table: TableDescription,
|
|
128
|
+
key: ForeignKeyDescription,
|
|
129
|
+
doomed: ReadonlySet<string>,
|
|
130
|
+
): string {
|
|
131
|
+
if (!doomed.has(table.name) && !doomed.has(key.referencedTable)) {
|
|
132
|
+
return addForeignKey(table.name, key);
|
|
133
|
+
}
|
|
134
|
+
return unrestorableNote(
|
|
135
|
+
table.name,
|
|
136
|
+
key.name,
|
|
137
|
+
doomed.has(table.name) ? table.name : key.referencedTable,
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/sql-type.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Single responsibility: the physical Postgres type a declared column KIND becomes. One table, so
|
|
2
|
+
// the statement that writes a column, the snapshot that records it and the pass that decides
|
|
3
|
+
// whether a retype is happening at all cannot disagree about what `char` means.
|
|
4
|
+
//
|
|
5
|
+
// Split out of `generate.ts` for `retype-keys.ts`, which has to answer "does this column's type
|
|
6
|
+
// move" ABOVE `diffTable` — a foreign key over a retyped column lives in another table's record.
|
|
7
|
+
|
|
8
|
+
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
9
|
+
uuid: 'uuid',
|
|
10
|
+
text: 'text',
|
|
11
|
+
// Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
|
|
12
|
+
// currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
|
|
13
|
+
// Without the length no currency ever fits the constraint the same statement demands.
|
|
14
|
+
char: 'char(3)',
|
|
15
|
+
boolean: 'boolean',
|
|
16
|
+
integer: 'integer',
|
|
17
|
+
bigint: 'bigint',
|
|
18
|
+
numeric: 'numeric',
|
|
19
|
+
timestamptz: 'timestamptz',
|
|
20
|
+
date: 'date',
|
|
21
|
+
jsonb: 'jsonb',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A kind this table does not name passes through verbatim — an app's own domain, an enum type a
|
|
26
|
+
* hand-written migration created.
|
|
27
|
+
*
|
|
28
|
+
* `Object.hasOwn` and not a bare index: `kind` is DATA, so `SQL_TYPES['constructor']` answered the
|
|
29
|
+
* `Object` function and `type ${wanted}` spliced its source into a statement, while `'__proto__'`
|
|
30
|
+
* answered `[object Object]`. Guarded, both behave like every other unknown kind and pass through
|
|
31
|
+
* as themselves. Measured across the package's 766 tests: no other input's answer moves.
|
|
32
|
+
*/
|
|
33
|
+
export function sqlType(kind: string): string {
|
|
34
|
+
return Object.hasOwn(SQL_TYPES, kind) ? (SQL_TYPES[kind] ?? kind) : kind;
|
|
35
|
+
}
|
package/src/sql.ts
CHANGED
|
@@ -132,19 +132,37 @@ export function identifier(name: string): SqlFragment {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
* A quoted string literal
|
|
136
|
-
*
|
|
137
|
-
*
|
|
135
|
+
* A quoted string literal Postgres reads IDENTICALLY under both settings of
|
|
136
|
+
* `standard_conforming_strings`. Utility and DDL statements (`CREATE DATABASE`, `COMMENT ON`,
|
|
137
|
+
* `create table … default …`) reject bound parameters, so this is the only place a value may be
|
|
138
|
+
* inlined. Never reach for it in a query: `sql` binds parameters there.
|
|
138
139
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
140
|
+
* **It DOES receive caller input, and this comment said otherwise until 2026-08-25.**
|
|
141
|
+
* `column-default.ts:43` renders `ColumnDefaultLike` here, which is an app's own
|
|
142
|
+
* `.default('C:\\logs')` crossing the tier seam from `@ultimat3/entity` — nothing validates it and
|
|
143
|
+
* no `identifier()` guards it. (The package's two other callers are safe by CONSTRUCTION, not by
|
|
144
|
+
* input: `readonly-role.ts:71` sits in the same `sql` template as an `identifier(role)` that throws
|
|
145
|
+
* first, and `branch.ts:85` runs after an already-awaited `identifier(base)`.)
|
|
146
|
+
*
|
|
147
|
+
* Doubling the quote is not the whole rule. That GUC is settable per session, per database and per
|
|
148
|
+
* role and `SET` needs no privilege, and with it `off` a backslash escapes the character after it
|
|
149
|
+
* inside an ordinary `'…'`. Measured on 18.4 through `generateMigration`: `.default('C:\\logs')`
|
|
150
|
+
* emits `default 'C:\logs'`, which stores `C:\logs` with the GUC on and **`C:logs`** with it off —
|
|
151
|
+
* a column defaulting to a value nobody wrote, with no error anywhere. A value ENDING in a
|
|
152
|
+
* backslash is worse than wrong: the escaped quote leaves the literal unterminated and the text
|
|
153
|
+
* after it is string data until the next `'` puts the remainder back into code position.
|
|
154
|
+
*
|
|
155
|
+
* `E'…'` fixes the dialect in the text itself rather than trusting a setting, so both readings
|
|
156
|
+
* agree — **only** when the value actually carries a backslash. Without one there is no escape
|
|
157
|
+
* mechanism for the two settings to disagree about, so every migration already generated stays byte
|
|
158
|
+
* for byte what it was and nothing regenerates spuriously; both tracked apps have applied
|
|
159
|
+
* migrations on disk with hashes over this text. Same rule, same measurement, as
|
|
160
|
+
* `packages/entity/src/sql-literal.ts`, which is where it was first written and which adopts this
|
|
161
|
+
* one — tier 1 holds it, tier 2 imports down.
|
|
145
162
|
*/
|
|
146
163
|
export function literal(value: string): SqlFragment {
|
|
147
|
-
|
|
164
|
+
const quoted = value.replaceAll("'", "''");
|
|
165
|
+
return raw(value.includes('\\') ? `E'${quoted.replaceAll('\\', '\\\\')}'` : `'${quoted}'`);
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
/** `a, b, c` — the one blessed way to build an IN list or a column list. */
|