@ultimat3/db 24.0.0 → 25.1.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 +28 -27
- package/README.md +49 -20
- package/package.json +3 -3
- package/src/client.ts +2 -2
- package/src/column-alter.ts +9 -3
- package/src/db-executor.ts +24 -0
- package/src/default-client.ts +4 -4
- package/src/dependent-view.ts +6 -6
- package/src/destructive.ts +1 -1
- package/src/drift-append-only.ts +53 -0
- package/src/drift-findings.ts +4 -2
- package/src/drift.ts +14 -6
- package/src/entity-shape.ts +5 -0
- package/src/errors.ts +6 -1
- package/src/fake.ts +1 -1
- package/src/generate-append-only.ts +146 -0
- package/src/generate.ts +16 -7
- package/src/index.ts +8 -6
- package/src/introspect-catalog.ts +1 -1
- package/src/introspect.ts +47 -4
- package/src/migrate-rollback.ts +44 -0
- package/src/migrate.ts +44 -154
- package/src/migration-ledger.ts +167 -0
- package/src/pglite-branch.ts +1 -1
- package/src/pglite.ts +3 -3
- package/src/pool-profile.ts +1 -1
- package/src/primary-key.ts +34 -4
- package/src/retype-dependents.ts +3 -3
- package/src/snapshot-parse.ts +9 -3
- package/src/sql-scan.ts +37 -12
- package/src/drift-fixtures.ts +0 -23
- package/src/fake-pglite.ts +0 -32
- package/src/fake-reservable.ts +0 -50
package/src/sql-scan.ts
CHANGED
|
@@ -3,10 +3,27 @@
|
|
|
3
3
|
// disagreed with a guard about where a literal ends is a `;` sent as data or a `delete` read as
|
|
4
4
|
// prose, and the two answers must be the same answer.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Postgres's `ident_start`/`dolq_start`: any non-ASCII character too (`\200-\377` per UTF-8 byte),
|
|
8
|
+
* so `$é$` opens a body and `é$x$` is one name — read against a server, `sql-scan.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
const IDENTIFIER_START = /[A-Za-z_\u0080-\uffff]/;
|
|
11
|
+
/** A tag's `dolq_cont`: an identifier character without the `$`, non-ASCII included. */
|
|
12
|
+
const TAG_PART = /[A-Za-z0-9_\u0080-\uffff]/;
|
|
13
|
+
/**
|
|
14
|
+
* `ident_cont`: the one class every word scan in this package reads a name with. `$` is legal
|
|
15
|
+
* after the first character (`a$b` is one name, not three) and so is any non-ASCII one — an
|
|
16
|
+
* ASCII-only scan found `col` inside `écol` and never found `prénom` at all.
|
|
17
|
+
*/
|
|
18
|
+
export const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An unquoted name as a UTF8 server stores it: ASCII folded to lower case and nothing else —
|
|
22
|
+
* `Évent` stays `Évent` (read against a server, `dependent-view.test.ts`). `toLowerCase()` folded
|
|
23
|
+
* the `É` too and named a column the catalog does not hold.
|
|
24
|
+
*/
|
|
25
|
+
export const foldIdentifier = (name: string): string =>
|
|
26
|
+
name.replace(/[A-Z]+/g, (run) => run.toLowerCase());
|
|
10
27
|
|
|
11
28
|
export type NoiseKind = 'line-comment' | 'block-comment' | 'string' | 'identifier' | 'dollar-body';
|
|
12
29
|
|
|
@@ -23,11 +40,13 @@ function skipLineComment(script: string, index: number): number {
|
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
/**
|
|
26
|
-
* Past a block comment
|
|
27
|
-
* the
|
|
28
|
-
* every `;` after that point would otherwise
|
|
43
|
+
* Past a block comment opening at `index`, or `null` when it never closes. Postgres **nests**
|
|
44
|
+
* them, so the depth is counted rather than matched to the first terminator — a commented-out
|
|
45
|
+
* block that itself contains a comment closes once, and every `;` after that point would otherwise
|
|
46
|
+
* be read as data. Exported for `@ultimat3/mcp`'s read-only check, which REFUSES an unclosed
|
|
47
|
+
* comment where this lexer runs it to the end: one rule for where a comment ends, two readers.
|
|
29
48
|
*/
|
|
30
|
-
function
|
|
49
|
+
export function endOfBlockComment(script: string, index: number): number | null {
|
|
31
50
|
let depth = 0;
|
|
32
51
|
let at = index;
|
|
33
52
|
while (at < script.length) {
|
|
@@ -46,9 +65,13 @@ function skipBlockComment(script: string, index: number): number {
|
|
|
46
65
|
}
|
|
47
66
|
at += 1;
|
|
48
67
|
}
|
|
49
|
-
return
|
|
68
|
+
return null;
|
|
50
69
|
}
|
|
51
70
|
|
|
71
|
+
/** An unclosed comment runs to the end: Postgres reports that syntax error, not this lexer. */
|
|
72
|
+
const skipBlockComment = (script: string, index: number): number =>
|
|
73
|
+
endOfBlockComment(script, index) ?? script.length;
|
|
74
|
+
|
|
52
75
|
/**
|
|
53
76
|
* Past a run closing on `quote`, where a doubled quote is an escaped one — `'it''s'` and
|
|
54
77
|
* `"a""b"` are each one token. `escapes` is the `E''` dialect, the only one where a backslash
|
|
@@ -84,7 +107,7 @@ function skipQuoted(script: string, index: number, quote: string, escapes: boole
|
|
|
84
107
|
*/
|
|
85
108
|
function insideIdentifier(script: string, index: number): boolean {
|
|
86
109
|
let at = index - 1;
|
|
87
|
-
while (at >= 0 &&
|
|
110
|
+
while (at >= 0 && IDENTIFIER_CHAR.test(script[at] ?? '')) at -= 1;
|
|
88
111
|
const first = script[at + 1];
|
|
89
112
|
return at + 1 < index && first !== undefined && IDENTIFIER_START.test(first);
|
|
90
113
|
}
|
|
@@ -104,7 +127,7 @@ export function dollarTagAt(script: string, index: number): string | null {
|
|
|
104
127
|
let at = index + 1;
|
|
105
128
|
while (at < script.length) {
|
|
106
129
|
const char = script[at] ?? '';
|
|
107
|
-
const valid = at === index + 1 ? IDENTIFIER_START.test(char) :
|
|
130
|
+
const valid = at === index + 1 ? IDENTIFIER_START.test(char) : TAG_PART.test(char);
|
|
108
131
|
if (!valid) break;
|
|
109
132
|
at += 1;
|
|
110
133
|
}
|
|
@@ -125,7 +148,9 @@ function escapesAt(script: string, index: number): boolean {
|
|
|
125
148
|
const prefix = script[index - 1];
|
|
126
149
|
if (prefix !== 'E' && prefix !== 'e') return false;
|
|
127
150
|
const before = script[index - 2];
|
|
128
|
-
|
|
151
|
+
// `IDENTIFIER_CHAR`, never an ASCII-only class: `éE` and `a$E` are names to Postgres,
|
|
152
|
+
// and reading their `E` as a prefix let a `\'` swallow the `;` before a `drop table`.
|
|
153
|
+
return before === undefined || !IDENTIFIER_CHAR.test(before);
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
/**
|
package/src/drift-fixtures.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// TEST-ONLY. The two builders every drift suite compares with — a `TableDescription` of text
|
|
2
|
-
// columns and the `SchemaDescription` around it. One copy, because three suites arguing about
|
|
3
|
-
// schemas built differently would each be judging a different fixture. Never exported from
|
|
4
|
-
// `index.ts`.
|
|
5
|
-
|
|
6
|
-
import type { SchemaDescription, TableDescription } from './introspect';
|
|
7
|
-
|
|
8
|
-
export const table = (name: string, columns: readonly string[]): TableDescription => ({
|
|
9
|
-
schema: 'public',
|
|
10
|
-
name,
|
|
11
|
-
columns: columns.map((column, index) => ({
|
|
12
|
-
name: column,
|
|
13
|
-
dataType: 'text',
|
|
14
|
-
nullable: true,
|
|
15
|
-
default: null,
|
|
16
|
-
position: index + 1,
|
|
17
|
-
})),
|
|
18
|
-
primaryKey: ['id'],
|
|
19
|
-
indexes: [],
|
|
20
|
-
foreignKeys: [],
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
export const schema = (...tables: readonly TableDescription[]): SchemaDescription => ({ tables });
|
package/src/fake-pglite.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// Single responsibility: the PGlite driver fake the adapter tests record statements against.
|
|
2
|
-
// Shared rather than copied for the same reason `fake-reservable.ts` is: the assertion in every
|
|
3
|
-
// one of these tests is the recorded ORDER, and two copies of the recorder drift into two orders.
|
|
4
|
-
|
|
5
|
-
import type { PgliteDriver, PgliteResult } from './pglite';
|
|
6
|
-
|
|
7
|
-
/** One statement as the driver received it — the text after binding, and the bound values. */
|
|
8
|
-
export interface Recorded {
|
|
9
|
-
readonly text: string;
|
|
10
|
-
readonly values: readonly unknown[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export type RecordingPgliteDriver = PgliteDriver & {
|
|
14
|
-
readonly calls: Recorded[];
|
|
15
|
-
closed: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/** A driver that answers every statement with `result` and remembers the order it saw them in. */
|
|
19
|
-
export function fakeDriver(result: PgliteResult): RecordingPgliteDriver {
|
|
20
|
-
const calls: Recorded[] = [];
|
|
21
|
-
return {
|
|
22
|
-
calls,
|
|
23
|
-
closed: 0,
|
|
24
|
-
async query(text, values) {
|
|
25
|
-
calls.push({ text, values: values ?? [] });
|
|
26
|
-
return result;
|
|
27
|
-
},
|
|
28
|
-
async close() {
|
|
29
|
-
this.closed += 1;
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
}
|
package/src/fake-reservable.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// Single responsibility: a `ReservableClient` whose pin is countable, wrapped over any `DbClient`.
|
|
2
|
-
// The leak it exists to catch is invisible to the recording client — the statements are identical
|
|
3
|
-
// whether or not the reservation ever came back, and only the counter says which happened. Shared
|
|
4
|
-
// rather than copied: a second copy of a fixture asserts less than the first, silently.
|
|
5
|
-
|
|
6
|
-
import type { DbClient, ReservableClient } from './client';
|
|
7
|
-
|
|
8
|
-
/** Pins taken and pins given back. A leak is `reserves > releases`; the fix makes them equal. */
|
|
9
|
-
export interface PinCounts {
|
|
10
|
-
reserves: number;
|
|
11
|
-
releases: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface ReservableFake {
|
|
15
|
-
readonly client: ReservableClient;
|
|
16
|
-
readonly pins: PinCounts;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Wrap `inner` in a pool that counts its reservations. `release()` is idempotent and
|
|
21
|
-
* `[Symbol.dispose]` is the same call, exactly as both real drivers are — so a double release
|
|
22
|
-
* counts once and only a genuine leak leaves the counters uneven.
|
|
23
|
-
*/
|
|
24
|
-
export function reservableOver(inner: DbClient): ReservableFake {
|
|
25
|
-
const pins: PinCounts = { reserves: 0, releases: 0 };
|
|
26
|
-
return {
|
|
27
|
-
pins,
|
|
28
|
-
client: {
|
|
29
|
-
query: (fragment) => inner.query(fragment),
|
|
30
|
-
one: (fragment) => inner.one(fragment),
|
|
31
|
-
execute: (fragment) => inner.execute(fragment),
|
|
32
|
-
reserve: async () => {
|
|
33
|
-
pins.reserves += 1;
|
|
34
|
-
let held = true;
|
|
35
|
-
const release = (): void => {
|
|
36
|
-
if (!held) return;
|
|
37
|
-
held = false;
|
|
38
|
-
pins.releases += 1;
|
|
39
|
-
};
|
|
40
|
-
return {
|
|
41
|
-
query: (fragment) => inner.query(fragment),
|
|
42
|
-
one: (fragment) => inner.one(fragment),
|
|
43
|
-
execute: (fragment) => inner.execute(fragment),
|
|
44
|
-
release,
|
|
45
|
-
[Symbol.dispose]: release,
|
|
46
|
-
};
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|