@metaobjectsdev/migrate-ts 0.8.1-rc.1 → 0.9.0-rc.1
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 +1 -3
- package/dist/apply/apply.d.ts +61 -0
- package/dist/apply/apply.d.ts.map +1 -0
- package/dist/apply/apply.js +241 -0
- package/dist/apply/apply.js.map +1 -0
- package/dist/apply/ledger.d.ts +78 -0
- package/dist/apply/ledger.d.ts.map +1 -0
- package/dist/apply/ledger.js +146 -0
- package/dist/apply/ledger.js.map +1 -0
- package/dist/check-expr-compare.d.ts +13 -0
- package/dist/check-expr-compare.d.ts.map +1 -0
- package/dist/check-expr-compare.js +48 -0
- package/dist/check-expr-compare.js.map +1 -0
- package/dist/diff/index.d.ts +3 -1
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +57 -14
- package/dist/diff/index.js.map +1 -1
- package/dist/diff/status.js +3 -0
- package/dist/diff/status.js.map +1 -1
- package/dist/drift/classify.d.ts +16 -0
- package/dist/drift/classify.d.ts.map +1 -0
- package/dist/drift/classify.js +44 -0
- package/dist/drift/classify.js.map +1 -0
- package/dist/drift/drift.d.ts +32 -0
- package/dist/drift/drift.d.ts.map +1 -0
- package/dist/drift/drift.js +36 -0
- package/dist/drift/drift.js.map +1 -0
- package/dist/emit/d1-safety-pass.d.ts.map +1 -1
- package/dist/emit/d1-safety-pass.js +15 -45
- package/dist/emit/d1-safety-pass.js.map +1 -1
- package/dist/emit/postgres.d.ts.map +1 -1
- package/dist/emit/postgres.js +47 -4
- package/dist/emit/postgres.js.map +1 -1
- package/dist/emit/sqlite.d.ts.map +1 -1
- package/dist/emit/sqlite.js +22 -0
- package/dist/emit/sqlite.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -1
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +114 -5
- package/dist/expected-schema.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect/d1.d.ts.map +1 -1
- package/dist/introspect/d1.js +1 -0
- package/dist/introspect/d1.js.map +1 -1
- package/dist/introspect/postgres.d.ts.map +1 -1
- package/dist/introspect/postgres.js +38 -2
- package/dist/introspect/postgres.js.map +1 -1
- package/dist/introspect/sqlite.d.ts.map +1 -1
- package/dist/introspect/sqlite.js +13 -2
- package/dist/introspect/sqlite.js.map +1 -1
- package/dist/snapshot/checksum.d.ts +10 -0
- package/dist/snapshot/checksum.d.ts.map +1 -0
- package/dist/snapshot/checksum.js +14 -0
- package/dist/snapshot/checksum.js.map +1 -0
- package/dist/snapshot/plan.d.ts +25 -0
- package/dist/snapshot/plan.d.ts.map +1 -0
- package/dist/snapshot/plan.js +30 -0
- package/dist/snapshot/plan.js.map +1 -0
- package/dist/snapshot/serialize.d.ts +10 -0
- package/dist/snapshot/serialize.d.ts.map +1 -0
- package/dist/snapshot/serialize.js +63 -0
- package/dist/snapshot/serialize.js.map +1 -0
- package/dist/snapshot/store.d.ts +12 -0
- package/dist/snapshot/store.d.ts.map +1 -0
- package/dist/snapshot/store.js +32 -0
- package/dist/snapshot/store.js.map +1 -0
- package/dist/sql/split-statements.d.ts +12 -0
- package/dist/sql/split-statements.d.ts.map +1 -0
- package/dist/sql/split-statements.js +112 -0
- package/dist/sql/split-statements.js.map +1 -0
- package/dist/sql-type.d.ts +2 -0
- package/dist/sql-type.d.ts.map +1 -1
- package/dist/sql-type.js +2 -0
- package/dist/sql-type.js.map +1 -1
- package/dist/types.d.ts +36 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/verify/replay.d.ts +25 -0
- package/dist/verify/replay.d.ts.map +1 -0
- package/dist/verify/replay.js +25 -0
- package/dist/verify/replay.js.map +1 -0
- package/dist/view-sql-compare.d.ts +8 -0
- package/dist/view-sql-compare.d.ts.map +1 -0
- package/dist/view-sql-compare.js +44 -0
- package/dist/view-sql-compare.js.map +1 -0
- package/package.json +2 -2
- package/src/apply/apply.ts +340 -0
- package/src/apply/ledger.ts +241 -0
- package/src/check-expr-compare.ts +49 -0
- package/src/diff/index.ts +59 -15
- package/src/diff/status.ts +3 -0
- package/src/drift/classify.ts +56 -0
- package/src/drift/drift.ts +66 -0
- package/src/emit/d1-safety-pass.ts +16 -45
- package/src/emit/postgres.ts +47 -4
- package/src/emit/sqlite.ts +22 -0
- package/src/errors.ts +4 -0
- package/src/expected-schema.ts +124 -4
- package/src/index.ts +44 -0
- package/src/introspect/d1.ts +1 -0
- package/src/introspect/postgres.ts +38 -4
- package/src/introspect/sqlite.ts +13 -3
- package/src/snapshot/checksum.ts +15 -0
- package/src/snapshot/plan.ts +53 -0
- package/src/snapshot/serialize.ts +81 -0
- package/src/snapshot/store.ts +33 -0
- package/src/sql/split-statements.ts +115 -0
- package/src/sql-type.ts +3 -0
- package/src/types.ts +26 -9
- package/src/verify/replay.ts +43 -0
- package/src/view-sql-compare.ts +46 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { type Kysely, sql } from "kysely";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default migration-history ledger table name. A single source of truth tracked
|
|
5
|
+
* across postgres + sqlite so `meta migrate --apply` can skip already-applied
|
|
6
|
+
* files (idempotency from the LEDGER, not from re-diffing).
|
|
7
|
+
*/
|
|
8
|
+
export const MIGRATIONS_TABLE = "_metaobjects_migrations";
|
|
9
|
+
|
|
10
|
+
/** Default Postgres schema holding the ledger (and, by default, the lock scope). */
|
|
11
|
+
export const DEFAULT_LEDGER_SCHEMA = "public";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Safe SQL identifier pattern for caller-supplied `schema` / `table` names. These
|
|
15
|
+
* are interpolated as SQL identifiers (one `sql.ref` per part), so they MUST be a
|
|
16
|
+
* single, unquoted-style identifier — no dots, spaces, or quotes — otherwise
|
|
17
|
+
* Kysely's `sql.ref` would split a value like `"v1.2_migrations"` on every `.`
|
|
18
|
+
* into a wrong multi-part name. Validated at resolve time; a violation throws.
|
|
19
|
+
*/
|
|
20
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Throw a clear, actionable error if a caller-supplied SQL-identifier option
|
|
24
|
+
* (`schema` / `table`) is not a single safe identifier. Names the offending
|
|
25
|
+
* option + value so the caller can fix the misconfiguration directly.
|
|
26
|
+
*/
|
|
27
|
+
function assertSafeIdentifier(option: string, value: string): void {
|
|
28
|
+
if (!SAFE_IDENTIFIER.test(value)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`LedgerOptions.${option} must be a single SQL identifier matching ` +
|
|
31
|
+
`${SAFE_IDENTIFIER.source} (letters, digits, underscore; not starting with a digit). ` +
|
|
32
|
+
`Got ${JSON.stringify(value)} — a dotted/quoted/spaced value would be mis-parsed as a ` +
|
|
33
|
+
`multi-part identifier. Use a plain name (e.g. a per-tenant prefix) instead.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The dialect signal threaded through the ledger fns (schema-qualification only applies to pg). */
|
|
39
|
+
export type LedgerDialect = "postgres" | "sqlite";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Multi-tenant ledger configuration. Generalizes the fixed
|
|
43
|
+
* `public._metaobjects_migrations` ledger so multiple apps/tenants can track
|
|
44
|
+
* independently in one physical database.
|
|
45
|
+
*
|
|
46
|
+
* Defaults preserve the original single-tenant behavior exactly: `schema`
|
|
47
|
+
* defaults to `public`, `table` to `_metaobjects_migrations`. Postgres uses
|
|
48
|
+
* `schema`; SQLite has no schemas and ignores it. `lockName` is consumed by the
|
|
49
|
+
* advisory-lock path (apply/rollback) — see {@link applyPending}.
|
|
50
|
+
*/
|
|
51
|
+
export interface LedgerOptions {
|
|
52
|
+
/** Postgres schema holding the ledger table. Default `public`. Ignored on SQLite. */
|
|
53
|
+
schema?: string;
|
|
54
|
+
/** Ledger table name. Default `_metaobjects_migrations`. */
|
|
55
|
+
table?: string;
|
|
56
|
+
/** Advisory-lock name. Default derived from `<schema>.<table>`. Postgres-only. */
|
|
57
|
+
lockName?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A single ledger row. */
|
|
61
|
+
export interface LedgerRow {
|
|
62
|
+
/** Migration name = the `<timestamp>-<slug>` directory name (sort key + id). */
|
|
63
|
+
name: string;
|
|
64
|
+
/** sha-256 of the up.sql contents at apply time (tamper guard). */
|
|
65
|
+
checksum: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolved ledger location: a dialect + a Kysely raw-SQL identifier reference. */
|
|
69
|
+
interface ResolvedLedger {
|
|
70
|
+
dialect: LedgerDialect;
|
|
71
|
+
schema: string;
|
|
72
|
+
table: string;
|
|
73
|
+
/**
|
|
74
|
+
* A qualified-identifier SQL fragment built from SEPARATE `sql.ref` parts —
|
|
75
|
+
* `"<schema>"."<table>"` on pg, the bare `"<table>"` on sqlite. Built part-wise
|
|
76
|
+
* (not from a single dotted string) so a `.` inside a name can never be
|
|
77
|
+
* misread as an identifier separator, independent of the resolve-time
|
|
78
|
+
* validation.
|
|
79
|
+
*/
|
|
80
|
+
ref: ReturnType<typeof sql>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a {@link LedgerOptions} (+ dialect) to a concrete ledger location.
|
|
85
|
+
* On Postgres the table is schema-qualified (`"<schema>"."<table>"`); on SQLite
|
|
86
|
+
* (no schema concept) the schema is ignored and the bare `"<table>"` is used.
|
|
87
|
+
*
|
|
88
|
+
* Caller-supplied `schema` / `table` are validated against {@link SAFE_IDENTIFIER}
|
|
89
|
+
* (a violation throws here, naming the option + value), then assembled from two
|
|
90
|
+
* separate `sql.ref` parts so identifier quoting stays dialect-portable AND no
|
|
91
|
+
* `.` can be mis-parsed as a multi-part separator.
|
|
92
|
+
*/
|
|
93
|
+
function resolveLedger(
|
|
94
|
+
dialect: LedgerDialect,
|
|
95
|
+
opts: LedgerOptions | undefined,
|
|
96
|
+
): ResolvedLedger {
|
|
97
|
+
const schema = opts?.schema ?? DEFAULT_LEDGER_SCHEMA;
|
|
98
|
+
const table = opts?.table ?? MIGRATIONS_TABLE;
|
|
99
|
+
assertSafeIdentifier("table", table);
|
|
100
|
+
if (dialect === "postgres") {
|
|
101
|
+
assertSafeIdentifier("schema", schema);
|
|
102
|
+
}
|
|
103
|
+
const ref =
|
|
104
|
+
dialect === "postgres"
|
|
105
|
+
? sql`${sql.ref(schema)}.${sql.ref(table)}`
|
|
106
|
+
: sql`${sql.ref(table)}`;
|
|
107
|
+
return { dialect, schema, table, ref };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create the migration-history table if it does not already exist. Idempotent:
|
|
112
|
+
* re-running is a no-op and preserves existing rows. Dialect-portable DDL
|
|
113
|
+
* (TEXT columns work on both sqlite and postgres; `applied_at` is stored as
|
|
114
|
+
* text so we don't depend on a dialect-specific timestamp type).
|
|
115
|
+
*
|
|
116
|
+
* On Postgres a multi-tenant `schema` is created first (`CREATE SCHEMA IF NOT
|
|
117
|
+
* EXISTS`) so the ledger can live outside `public`.
|
|
118
|
+
*/
|
|
119
|
+
export async function ensureLedger(
|
|
120
|
+
db: Kysely<Record<string, unknown>>,
|
|
121
|
+
dialect: LedgerDialect = "sqlite",
|
|
122
|
+
opts?: LedgerOptions,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const ledger = resolveLedger(dialect, opts);
|
|
125
|
+
if (ledger.dialect === "postgres") {
|
|
126
|
+
await sql`CREATE SCHEMA IF NOT EXISTS ${sql.ref(ledger.schema)}`.execute(db);
|
|
127
|
+
}
|
|
128
|
+
await sql`
|
|
129
|
+
CREATE TABLE IF NOT EXISTS ${ledger.ref} (
|
|
130
|
+
name TEXT PRIMARY KEY,
|
|
131
|
+
applied_at TEXT NOT NULL,
|
|
132
|
+
checksum TEXT NOT NULL
|
|
133
|
+
)
|
|
134
|
+
`.execute(db);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Record a migration as applied. Inserts a row with the current UTC timestamp.
|
|
139
|
+
* Intended to run inside the SAME transaction that applied the migration SQL.
|
|
140
|
+
*/
|
|
141
|
+
export async function recordApplied(
|
|
142
|
+
db: Kysely<Record<string, unknown>>,
|
|
143
|
+
name: string,
|
|
144
|
+
checksum: string,
|
|
145
|
+
dialect: LedgerDialect = "sqlite",
|
|
146
|
+
opts?: LedgerOptions,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const ledger = resolveLedger(dialect, opts);
|
|
149
|
+
const appliedAt = new Date().toISOString();
|
|
150
|
+
await sql`
|
|
151
|
+
INSERT INTO ${ledger.ref} (name, applied_at, checksum)
|
|
152
|
+
VALUES (${name}, ${appliedAt}, ${checksum})
|
|
153
|
+
`.execute(db);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Delete a migration's ledger row (rollback unrecord). */
|
|
157
|
+
export async function deleteApplied(
|
|
158
|
+
db: Kysely<Record<string, unknown>>,
|
|
159
|
+
name: string,
|
|
160
|
+
dialect: LedgerDialect = "sqlite",
|
|
161
|
+
opts?: LedgerOptions,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const ledger = resolveLedger(dialect, opts);
|
|
164
|
+
await sql`
|
|
165
|
+
DELETE FROM ${ledger.ref} WHERE name = ${name}
|
|
166
|
+
`.execute(db);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Return the set of applied migration names. */
|
|
170
|
+
export async function appliedNames(
|
|
171
|
+
db: Kysely<Record<string, unknown>>,
|
|
172
|
+
dialect: LedgerDialect = "sqlite",
|
|
173
|
+
opts?: LedgerOptions,
|
|
174
|
+
): Promise<Set<string>> {
|
|
175
|
+
return new Set((await appliedRecords(db, dialect, opts)).keys());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Return a name→checksum map for all applied migrations (tamper-guard input).
|
|
180
|
+
*
|
|
181
|
+
* The {@link BASELINE_NAME} marker row is excluded at the SQL level: it is a
|
|
182
|
+
* marker, NOT a migration, so no migration-listing consumer (e.g. rollback-all,
|
|
183
|
+
* which derives its work list from these names) should ever see it. The baseline
|
|
184
|
+
* is read independently via {@link baselineRecord}.
|
|
185
|
+
*/
|
|
186
|
+
export async function appliedRecords(
|
|
187
|
+
db: Kysely<Record<string, unknown>>,
|
|
188
|
+
dialect: LedgerDialect = "sqlite",
|
|
189
|
+
opts?: LedgerOptions,
|
|
190
|
+
): Promise<Map<string, string>> {
|
|
191
|
+
const ledger = resolveLedger(dialect, opts);
|
|
192
|
+
// Raw select keeps this dialect-portable and sidesteps typing the dynamic
|
|
193
|
+
// table name against the untyped Kysely<Record<string, unknown>> schema.
|
|
194
|
+
const result = await sql<{ name: string; checksum: string }>`
|
|
195
|
+
SELECT name, checksum FROM ${ledger.ref} WHERE name != ${BASELINE_NAME}
|
|
196
|
+
`.execute(db);
|
|
197
|
+
const map = new Map<string, string>();
|
|
198
|
+
for (const row of result.rows) {
|
|
199
|
+
map.set(row.name, row.checksum);
|
|
200
|
+
}
|
|
201
|
+
return map;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Reserved ledger name for the baseline marker (sorts before any timestamped migration). */
|
|
205
|
+
export const BASELINE_NAME = "0000-baseline";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Record (or overwrite) the baseline marker — the snapshot checksum captured when
|
|
209
|
+
* `migrate baseline` seeded the reference snapshot. Lets a later check detect a
|
|
210
|
+
* snapshot that was hand-edited out of sync with the migration chain.
|
|
211
|
+
*/
|
|
212
|
+
export async function recordBaseline(
|
|
213
|
+
db: Kysely<Record<string, unknown>>,
|
|
214
|
+
dialect: LedgerDialect,
|
|
215
|
+
checksum: string,
|
|
216
|
+
opts?: LedgerOptions,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const ledger = resolveLedger(dialect, opts);
|
|
219
|
+
await ensureLedger(db, dialect, opts);
|
|
220
|
+
const appliedAt = new Date().toISOString();
|
|
221
|
+
// Upsert: delete any prior baseline, then insert (portable across sqlite/pg).
|
|
222
|
+
await sql`DELETE FROM ${ledger.ref} WHERE name = ${BASELINE_NAME}`.execute(db);
|
|
223
|
+
await sql`
|
|
224
|
+
INSERT INTO ${ledger.ref} (name, applied_at, checksum)
|
|
225
|
+
VALUES (${BASELINE_NAME}, ${appliedAt}, ${checksum})
|
|
226
|
+
`.execute(db);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Read the baseline marker, or null if none recorded. */
|
|
230
|
+
export async function baselineRecord(
|
|
231
|
+
db: Kysely<Record<string, unknown>>,
|
|
232
|
+
dialect: LedgerDialect = "sqlite",
|
|
233
|
+
opts?: LedgerOptions,
|
|
234
|
+
): Promise<{ name: string; checksum: string } | null> {
|
|
235
|
+
const ledger = resolveLedger(dialect, opts);
|
|
236
|
+
const result = await sql<{ name: string; checksum: string }>`
|
|
237
|
+
SELECT name, checksum FROM ${ledger.ref} WHERE name = ${BASELINE_NAME}
|
|
238
|
+
`.execute(db);
|
|
239
|
+
const row = result.rows[0];
|
|
240
|
+
return row ? { name: row.name, checksum: row.checksum } : null;
|
|
241
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/check-expr-compare.ts
|
|
2
|
+
//
|
|
3
|
+
// CHECK-expression comparison. Postgres rewrites a stored CHECK body so the raw
|
|
4
|
+
// text we generate and the introspected text differ textually but mean the same
|
|
5
|
+
// thing. This reduces both to ONE canonical form for comparison. Reliable here
|
|
6
|
+
// because every check expression we emit is machine-derived with a simple, known
|
|
7
|
+
// shape (comparison / IN / length / regex) — there is no arbitrary author SQL to
|
|
8
|
+
// mis-normalize. The rewrites PG applies (verified against a live server):
|
|
9
|
+
// - parenthesizes terms: `col >= 0 AND col <= 100` → `(col >= 0) AND (col <= 100)`
|
|
10
|
+
// - rewrites IN-lists: `status IN ('A','B')` → `status = ANY (ARRAY['A'::text, 'B'::text])`
|
|
11
|
+
// - appends type casts: string literals gain `::text`
|
|
12
|
+
// All three are canonicalized below so an enum/range CHECK introspected from PG
|
|
13
|
+
// compares equal to the one we generate (idempotency on the --from-db / verify paths).
|
|
14
|
+
|
|
15
|
+
/** Canonical form: drop casts/brackets/parens, fold `= ANY (ARRAY[…])` back to `IN`, lower-case, collapse whitespace. */
|
|
16
|
+
export function normalizeCheckExpr(expr: string): string {
|
|
17
|
+
const stripped = expr
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
// Drop `::text` / `::"MyType"` type casts PG adds to literals. The lookbehind
|
|
20
|
+
// restricts the strip to a cast that immediately follows a CLOSING single
|
|
21
|
+
// quote (`'open'::text`), so a `::` appearing INSIDE a regex pattern literal
|
|
22
|
+
// (`slug ~ 'a::foo'`) is preserved — otherwise two distinct regex CHECKs would
|
|
23
|
+
// normalize equal and a pattern change would be silently missed.
|
|
24
|
+
.replace(/(?<=')::\s*"?\w+"?/g, "")
|
|
25
|
+
.replace(/[()[\]]/g, " ") // drop parens AND square brackets (ARRAY[…])
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim();
|
|
28
|
+
// PG stores `col IN (…)` as `col = ANY (ARRAY[…])`; after the bracket strip above
|
|
29
|
+
// that reads `col = any array …`. Fold it back to the `col in …` form we emit.
|
|
30
|
+
return stripped.replace(/=\s*any\s+array/g, "in").replace(/\s+/g, " ").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when two CHECK expressions are equivalent after normalization. */
|
|
34
|
+
export function checkExprEquals(a: string | undefined, b: string | undefined): boolean {
|
|
35
|
+
if (a === undefined || b === undefined) return false;
|
|
36
|
+
return normalizeCheckExpr(a) === normalizeCheckExpr(b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `CHECK (<expr>)` → `<expr>` (balanced outer wrapper); returns input unchanged
|
|
41
|
+
* if there is no CHECK wrapper. Tolerates a trailing constraint modifier suffix
|
|
42
|
+
* (`pg_get_constraintdef` can return `CHECK (<expr>) NOT VALID`) so the wrapper
|
|
43
|
+
* still strips cleanly to the inner expression instead of falling through to the
|
|
44
|
+
* unchanged-input fallback (which would cause spurious drop+add churn).
|
|
45
|
+
*/
|
|
46
|
+
export function stripCheckWrapper(def: string): string {
|
|
47
|
+
const m = /^\s*CHECK\s*\((.*)\)(?:\s+NOT\s+VALID)?\s*$/is.exec(def);
|
|
48
|
+
return m ? m[1]!.trim() : def.trim();
|
|
49
|
+
}
|
package/src/diff/index.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
|
|
3
3
|
ViewDescriptor,
|
|
4
|
-
Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback,
|
|
4
|
+
Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback, Dialect,
|
|
5
5
|
} from "../types.js";
|
|
6
6
|
import type { SqlType } from "../sql-type.js";
|
|
7
7
|
import { sqlTypeEquals } from "../sql-type.js";
|
|
8
8
|
import { applyStatus } from "./status.js";
|
|
9
9
|
import { detectColumnRenames, detectTableRenames } from "./rename-heuristic.js";
|
|
10
|
+
import { viewSqlEquals } from "../view-sql-compare.js";
|
|
11
|
+
import { checkExprEquals } from "../check-expr-compare.js";
|
|
10
12
|
import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
|
|
11
13
|
|
|
12
14
|
export interface DiffArgs {
|
|
@@ -25,6 +27,8 @@ export interface DiffArgs {
|
|
|
25
27
|
* the default. Pass additional patterns to extend.
|
|
26
28
|
*/
|
|
27
29
|
ignoreTables?: string[];
|
|
30
|
+
/** Dialect; CHECK-constraint evolution on existing tables is emitted for postgres only. */
|
|
31
|
+
dialect?: Dialect;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
const ALLOWED: ChangeStatus = { state: "allowed" };
|
|
@@ -128,6 +132,12 @@ export async function diff(
|
|
|
128
132
|
fk, status: ALLOWED,
|
|
129
133
|
});
|
|
130
134
|
}
|
|
135
|
+
// CHECK constraints are inlined into the CREATE TABLE DDL at emit time
|
|
136
|
+
// (both postgres and sqlite support inline CHECK), so they ride on
|
|
137
|
+
// `create-table.table.checks` rather than as separate add-check changes.
|
|
138
|
+
// For brand-new tables no add-check is emitted here; existing-table CHECK
|
|
139
|
+
// evolution (add/drop on tables present on both sides) is handled by
|
|
140
|
+
// diffTableChecks in Pass 2.
|
|
131
141
|
}
|
|
132
142
|
}
|
|
133
143
|
// Pass 1b: tables present in actual but not expected → drop-table
|
|
@@ -135,7 +145,7 @@ export async function diff(
|
|
|
135
145
|
for (const [id, t] of actualTables) {
|
|
136
146
|
if (!expectedTables.has(id)) {
|
|
137
147
|
const dropChange: Change & { _columns?: ColumnDescriptor[] } = {
|
|
138
|
-
kind: "drop-table", table: t.name, ...schemaSpread(t.schema), status: ALLOWED,
|
|
148
|
+
kind: "drop-table", table: t.name, ...schemaSpread(t.schema), restore: t, status: ALLOWED,
|
|
139
149
|
};
|
|
140
150
|
dropChange._columns = t.columns;
|
|
141
151
|
changes.push(dropChange);
|
|
@@ -149,13 +159,15 @@ export async function diff(
|
|
|
149
159
|
diffTableColumns(expectedTable, actualTable, changes);
|
|
150
160
|
diffTableIndexes(expectedTable, actualTable, changes);
|
|
151
161
|
diffTableForeignKeys(expectedTable, actualTable, changes);
|
|
162
|
+
// CHECK constraints on existing tables are evolved for postgres only (SQLite
|
|
163
|
+
// evolves checks via table recreate, not ALTER). Gated on `actual.checks`
|
|
164
|
+
// being populated — by the snapshot offline, or pg_constraint introspection.
|
|
165
|
+
if (args.dialect === "postgres") diffTableChecks(expectedTable, actualTable, changes);
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
// Pass 2b: views. Identity is (schema, name).
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// Users who change a view body without renaming need to manually drop+recreate
|
|
158
|
-
// or do a full bootstrap until replace-view-from-body-diff lands.
|
|
168
|
+
// Pass 2b: views. Identity is (schema, name). A name present on both sides
|
|
169
|
+
// with a divergent body (whitespace-/wrapper-normalized) emits replace-view;
|
|
170
|
+
// introspect now reads the actual body so view-body drift is visible.
|
|
159
171
|
diffViews(args.expected.views, args.actual.views, changes);
|
|
160
172
|
|
|
161
173
|
// Pass 3: detect table renames BEFORE column renames — so a renamed table's
|
|
@@ -221,7 +233,7 @@ function diffTableColumns(
|
|
|
221
233
|
for (const [name, ac] of actualCols) {
|
|
222
234
|
if (!expectedCols.has(name)) {
|
|
223
235
|
const dropChange: Change & { _sqlType?: SqlType; _nullable?: boolean } = {
|
|
224
|
-
kind: "drop-column", table, ...sx, column: name, status: ALLOWED,
|
|
236
|
+
kind: "drop-column", table, ...sx, column: name, restore: ac, status: ALLOWED,
|
|
225
237
|
};
|
|
226
238
|
dropChange._sqlType = ac.sqlType;
|
|
227
239
|
dropChange._nullable = ac.nullable;
|
|
@@ -245,13 +257,14 @@ function diffTableIndexes(
|
|
|
245
257
|
changes.push({ kind: "add-index", table, ...sx, index: ix, status: ALLOWED });
|
|
246
258
|
} else if (!indexEquals(ix, a)) {
|
|
247
259
|
// Index shape changed: drop + add (atomic from caller's perspective).
|
|
248
|
-
|
|
260
|
+
// restore = the ACTUAL shape so the down re-creates the original index.
|
|
261
|
+
changes.push({ kind: "drop-index", table, ...sx, index: name, restore: a, status: ALLOWED });
|
|
249
262
|
changes.push({ kind: "add-index", table, ...sx, index: ix, status: ALLOWED });
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
|
-
for (const [name] of actualIdx) {
|
|
265
|
+
for (const [name, ai] of actualIdx) {
|
|
253
266
|
if (!expectedIdx.has(name)) {
|
|
254
|
-
changes.push({ kind: "drop-index", table, ...sx, index: name, status: ALLOWED });
|
|
267
|
+
changes.push({ kind: "drop-index", table, ...sx, index: name, restore: ai, status: ALLOWED });
|
|
255
268
|
}
|
|
256
269
|
}
|
|
257
270
|
}
|
|
@@ -270,13 +283,35 @@ function diffTableForeignKeys(
|
|
|
270
283
|
if (!a) {
|
|
271
284
|
changes.push({ kind: "add-fk", table, ...sx, fk, status: ALLOWED });
|
|
272
285
|
} else if (!fkEquals(fk, a)) {
|
|
273
|
-
|
|
286
|
+
// FK shape changed: drop + add. restore = the ACTUAL shape so the down
|
|
287
|
+
// re-creates the original FK.
|
|
288
|
+
changes.push({ kind: "drop-fk", table, ...sx, fk: name, restore: a, status: ALLOWED });
|
|
274
289
|
changes.push({ kind: "add-fk", table, ...sx, fk, status: ALLOWED });
|
|
275
290
|
}
|
|
276
291
|
}
|
|
277
|
-
for (const [name] of actualFk) {
|
|
292
|
+
for (const [name, af] of actualFk) {
|
|
278
293
|
if (!expectedFk.has(name)) {
|
|
279
|
-
changes.push({ kind: "drop-fk", table, ...sx, fk: name, status: ALLOWED });
|
|
294
|
+
changes.push({ kind: "drop-fk", table, ...sx, fk: name, restore: af, status: ALLOWED });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function diffTableChecks(expected: TableDescriptor, actual: TableDescriptor, changes: Change[]): void {
|
|
300
|
+
const sx = schemaSpread(expected.schema);
|
|
301
|
+
const expectedChk = new Map(expected.checks.map((c) => [c.name, c]));
|
|
302
|
+
const actualChk = new Map(actual.checks.map((c) => [c.name, c]));
|
|
303
|
+
for (const [name, ec] of expectedChk) {
|
|
304
|
+
const ac = actualChk.get(name);
|
|
305
|
+
if (!ac) {
|
|
306
|
+
changes.push({ kind: "add-check", table: expected.name, ...sx, check: ec, status: ALLOWED });
|
|
307
|
+
} else if (!checkExprEquals(ec.expression, ac.expression)) {
|
|
308
|
+
changes.push({ kind: "drop-check", table: expected.name, ...sx, check: name, restore: ac, status: ALLOWED });
|
|
309
|
+
changes.push({ kind: "add-check", table: expected.name, ...sx, check: ec, status: ALLOWED });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const [name, ac] of actualChk) {
|
|
313
|
+
if (!expectedChk.has(name)) {
|
|
314
|
+
changes.push({ kind: "drop-check", table: expected.name, ...sx, check: name, restore: ac, status: ALLOWED });
|
|
280
315
|
}
|
|
281
316
|
}
|
|
282
317
|
}
|
|
@@ -291,8 +326,17 @@ function diffViews(
|
|
|
291
326
|
const exp = new Map(expected.map((v) => [viewIdentity(v), v] as const));
|
|
292
327
|
const act = new Map(actual.map((v) => [viewIdentity(v), v] as const));
|
|
293
328
|
for (const [id, v] of exp) {
|
|
294
|
-
|
|
329
|
+
const a = act.get(id);
|
|
330
|
+
if (a === undefined) {
|
|
295
331
|
changes.push({ kind: "create-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
|
|
332
|
+
} else if (
|
|
333
|
+
// Both bodies known and divergent → replace-view. When either body is
|
|
334
|
+
// absent (e.g. expected projection unresolvable, or an introspector that
|
|
335
|
+
// couldn't read the body) we cannot prove a change, so we leave it alone
|
|
336
|
+
// rather than emit a spurious replace.
|
|
337
|
+
v.sql !== undefined && a.sql !== undefined && !viewSqlEquals(v.sql, a.sql)
|
|
338
|
+
) {
|
|
339
|
+
changes.push({ kind: "replace-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
|
|
296
340
|
}
|
|
297
341
|
}
|
|
298
342
|
for (const [id, v] of act) {
|
package/src/diff/status.ts
CHANGED
|
@@ -27,6 +27,8 @@ function blockedReasonFor(c: Change, allow: AllowOptions): string | null {
|
|
|
27
27
|
return allow.dropIndex ? null : "destructive: drop-index not allowed (pass allow.dropIndex)";
|
|
28
28
|
case "drop-fk":
|
|
29
29
|
return allow.dropFk ? null : "destructive: drop-fk not allowed (pass allow.dropFk)";
|
|
30
|
+
case "drop-check":
|
|
31
|
+
return allow.dropCheck ? null : "destructive: drop-check not allowed (pass allow.dropCheck)";
|
|
30
32
|
|
|
31
33
|
case "change-column-type":
|
|
32
34
|
if (isWidening(c.from, c.to)) return null; // widening always allowed
|
|
@@ -47,6 +49,7 @@ function blockedReasonFor(c: Change, allow: AllowOptions): string | null {
|
|
|
47
49
|
case "change-column-default":
|
|
48
50
|
case "add-index":
|
|
49
51
|
case "add-fk":
|
|
52
|
+
case "add-check":
|
|
50
53
|
case "create-view":
|
|
51
54
|
case "drop-view":
|
|
52
55
|
case "replace-view":
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/drift/classify.ts
|
|
2
|
+
import { diff } from "../diff/index.js";
|
|
3
|
+
import type { Change, Dialect, DiffResult, SchemaSnapshot } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Change kinds that represent an object present in the live DB but absent from
|
|
7
|
+
* the snapshot. When the snapshot is the `expected` side of a diff, these are
|
|
8
|
+
* the DB's *unmanaged* objects — hand-authored, not modeled — and must never be
|
|
9
|
+
* treated as actionable drift or auto-dropped.
|
|
10
|
+
*/
|
|
11
|
+
const UNMANAGED_KINDS = new Set<string>([
|
|
12
|
+
"drop-table",
|
|
13
|
+
"drop-column",
|
|
14
|
+
"drop-index",
|
|
15
|
+
"drop-fk",
|
|
16
|
+
"drop-view",
|
|
17
|
+
// A CHECK present in the DB but not the snapshot is a DB-only object, same as
|
|
18
|
+
// a hand-authored index/fk/view — never actionable drift, never auto-dropped.
|
|
19
|
+
"drop-check",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export interface DriftClassification {
|
|
23
|
+
/** Modeled objects the DB is missing or has differently — actionable; fails the gate. */
|
|
24
|
+
drift: Change[];
|
|
25
|
+
/** Objects present in the DB but not the snapshot — informational; never dropped. */
|
|
26
|
+
unmanaged: Change[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function classifyDrift(changes: Change[]): DriftClassification {
|
|
30
|
+
const drift: Change[] = [];
|
|
31
|
+
const unmanaged: Change[] = [];
|
|
32
|
+
for (const c of changes) {
|
|
33
|
+
if (UNMANAGED_KINDS.has(c.kind)) unmanaged.push(c);
|
|
34
|
+
else drift.push(c);
|
|
35
|
+
}
|
|
36
|
+
return { drift, unmanaged };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Drift of a live DB (introspected into `actual`) against the committed snapshot.
|
|
41
|
+
* Diffs with `expected = snapshot` so that objects only in the DB surface as
|
|
42
|
+
* `drop-*` → classified `unmanaged`; objects the snapshot has but the DB lacks or
|
|
43
|
+
* differs surface as create/add/change → classified `drift`.
|
|
44
|
+
*/
|
|
45
|
+
export async function driftAgainstSnapshot(
|
|
46
|
+
snapshot: SchemaSnapshot,
|
|
47
|
+
actual: SchemaSnapshot,
|
|
48
|
+
dialect?: Dialect,
|
|
49
|
+
): Promise<DriftClassification> {
|
|
50
|
+
const result: DiffResult = await diff({
|
|
51
|
+
expected: snapshot,
|
|
52
|
+
actual,
|
|
53
|
+
...(dialect !== undefined ? { dialect } : {}),
|
|
54
|
+
});
|
|
55
|
+
return classifyDrift(result.changes);
|
|
56
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// computeDrift — structural + view-body drift of a live DB vs the metadata-
|
|
2
|
+
// declared schema. Thin composition over the existing pipeline:
|
|
3
|
+
// buildExpectedSchema(metadata) + introspect(db, dialect) → diff(...)
|
|
4
|
+
//
|
|
5
|
+
// Used by the `meta verify --db` schema-drift gate: a non-empty `changes` list
|
|
6
|
+
// means the live DB has diverged from what the metadata describes (a missing
|
|
7
|
+
// column, a changed view body, an extra table, etc.). Unlike `meta migrate`,
|
|
8
|
+
// drift detection never emits SQL or asks about ambiguous renames — it just
|
|
9
|
+
// reports the divergence so CI can fail loud.
|
|
10
|
+
|
|
11
|
+
import type { Kysely } from "kysely";
|
|
12
|
+
import type { MetaRoot } from "@metaobjectsdev/metadata";
|
|
13
|
+
import type { ColumnNamingStrategy } from "@metaobjectsdev/metadata";
|
|
14
|
+
import { buildExpectedSchema } from "../expected-schema.js";
|
|
15
|
+
import { introspect } from "../introspect/index.js";
|
|
16
|
+
import { diff } from "../diff/index.js";
|
|
17
|
+
import type { AllowOptions, Dialect, DiffResult } from "../types.js";
|
|
18
|
+
|
|
19
|
+
export interface ComputeDriftOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Destructive-change permissions. Mirrors `diff`'s `allow` — a drift that is
|
|
22
|
+
* "allowed" still appears in `changes` (it's still drift), so the gate fails
|
|
23
|
+
* on it. `allow` only affects `blocked`. Defaults to `{}`.
|
|
24
|
+
*/
|
|
25
|
+
allow?: AllowOptions;
|
|
26
|
+
/**
|
|
27
|
+
* Column-naming strategy for fields with no `@column` override. Must match
|
|
28
|
+
* the runtime's strategy or the expected schema's columns won't line up with
|
|
29
|
+
* what introspection sees. Defaults to `buildExpectedSchema`'s default.
|
|
30
|
+
*/
|
|
31
|
+
columnNamingStrategy?: ColumnNamingStrategy;
|
|
32
|
+
/**
|
|
33
|
+
* Table-name patterns to ignore on both sides (passed through to `diff`).
|
|
34
|
+
* Omit to keep `diff`'s default (migration-tracking sidecar tables).
|
|
35
|
+
*/
|
|
36
|
+
ignoreTables?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compute the drift between a live DB and the metadata-declared schema.
|
|
41
|
+
*
|
|
42
|
+
* Returns a `DiffResult` whose `changes` is empty iff the DB matches the
|
|
43
|
+
* metadata. The caller decides exit behavior (the schema-drift gate fails when
|
|
44
|
+
* `changes` is non-empty).
|
|
45
|
+
*/
|
|
46
|
+
export async function computeDrift(
|
|
47
|
+
db: Kysely<Record<string, unknown>>,
|
|
48
|
+
dialect: Dialect,
|
|
49
|
+
metadata: MetaRoot,
|
|
50
|
+
opts?: ComputeDriftOptions,
|
|
51
|
+
): Promise<DiffResult> {
|
|
52
|
+
const expected = buildExpectedSchema(metadata, {
|
|
53
|
+
dialect,
|
|
54
|
+
...(opts?.columnNamingStrategy !== undefined
|
|
55
|
+
? { columnNamingStrategy: opts.columnNamingStrategy }
|
|
56
|
+
: {}),
|
|
57
|
+
});
|
|
58
|
+
const actual = await introspect(db, dialect);
|
|
59
|
+
return diff({
|
|
60
|
+
expected,
|
|
61
|
+
actual,
|
|
62
|
+
dialect,
|
|
63
|
+
allow: opts?.allow ?? {},
|
|
64
|
+
...(opts?.ignoreTables !== undefined ? { ignoreTables: opts.ignoreTables } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { splitSqlStatements } from "../sql/split-statements.js";
|
|
2
|
+
|
|
1
3
|
const MAX_STATEMENT_BYTES = 1 * 1024 * 1024; // 1 MB — D1 batch API per-statement limit (path used by `wrangler d1 migrations apply --file`).
|
|
2
4
|
|
|
3
5
|
export class D1UnsupportedStatementError extends Error {
|
|
@@ -22,73 +24,42 @@ export function applyD1SafetyPass(sql: string, opts?: { collectWarnings?: boolea
|
|
|
22
24
|
return collect ? { sql: "", warnings } : "";
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
// splitSqlStatements already returns trimmed, non-empty statements.
|
|
28
|
+
const statements = splitSqlStatements(sql);
|
|
26
29
|
const kept: string[] = [];
|
|
27
30
|
|
|
28
31
|
for (const stmt of statements) {
|
|
29
|
-
const trimmed = stmt.trim();
|
|
30
|
-
if (trimmed.length === 0) continue;
|
|
31
|
-
|
|
32
32
|
// Reject hard failures up front.
|
|
33
|
-
if (/^\s*(ATTACH|DETACH)\b/i.test(
|
|
34
|
-
throw new D1UnsupportedStatementError(
|
|
33
|
+
if (/^\s*(ATTACH|DETACH)\b/i.test(stmt)) {
|
|
34
|
+
throw new D1UnsupportedStatementError(stmt, "ATTACH/DETACH DATABASE");
|
|
35
35
|
}
|
|
36
|
-
if (/^\s*VACUUM\b/i.test(
|
|
37
|
-
throw new D1UnsupportedStatementError(
|
|
36
|
+
if (/^\s*VACUUM\b/i.test(stmt)) {
|
|
37
|
+
throw new D1UnsupportedStatementError(stmt, "VACUUM");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Strip explicit transaction control + savepoints.
|
|
41
|
-
if (/^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(
|
|
41
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(stmt)) {
|
|
42
42
|
continue;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const byteLen = byteLength(
|
|
45
|
+
const byteLen = byteLength(stmt);
|
|
46
46
|
if (byteLen > MAX_STATEMENT_BYTES) {
|
|
47
47
|
warnings.push(
|
|
48
48
|
`statement exceeds D1's 1 MB per-statement limit (${byteLen} bytes); ` +
|
|
49
|
-
`may be rejected by D1 at apply time: ${
|
|
49
|
+
`may be rejected by D1 at apply time: ${stmt.slice(0, 80)}...`,
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
kept.push(
|
|
53
|
+
kept.push(stmt);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Re-join: each statement on its own line, blank line between top-level
|
|
57
|
-
//
|
|
58
|
-
|
|
56
|
+
// Re-join: each statement on its own line, blank line between top-level DDL
|
|
57
|
+
// statements (matches sqlite emit's output style). splitSqlStatements strips
|
|
58
|
+
// the `;` separators, so re-add exactly one terminator per kept statement.
|
|
59
|
+
const out = kept.map((s) => `${s};`).join("\n\n");
|
|
59
60
|
return collect ? { sql: out, warnings } : out;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
* Split SQL on `;` boundaries, respecting single-quoted strings (SQL uses
|
|
64
|
-
* '' to escape a single quote inside a literal — two consecutive quotes toggle
|
|
65
|
-
* inString twice, net zero, which is exactly what we want).
|
|
66
|
-
* Sufficient for our DDL output; we don't generate dollar-quoted blocks or
|
|
67
|
-
* other exotic SQLite literals.
|
|
68
|
-
*/
|
|
69
|
-
function splitStatements(sql: string): string[] {
|
|
70
|
-
const out: string[] = [];
|
|
71
|
-
let buf = "";
|
|
72
|
-
let inString = false;
|
|
73
|
-
for (let i = 0; i < sql.length; i++) {
|
|
74
|
-
const c = sql[i]!;
|
|
75
|
-
if (c === "'") {
|
|
76
|
-
buf += c;
|
|
77
|
-
inString = !inString;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (c === ";" && !inString) {
|
|
81
|
-
buf += ";";
|
|
82
|
-
out.push(buf);
|
|
83
|
-
buf = "";
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
buf += c;
|
|
87
|
-
}
|
|
88
|
-
if (buf.trim().length > 0) out.push(buf);
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
63
|
function byteLength(s: string): number {
|
|
93
64
|
return new TextEncoder().encode(s).length;
|
|
94
65
|
}
|