@sedrino/db-schema 0.1.1 → 0.1.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/README.md +62 -6
- package/dist/cli.js +1904 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +365 -85
- package/dist/index.js +1108 -187
- package/dist/index.js.map +1 -1
- package/docs/cli.md +93 -0
- package/docs/expressions-and-transforms.md +165 -0
- package/docs/index.md +5 -2
- package/docs/migrations.md +183 -3
- package/docs/planning-and-apply.md +200 -0
- package/docs/relations.md +130 -0
- package/docs/schema-document.md +62 -0
- package/package.json +3 -2
- package/src/apply.ts +67 -0
- package/src/cli.ts +105 -7
- package/src/drizzle.ts +348 -1
- package/src/index.ts +38 -1
- package/src/migration.ts +315 -3
- package/src/operations.ts +278 -0
- package/src/planner.ts +7 -190
- package/src/project.ts +157 -1
- package/src/sql-expression.ts +123 -0
- package/src/sqlite.ts +150 -9
- package/src/transforms.ts +94 -0
- package/src/utils.ts +54 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import path2 from "path";
|
|
5
|
+
import { env, exit } from "process";
|
|
6
|
+
|
|
7
|
+
// src/utils.ts
|
|
8
|
+
var SQLITE_EPOCH_MS_NOW = "CAST(strftime('%s','now') AS integer) * 1000 + CAST((strftime('%f','now') - strftime('%S','now')) * 1000 AS integer)";
|
|
9
|
+
function sqliteEpochMsNowSql() {
|
|
10
|
+
return SQLITE_EPOCH_MS_NOW;
|
|
11
|
+
}
|
|
12
|
+
function toSnakeCase(value) {
|
|
13
|
+
return value.replace(/-/g, "_").replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1_$2").toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function toPascalCase(value) {
|
|
16
|
+
return value.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\s+/).filter(Boolean).map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join("");
|
|
17
|
+
}
|
|
18
|
+
function toCamelCase(value) {
|
|
19
|
+
const pascal = toPascalCase(value);
|
|
20
|
+
return pascal.length > 0 ? `${pascal[0].toLowerCase()}${pascal.slice(1)}` : pascal;
|
|
21
|
+
}
|
|
22
|
+
function pluralize(value) {
|
|
23
|
+
if (value.endsWith("s")) return `${value}es`;
|
|
24
|
+
if (value.endsWith("y") && !/[aeiou]y$/i.test(value)) {
|
|
25
|
+
return `${value.slice(0, -1)}ies`;
|
|
26
|
+
}
|
|
27
|
+
return `${value}s`;
|
|
28
|
+
}
|
|
29
|
+
function singularize(value) {
|
|
30
|
+
if (value.endsWith("ies") && value.length > 3) {
|
|
31
|
+
return `${value.slice(0, -3)}y`;
|
|
32
|
+
}
|
|
33
|
+
if (value.endsWith("ses") && value.length > 3) {
|
|
34
|
+
return value.slice(0, -2);
|
|
35
|
+
}
|
|
36
|
+
if (value.endsWith("s") && !value.endsWith("ss") && value.length > 1) {
|
|
37
|
+
return value.slice(0, -1);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function quoteIdentifier(value) {
|
|
42
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
43
|
+
}
|
|
44
|
+
function tableVariableName(tableName) {
|
|
45
|
+
return `table${toPascalCase(tableName)}`;
|
|
46
|
+
}
|
|
47
|
+
function defaultStorageForLogical(logical, column) {
|
|
48
|
+
switch (logical.kind) {
|
|
49
|
+
case "id":
|
|
50
|
+
case "string":
|
|
51
|
+
case "text":
|
|
52
|
+
case "enum":
|
|
53
|
+
case "json":
|
|
54
|
+
return { strategy: "sqlite.text", column };
|
|
55
|
+
case "boolean":
|
|
56
|
+
case "integer":
|
|
57
|
+
return { strategy: "sqlite.integer", column };
|
|
58
|
+
case "number":
|
|
59
|
+
return { strategy: "sqlite.real", column };
|
|
60
|
+
case "temporal.instant":
|
|
61
|
+
return { strategy: "sqlite.temporalInstantEpochMs", column };
|
|
62
|
+
case "temporal.plainDate":
|
|
63
|
+
return { strategy: "sqlite.temporalPlainDateText", column };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function validateLogicalAndStorageCompatibility(logical, storage) {
|
|
67
|
+
switch (storage.strategy) {
|
|
68
|
+
case "sqlite.temporalInstantEpochMs":
|
|
69
|
+
return logical.kind === "temporal.instant" ? null : "sqlite.temporalInstantEpochMs storage requires logical kind temporal.instant";
|
|
70
|
+
case "sqlite.temporalPlainDateText":
|
|
71
|
+
return logical.kind === "temporal.plainDate" ? null : "sqlite.temporalPlainDateText storage requires logical kind temporal.plainDate";
|
|
72
|
+
case "sqlite.integer":
|
|
73
|
+
return logical.kind === "boolean" || logical.kind === "integer" ? null : "sqlite.integer storage requires logical kind boolean or integer";
|
|
74
|
+
case "sqlite.real":
|
|
75
|
+
return logical.kind === "number" ? null : "sqlite.real storage requires logical kind number";
|
|
76
|
+
case "sqlite.text":
|
|
77
|
+
return logical.kind === "id" || logical.kind === "string" || logical.kind === "text" || logical.kind === "enum" || logical.kind === "json" ? null : "sqlite.text storage requires logical kind id, string, text, enum, or json";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function validateDefaultCompatibility(field, defaultValue) {
|
|
81
|
+
if (defaultValue.kind === "generatedId") {
|
|
82
|
+
return field.logical.kind === "id" ? null : "generatedId default requires logical kind id";
|
|
83
|
+
}
|
|
84
|
+
if (defaultValue.kind === "now") {
|
|
85
|
+
return field.logical.kind === "temporal.instant" ? null : "now default requires logical kind temporal.instant";
|
|
86
|
+
}
|
|
87
|
+
if (field.logical.kind === "boolean" && typeof defaultValue.value !== "boolean") {
|
|
88
|
+
return "boolean fields require boolean literal defaults";
|
|
89
|
+
}
|
|
90
|
+
if (field.logical.kind === "integer" && !Number.isInteger(defaultValue.value)) {
|
|
91
|
+
return "integer fields require integer literal defaults";
|
|
92
|
+
}
|
|
93
|
+
if (field.logical.kind === "number" && typeof defaultValue.value !== "number") {
|
|
94
|
+
return "number fields require numeric literal defaults";
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function validateSchemaDocumentCompatibility(schema) {
|
|
99
|
+
const issues = [];
|
|
100
|
+
const tableNames = /* @__PURE__ */ new Set();
|
|
101
|
+
const indexNames = /* @__PURE__ */ new Set();
|
|
102
|
+
for (const table of schema.tables) {
|
|
103
|
+
if (tableNames.has(table.name)) {
|
|
104
|
+
issues.push({
|
|
105
|
+
path: `tables.${table.name}`,
|
|
106
|
+
message: `Duplicate table name ${table.name}`
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
tableNames.add(table.name);
|
|
110
|
+
const fieldNames = /* @__PURE__ */ new Set();
|
|
111
|
+
const columnNames = /* @__PURE__ */ new Set();
|
|
112
|
+
let primaryKeyCount = 0;
|
|
113
|
+
for (const field of table.fields) {
|
|
114
|
+
if (fieldNames.has(field.name)) {
|
|
115
|
+
issues.push({
|
|
116
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
117
|
+
message: `Duplicate field name ${field.name}`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
fieldNames.add(field.name);
|
|
121
|
+
if (columnNames.has(field.storage.column)) {
|
|
122
|
+
issues.push({
|
|
123
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
124
|
+
message: `Duplicate column name ${field.storage.column}`
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
columnNames.add(field.storage.column);
|
|
128
|
+
if (field.primaryKey) primaryKeyCount += 1;
|
|
129
|
+
if (field.primaryKey && field.nullable) {
|
|
130
|
+
issues.push({
|
|
131
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
132
|
+
message: "Primary key fields cannot be nullable"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const storageIssue = validateLogicalAndStorageCompatibility(field.logical, field.storage);
|
|
136
|
+
if (storageIssue) {
|
|
137
|
+
issues.push({
|
|
138
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
139
|
+
message: storageIssue
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (field.default) {
|
|
143
|
+
const defaultIssue = validateDefaultCompatibility(field, field.default);
|
|
144
|
+
if (defaultIssue) {
|
|
145
|
+
issues.push({
|
|
146
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
147
|
+
message: defaultIssue
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (field.references && !tableNames.has(field.references.table)) {
|
|
152
|
+
const targetTableExists = schema.tables.some(
|
|
153
|
+
(candidate) => candidate.name === field.references.table
|
|
154
|
+
);
|
|
155
|
+
if (!targetTableExists) {
|
|
156
|
+
issues.push({
|
|
157
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
158
|
+
message: `Referenced table ${field.references.table} does not exist`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (field.references) {
|
|
163
|
+
const targetTable = schema.tables.find(
|
|
164
|
+
(candidate) => candidate.name === field.references.table
|
|
165
|
+
);
|
|
166
|
+
const targetFieldExists = targetTable?.fields.some(
|
|
167
|
+
(candidate) => candidate.name === field.references.field
|
|
168
|
+
);
|
|
169
|
+
if (targetTable && !targetFieldExists) {
|
|
170
|
+
issues.push({
|
|
171
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
172
|
+
message: `Referenced field ${field.references.field} does not exist on table ${field.references.table}`
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (primaryKeyCount > 1) {
|
|
178
|
+
issues.push({
|
|
179
|
+
path: `tables.${table.name}`,
|
|
180
|
+
message: "Composite primary keys are not supported in v1"
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
for (const index of table.indexes) {
|
|
184
|
+
const indexName = index.name ?? `${table.name}_${index.fields.join("_")}_idx`;
|
|
185
|
+
if (indexNames.has(indexName)) {
|
|
186
|
+
issues.push({
|
|
187
|
+
path: `tables.${table.name}.indexes.${indexName}`,
|
|
188
|
+
message: `Duplicate index name ${indexName}`
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
indexNames.add(indexName);
|
|
192
|
+
for (const fieldName of index.fields) {
|
|
193
|
+
if (!fieldNames.has(fieldName)) {
|
|
194
|
+
issues.push({
|
|
195
|
+
path: `tables.${table.name}.indexes.${index.name ?? fieldName}`,
|
|
196
|
+
message: `Index references missing field ${fieldName}`
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const unique of table.uniques) {
|
|
202
|
+
const uniqueName = unique.name ?? `${table.name}_${unique.fields.join("_")}_unique`;
|
|
203
|
+
if (indexNames.has(uniqueName)) {
|
|
204
|
+
issues.push({
|
|
205
|
+
path: `tables.${table.name}.uniques.${uniqueName}`,
|
|
206
|
+
message: `Duplicate index name ${uniqueName}`
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
indexNames.add(uniqueName);
|
|
210
|
+
for (const fieldName of unique.fields) {
|
|
211
|
+
if (!fieldNames.has(fieldName)) {
|
|
212
|
+
issues.push({
|
|
213
|
+
path: `tables.${table.name}.uniques.${unique.name ?? fieldName}`,
|
|
214
|
+
message: `Unique constraint references missing field ${fieldName}`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return issues;
|
|
221
|
+
}
|
|
222
|
+
function stableStringify(value) {
|
|
223
|
+
return JSON.stringify(sortValue(value));
|
|
224
|
+
}
|
|
225
|
+
function sortValue(value) {
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
return value.map((entry) => sortValue(entry));
|
|
228
|
+
}
|
|
229
|
+
if (value && typeof value === "object") {
|
|
230
|
+
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, innerValue]) => [key, sortValue(innerValue)]);
|
|
231
|
+
return Object.fromEntries(entries);
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
function createSchemaHash(schema) {
|
|
236
|
+
const text = stableStringify(schema);
|
|
237
|
+
let hash = 2166136261;
|
|
238
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
239
|
+
hash ^= text.charCodeAt(index);
|
|
240
|
+
hash = Math.imul(hash, 16777619);
|
|
241
|
+
}
|
|
242
|
+
return `schema_${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
243
|
+
}
|
|
244
|
+
function cloneSchema(value) {
|
|
245
|
+
return structuredClone(value);
|
|
246
|
+
}
|
|
247
|
+
function renameStorageColumn(field, newFieldName) {
|
|
248
|
+
const next = cloneSchema(field);
|
|
249
|
+
const column = toSnakeCase(newFieldName);
|
|
250
|
+
next.name = newFieldName;
|
|
251
|
+
next.storage = { ...next.storage, column };
|
|
252
|
+
return next;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/drizzle.ts
|
|
256
|
+
function compileSchemaToDrizzle(schema) {
|
|
257
|
+
const sqliteImports = /* @__PURE__ */ new Set(["sqliteTable"]);
|
|
258
|
+
const toolkitImports = /* @__PURE__ */ new Set();
|
|
259
|
+
let needsRelationsImport = false;
|
|
260
|
+
let needsUlid = false;
|
|
261
|
+
for (const table of schema.tables) {
|
|
262
|
+
for (const field of table.fields) {
|
|
263
|
+
switch (field.logical.kind) {
|
|
264
|
+
case "id":
|
|
265
|
+
case "string":
|
|
266
|
+
case "text":
|
|
267
|
+
case "enum":
|
|
268
|
+
case "json":
|
|
269
|
+
sqliteImports.add("text");
|
|
270
|
+
break;
|
|
271
|
+
case "boolean":
|
|
272
|
+
case "integer":
|
|
273
|
+
sqliteImports.add("integer");
|
|
274
|
+
break;
|
|
275
|
+
case "number":
|
|
276
|
+
sqliteImports.add("real");
|
|
277
|
+
break;
|
|
278
|
+
case "temporal.instant":
|
|
279
|
+
toolkitImports.add("temporalInstantEpochMs");
|
|
280
|
+
break;
|
|
281
|
+
case "temporal.plainDate":
|
|
282
|
+
toolkitImports.add("temporalPlainDateText");
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
if (field.default?.kind === "generatedId") needsUlid = true;
|
|
286
|
+
if (field.default?.kind === "now") toolkitImports.add("epochMsNow");
|
|
287
|
+
if (field.references) needsRelationsImport = true;
|
|
288
|
+
}
|
|
289
|
+
if (table.indexes.length > 0) sqliteImports.add("index");
|
|
290
|
+
if (table.uniques.length > 0) sqliteImports.add("uniqueIndex");
|
|
291
|
+
}
|
|
292
|
+
const lines = [];
|
|
293
|
+
if (needsRelationsImport) {
|
|
294
|
+
lines.push(`import { defineRelations } from "drizzle-orm";`);
|
|
295
|
+
}
|
|
296
|
+
lines.push(
|
|
297
|
+
`import { ${Array.from(sqliteImports).sort().join(", ")} } from "drizzle-orm/sqlite-core";`
|
|
298
|
+
);
|
|
299
|
+
if (toolkitImports.size > 0) {
|
|
300
|
+
lines.push(
|
|
301
|
+
`import { ${Array.from(toolkitImports).sort().join(", ")} } from "@sedrino/toolkit/drizzle/sqlite";`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (needsUlid) {
|
|
305
|
+
lines.push(`import { ulid } from "ulid";`);
|
|
306
|
+
}
|
|
307
|
+
lines.push("");
|
|
308
|
+
for (const table of schema.tables) {
|
|
309
|
+
lines.push(renderTable(table));
|
|
310
|
+
lines.push("");
|
|
311
|
+
}
|
|
312
|
+
const relationsSource = compileSchemaToDrizzleRelations(schema);
|
|
313
|
+
if (relationsSource) {
|
|
314
|
+
lines.push(relationsSource);
|
|
315
|
+
lines.push("");
|
|
316
|
+
}
|
|
317
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
318
|
+
}
|
|
319
|
+
function compileSchemaToDrizzleRelations(schema) {
|
|
320
|
+
const directRelations = inferDirectRelations(schema);
|
|
321
|
+
const manyToManyRelations = inferManyToManyRelations(schema, directRelations.usedKeysByTable);
|
|
322
|
+
if (directRelations.relations.length === 0 && manyToManyRelations.length === 0) return "";
|
|
323
|
+
const schemaEntries = schema.tables.map((table) => ` ${tableSchemaKey(table.name)}: ${tableVariableName(table.name)},`).join("\n");
|
|
324
|
+
const tableBlocks = schema.tables.map((table) => {
|
|
325
|
+
const tableKey = tableSchemaKey(table.name);
|
|
326
|
+
const relationLines = directRelations.relations.filter(
|
|
327
|
+
(relation) => relation.sourceTable.name === table.name || relation.targetTable.name === table.name
|
|
328
|
+
).flatMap((relation) => {
|
|
329
|
+
const lines = [];
|
|
330
|
+
if (relation.sourceTable.name === table.name) {
|
|
331
|
+
lines.push(` ${relation.forwardKey}: ${renderOneRelation(relation)},`);
|
|
332
|
+
}
|
|
333
|
+
if (relation.targetTable.name === table.name && !relation.skipReverse) {
|
|
334
|
+
lines.push(` ${relation.reverseKey}: ${renderReverseRelation(relation)},`);
|
|
335
|
+
}
|
|
336
|
+
return lines;
|
|
337
|
+
}).concat(
|
|
338
|
+
manyToManyRelations.filter((relation) => relation.leftTable.name === table.name || relation.rightTable.name === table.name).map(
|
|
339
|
+
(relation) => relation.leftTable.name === table.name ? ` ${relation.leftKey}: ${renderManyToManyRelation(relation, "left")},` : ` ${relation.rightKey}: ${renderManyToManyRelation(relation, "right")},`
|
|
340
|
+
)
|
|
341
|
+
);
|
|
342
|
+
if (relationLines.length === 0) return null;
|
|
343
|
+
return ` ${tableKey}: {
|
|
344
|
+
${relationLines.join("\n")}
|
|
345
|
+
},`;
|
|
346
|
+
}).filter((block) => block !== null).join("\n");
|
|
347
|
+
return `export const relations = defineRelations({
|
|
348
|
+
${schemaEntries}
|
|
349
|
+
}, (r) => ({
|
|
350
|
+
${tableBlocks}
|
|
351
|
+
}));`;
|
|
352
|
+
}
|
|
353
|
+
function renderTable(table) {
|
|
354
|
+
const variableName = tableVariableName(table.name);
|
|
355
|
+
const fieldLines = table.fields.map((field) => ` ${field.name}: ${renderField(field)},`);
|
|
356
|
+
const tableConfig = renderTableConfig(table);
|
|
357
|
+
if (!tableConfig) {
|
|
358
|
+
return `export const ${variableName} = sqliteTable("${table.name}", {
|
|
359
|
+
${fieldLines.join("\n")}
|
|
360
|
+
});`;
|
|
361
|
+
}
|
|
362
|
+
return `export const ${variableName} = sqliteTable("${table.name}", {
|
|
363
|
+
${fieldLines.join(
|
|
364
|
+
"\n"
|
|
365
|
+
)}
|
|
366
|
+
}, (table) => [
|
|
367
|
+
${tableConfig}
|
|
368
|
+
]);`;
|
|
369
|
+
}
|
|
370
|
+
function renderTableConfig(table) {
|
|
371
|
+
const lines = [];
|
|
372
|
+
for (const index of table.indexes) {
|
|
373
|
+
lines.push(` ${renderIndex(table.name, index)},`);
|
|
374
|
+
}
|
|
375
|
+
for (const unique of table.uniques) {
|
|
376
|
+
lines.push(` ${renderUnique(table.name, unique)},`);
|
|
377
|
+
}
|
|
378
|
+
return lines.length > 0 ? lines.join("\n") : null;
|
|
379
|
+
}
|
|
380
|
+
function renderIndex(tableName, index) {
|
|
381
|
+
const name = index.name ?? `${tableName}_${index.fields.join("_")}_idx`;
|
|
382
|
+
return `index("${name}").on(${index.fields.map((field) => `table.${field}`).join(", ")})`;
|
|
383
|
+
}
|
|
384
|
+
function renderUnique(tableName, unique) {
|
|
385
|
+
const name = unique.name ?? `${tableName}_${unique.fields.join("_")}_unique`;
|
|
386
|
+
return `uniqueIndex("${name}").on(${unique.fields.map((field) => `table.${field}`).join(", ")})`;
|
|
387
|
+
}
|
|
388
|
+
function renderField(field) {
|
|
389
|
+
let expression = renderBaseColumn(field);
|
|
390
|
+
if (field.logical.kind === "json") {
|
|
391
|
+
expression += `.$type<${field.logical.tsType}>()`;
|
|
392
|
+
}
|
|
393
|
+
if (field.primaryKey) expression += ".primaryKey()";
|
|
394
|
+
if (!field.nullable) expression += ".notNull()";
|
|
395
|
+
if (field.unique && !field.primaryKey) expression += ".unique()";
|
|
396
|
+
const defaultExpression = renderDrizzleDefault(field.default);
|
|
397
|
+
if (defaultExpression) expression += `.default(${defaultExpression})`;
|
|
398
|
+
if (field.default?.kind === "generatedId") {
|
|
399
|
+
expression += `.$default(() => \`${field.default.prefix}-\${ulid()}\`)`;
|
|
400
|
+
}
|
|
401
|
+
if (field.references) {
|
|
402
|
+
const targetTable = tableVariableName(field.references.table);
|
|
403
|
+
const referenceParts = [`() => ${targetTable}.${field.references.field}`];
|
|
404
|
+
const options = [];
|
|
405
|
+
if (field.references.onDelete) options.push(`onDelete: "${field.references.onDelete}"`);
|
|
406
|
+
if (field.references.onUpdate) options.push(`onUpdate: "${field.references.onUpdate}"`);
|
|
407
|
+
if (options.length > 0) {
|
|
408
|
+
referenceParts.push(`{ ${options.join(", ")} }`);
|
|
409
|
+
}
|
|
410
|
+
expression += `.references(${referenceParts.join(", ")})`;
|
|
411
|
+
}
|
|
412
|
+
return expression;
|
|
413
|
+
}
|
|
414
|
+
function renderBaseColumn(field) {
|
|
415
|
+
switch (field.logical.kind) {
|
|
416
|
+
case "id":
|
|
417
|
+
case "string":
|
|
418
|
+
case "text":
|
|
419
|
+
return `text("${field.storage.column}")`;
|
|
420
|
+
case "enum":
|
|
421
|
+
return `text("${field.storage.column}", { enum: [${field.logical.values.map((value) => JSON.stringify(value)).join(", ")}] })`;
|
|
422
|
+
case "json":
|
|
423
|
+
return `text("${field.storage.column}", { mode: "json" })`;
|
|
424
|
+
case "boolean":
|
|
425
|
+
return `integer("${field.storage.column}", { mode: "boolean" })`;
|
|
426
|
+
case "integer":
|
|
427
|
+
return `integer("${field.storage.column}", { mode: "number" })`;
|
|
428
|
+
case "number":
|
|
429
|
+
return `real("${field.storage.column}")`;
|
|
430
|
+
case "temporal.instant":
|
|
431
|
+
return `temporalInstantEpochMs("${field.storage.column}")`;
|
|
432
|
+
case "temporal.plainDate":
|
|
433
|
+
return `temporalPlainDateText("${field.storage.column}")`;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function renderDrizzleDefault(defaultValue) {
|
|
437
|
+
if (!defaultValue) return null;
|
|
438
|
+
switch (defaultValue.kind) {
|
|
439
|
+
case "generatedId":
|
|
440
|
+
return null;
|
|
441
|
+
case "now":
|
|
442
|
+
return "epochMsNow()";
|
|
443
|
+
case "literal":
|
|
444
|
+
return JSON.stringify(defaultValue.value);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function inferDirectRelations(schema) {
|
|
448
|
+
const junctionTableNames = new Set(inferJunctionCandidates(schema).map((candidate) => candidate.junctionTable.name));
|
|
449
|
+
const raw = schema.tables.flatMap(
|
|
450
|
+
(sourceTable) => sourceTable.fields.filter((field) => field.references).map((field) => {
|
|
451
|
+
const targetTable = schema.tables.find((table) => table.name === field.references.table);
|
|
452
|
+
if (!targetTable) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`Cannot infer relation for ${sourceTable.name}.${field.name}; missing target table ${field.references.table}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
sourceTable,
|
|
459
|
+
targetTable,
|
|
460
|
+
sourceField: field,
|
|
461
|
+
targetFieldName: field.references.field,
|
|
462
|
+
sourceTableKey: tableSchemaKey(sourceTable.name),
|
|
463
|
+
targetTableKey: tableSchemaKey(targetTable.name),
|
|
464
|
+
forwardBase: inferForwardRelationKey(field, targetTable),
|
|
465
|
+
reverseBase: tableSchemaKey(pluralize(sourceTable.name))
|
|
466
|
+
};
|
|
467
|
+
})
|
|
468
|
+
);
|
|
469
|
+
const duplicatePairCounts = /* @__PURE__ */ new Map();
|
|
470
|
+
for (const relation of raw) {
|
|
471
|
+
const key = `${relation.sourceTable.name}::${relation.targetTable.name}`;
|
|
472
|
+
duplicatePairCounts.set(key, (duplicatePairCounts.get(key) ?? 0) + 1);
|
|
473
|
+
}
|
|
474
|
+
const usedByTable = /* @__PURE__ */ new Map();
|
|
475
|
+
return {
|
|
476
|
+
relations: raw.map((relation) => {
|
|
477
|
+
const skipReverse = junctionTableNames.has(relation.sourceTable.name);
|
|
478
|
+
const pairKey = `${relation.sourceTable.name}::${relation.targetTable.name}`;
|
|
479
|
+
const needsAlias = (duplicatePairCounts.get(pairKey) ?? 0) > 1;
|
|
480
|
+
const alias = needsAlias ? relation.forwardBase : null;
|
|
481
|
+
const sourceUsed = ensureUsedSet(usedByTable, relation.sourceTable.name);
|
|
482
|
+
const targetUsed = ensureUsedSet(usedByTable, relation.targetTable.name);
|
|
483
|
+
const reverseKind = inferReverseKind(relation.sourceTable, relation.sourceField);
|
|
484
|
+
const forwardKey = ensureUniqueKey(
|
|
485
|
+
sourceUsed,
|
|
486
|
+
relation.forwardBase,
|
|
487
|
+
`${relation.forwardBase}${toPascalCase(relation.targetTable.name)}`
|
|
488
|
+
);
|
|
489
|
+
const reversePreferred = needsAlias ? `${relation.forwardBase}${toPascalCase(pluralize(relation.sourceTable.name))}` : relation.reverseBase;
|
|
490
|
+
const reverseFallback = `${relation.forwardBase}${toPascalCase(pluralize(relation.sourceTable.name))}`;
|
|
491
|
+
const reverseKey = skipReverse ? reversePreferred : ensureUniqueKey(targetUsed, reversePreferred, reverseFallback);
|
|
492
|
+
return {
|
|
493
|
+
sourceTable: relation.sourceTable,
|
|
494
|
+
targetTable: relation.targetTable,
|
|
495
|
+
sourceField: relation.sourceField,
|
|
496
|
+
targetFieldName: relation.targetFieldName,
|
|
497
|
+
sourceTableKey: relation.sourceTableKey,
|
|
498
|
+
targetTableKey: relation.targetTableKey,
|
|
499
|
+
forwardKey,
|
|
500
|
+
reverseKey,
|
|
501
|
+
alias,
|
|
502
|
+
reverseKind,
|
|
503
|
+
skipReverse
|
|
504
|
+
};
|
|
505
|
+
}),
|
|
506
|
+
usedKeysByTable: usedByTable
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function inferManyToManyRelations(schema, usedByTable) {
|
|
510
|
+
const candidates = inferJunctionCandidates(schema);
|
|
511
|
+
const pairCounts = /* @__PURE__ */ new Map();
|
|
512
|
+
for (const candidate of candidates) {
|
|
513
|
+
const pairKey = createPairKey(candidate.leftTable.name, candidate.rightTable.name);
|
|
514
|
+
pairCounts.set(pairKey, (pairCounts.get(pairKey) ?? 0) + 1);
|
|
515
|
+
}
|
|
516
|
+
return candidates.map((candidate) => {
|
|
517
|
+
const pairKey = createPairKey(candidate.leftTable.name, candidate.rightTable.name);
|
|
518
|
+
const duplicatePair = (pairCounts.get(pairKey) ?? 0) > 1;
|
|
519
|
+
const selfRelation = candidate.leftTable.name === candidate.rightTable.name;
|
|
520
|
+
const alias = duplicatePair || selfRelation ? tableSchemaKey(candidate.junctionTable.name) : null;
|
|
521
|
+
const leftUsed = ensureUsedSet(usedByTable, candidate.leftTable.name);
|
|
522
|
+
const rightUsed = ensureUsedSet(usedByTable, candidate.rightTable.name);
|
|
523
|
+
const leftPreferred = inferManyToManyKey(candidate.rightTable, candidate.rightField, selfRelation);
|
|
524
|
+
const rightPreferred = inferManyToManyKey(candidate.leftTable, candidate.leftField, selfRelation);
|
|
525
|
+
const leftFallback = `${leftPreferred}${toPascalCase(candidate.junctionTable.name)}`;
|
|
526
|
+
const rightFallback = `${rightPreferred}${toPascalCase(candidate.junctionTable.name)}`;
|
|
527
|
+
return {
|
|
528
|
+
junctionTable: candidate.junctionTable,
|
|
529
|
+
leftTable: candidate.leftTable,
|
|
530
|
+
rightTable: candidate.rightTable,
|
|
531
|
+
leftField: candidate.leftField,
|
|
532
|
+
rightField: candidate.rightField,
|
|
533
|
+
leftTargetFieldName: candidate.leftField.references.field,
|
|
534
|
+
rightTargetFieldName: candidate.rightField.references.field,
|
|
535
|
+
junctionTableKey: tableSchemaKey(candidate.junctionTable.name),
|
|
536
|
+
leftTableKey: tableSchemaKey(candidate.leftTable.name),
|
|
537
|
+
rightTableKey: tableSchemaKey(candidate.rightTable.name),
|
|
538
|
+
leftKey: ensureUniqueKey(leftUsed, leftPreferred, leftFallback),
|
|
539
|
+
rightKey: ensureUniqueKey(rightUsed, rightPreferred, rightFallback),
|
|
540
|
+
alias
|
|
541
|
+
};
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
function inferJunctionCandidates(schema) {
|
|
545
|
+
return schema.tables.flatMap((junctionTable) => {
|
|
546
|
+
const referencedFields = junctionTable.fields.filter((field) => field.references);
|
|
547
|
+
if (referencedFields.length !== 2) return [];
|
|
548
|
+
if (junctionTable.fields.length !== 2) return [];
|
|
549
|
+
const compositeUnique = junctionTable.uniques.some(
|
|
550
|
+
(unique) => unique.fields.length === 2 && referencedFields.every((field) => unique.fields.includes(field.name))
|
|
551
|
+
);
|
|
552
|
+
if (!compositeUnique) return [];
|
|
553
|
+
const [leftField, rightField] = referencedFields;
|
|
554
|
+
const leftTable = schema.tables.find((table) => table.name === leftField.references.table);
|
|
555
|
+
const rightTable = schema.tables.find((table) => table.name === rightField.references.table);
|
|
556
|
+
if (!leftTable || !rightTable) return [];
|
|
557
|
+
return [
|
|
558
|
+
{
|
|
559
|
+
junctionTable,
|
|
560
|
+
leftField,
|
|
561
|
+
rightField,
|
|
562
|
+
leftTable,
|
|
563
|
+
rightTable
|
|
564
|
+
}
|
|
565
|
+
];
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function renderOneRelation(relation) {
|
|
569
|
+
const options = [
|
|
570
|
+
`from: r.${relation.sourceTableKey}.${relation.sourceField.name}`,
|
|
571
|
+
`to: r.${relation.targetTableKey}.${relation.targetFieldName}`
|
|
572
|
+
];
|
|
573
|
+
if (relation.alias) {
|
|
574
|
+
options.push(`alias: "${relation.alias}"`);
|
|
575
|
+
}
|
|
576
|
+
return `r.one.${relation.targetTableKey}({ ${options.join(", ")} })`;
|
|
577
|
+
}
|
|
578
|
+
function renderReverseRelation(relation) {
|
|
579
|
+
const options = [
|
|
580
|
+
`from: r.${relation.targetTableKey}.${relation.targetFieldName}`,
|
|
581
|
+
`to: r.${relation.sourceTableKey}.${relation.sourceField.name}`
|
|
582
|
+
];
|
|
583
|
+
if (relation.alias) {
|
|
584
|
+
options.push(`alias: "${relation.alias}"`);
|
|
585
|
+
}
|
|
586
|
+
return `r.${relation.reverseKind}.${relation.sourceTableKey}({ ${options.join(", ")} })`;
|
|
587
|
+
}
|
|
588
|
+
function renderManyToManyRelation(relation, side) {
|
|
589
|
+
const isLeft = side === "left";
|
|
590
|
+
const currentTableKey = isLeft ? relation.leftTableKey : relation.rightTableKey;
|
|
591
|
+
const currentTargetFieldName = isLeft ? relation.leftTargetFieldName : relation.rightTargetFieldName;
|
|
592
|
+
const currentJunctionFieldName = isLeft ? relation.leftField.name : relation.rightField.name;
|
|
593
|
+
const otherTableKey = isLeft ? relation.rightTableKey : relation.leftTableKey;
|
|
594
|
+
const otherTargetFieldName = isLeft ? relation.rightTargetFieldName : relation.leftTargetFieldName;
|
|
595
|
+
const otherJunctionFieldName = isLeft ? relation.rightField.name : relation.leftField.name;
|
|
596
|
+
const options = [
|
|
597
|
+
`from: r.${currentTableKey}.${currentTargetFieldName}.through(r.${relation.junctionTableKey}.${currentJunctionFieldName})`,
|
|
598
|
+
`to: r.${otherTableKey}.${otherTargetFieldName}.through(r.${relation.junctionTableKey}.${otherJunctionFieldName})`
|
|
599
|
+
];
|
|
600
|
+
if (relation.alias) {
|
|
601
|
+
options.push(`alias: "${relation.alias}"`);
|
|
602
|
+
}
|
|
603
|
+
return `r.many.${otherTableKey}({ ${options.join(", ")} })`;
|
|
604
|
+
}
|
|
605
|
+
function inferForwardRelationKey(field, targetTable) {
|
|
606
|
+
const trimmed = field.name.replace(/Id$/, "").replace(/Ids$/, "").replace(/Ref$/, "");
|
|
607
|
+
const candidate = trimmed.length > 0 ? trimmed : tableSchemaKey(targetTable.name);
|
|
608
|
+
return toCamelCase(candidate);
|
|
609
|
+
}
|
|
610
|
+
function inferManyToManyKey(targetTable, targetField, selfRelation) {
|
|
611
|
+
if (!selfRelation) {
|
|
612
|
+
return tableSchemaKey(pluralize(singularize(targetTable.name)));
|
|
613
|
+
}
|
|
614
|
+
return tableSchemaKey(pluralize(inferForwardRelationKey(targetField, targetTable)));
|
|
615
|
+
}
|
|
616
|
+
function inferReverseKind(sourceTable, sourceField) {
|
|
617
|
+
if (sourceField.primaryKey || sourceField.unique) return "one";
|
|
618
|
+
const hasSingleFieldUnique = sourceTable.uniques.some(
|
|
619
|
+
(unique) => unique.fields.length === 1 && unique.fields[0] === sourceField.name
|
|
620
|
+
);
|
|
621
|
+
return hasSingleFieldUnique ? "one" : "many";
|
|
622
|
+
}
|
|
623
|
+
function ensureUsedSet(map, key) {
|
|
624
|
+
const existing = map.get(key);
|
|
625
|
+
if (existing) return existing;
|
|
626
|
+
const created = /* @__PURE__ */ new Set();
|
|
627
|
+
map.set(key, created);
|
|
628
|
+
return created;
|
|
629
|
+
}
|
|
630
|
+
function ensureUniqueKey(used, preferred, fallback) {
|
|
631
|
+
if (!used.has(preferred)) {
|
|
632
|
+
used.add(preferred);
|
|
633
|
+
return preferred;
|
|
634
|
+
}
|
|
635
|
+
if (!used.has(fallback)) {
|
|
636
|
+
used.add(fallback);
|
|
637
|
+
return fallback;
|
|
638
|
+
}
|
|
639
|
+
let counter = 2;
|
|
640
|
+
while (used.has(`${fallback}${counter}`)) {
|
|
641
|
+
counter += 1;
|
|
642
|
+
}
|
|
643
|
+
const candidate = `${fallback}${counter}`;
|
|
644
|
+
used.add(candidate);
|
|
645
|
+
return candidate;
|
|
646
|
+
}
|
|
647
|
+
function tableSchemaKey(tableName) {
|
|
648
|
+
return toCamelCase(tableName);
|
|
649
|
+
}
|
|
650
|
+
function createPairKey(left, right) {
|
|
651
|
+
return [left, right].sort((a, b) => a.localeCompare(b)).join("::");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/apply.ts
|
|
655
|
+
import { createClient } from "@libsql/client";
|
|
656
|
+
|
|
657
|
+
// src/types.ts
|
|
658
|
+
import { z } from "zod";
|
|
659
|
+
var foreignKeyActionSchema = z.enum([
|
|
660
|
+
"cascade",
|
|
661
|
+
"restrict",
|
|
662
|
+
"set null",
|
|
663
|
+
"set default",
|
|
664
|
+
"no action"
|
|
665
|
+
]);
|
|
666
|
+
var fieldReferenceSpecSchema = z.object({
|
|
667
|
+
table: z.string().min(1),
|
|
668
|
+
field: z.string().min(1),
|
|
669
|
+
onDelete: foreignKeyActionSchema.optional(),
|
|
670
|
+
onUpdate: foreignKeyActionSchema.optional()
|
|
671
|
+
});
|
|
672
|
+
var logicalTypeSpecSchema = z.discriminatedUnion("kind", [
|
|
673
|
+
z.object({
|
|
674
|
+
kind: z.literal("id"),
|
|
675
|
+
prefix: z.string().min(1)
|
|
676
|
+
}),
|
|
677
|
+
z.object({
|
|
678
|
+
kind: z.literal("string"),
|
|
679
|
+
format: z.enum(["email", "url", "slug"]).optional()
|
|
680
|
+
}),
|
|
681
|
+
z.object({
|
|
682
|
+
kind: z.literal("text")
|
|
683
|
+
}),
|
|
684
|
+
z.object({
|
|
685
|
+
kind: z.literal("boolean")
|
|
686
|
+
}),
|
|
687
|
+
z.object({
|
|
688
|
+
kind: z.literal("integer")
|
|
689
|
+
}),
|
|
690
|
+
z.object({
|
|
691
|
+
kind: z.literal("number")
|
|
692
|
+
}),
|
|
693
|
+
z.object({
|
|
694
|
+
kind: z.literal("enum"),
|
|
695
|
+
values: z.array(z.string()).min(1)
|
|
696
|
+
}),
|
|
697
|
+
z.object({
|
|
698
|
+
kind: z.literal("json"),
|
|
699
|
+
tsType: z.string().min(1)
|
|
700
|
+
}),
|
|
701
|
+
z.object({
|
|
702
|
+
kind: z.literal("temporal.instant")
|
|
703
|
+
}),
|
|
704
|
+
z.object({
|
|
705
|
+
kind: z.literal("temporal.plainDate")
|
|
706
|
+
})
|
|
707
|
+
]);
|
|
708
|
+
var storageSpecSchema = z.discriminatedUnion("strategy", [
|
|
709
|
+
z.object({
|
|
710
|
+
strategy: z.literal("sqlite.text"),
|
|
711
|
+
column: z.string().min(1)
|
|
712
|
+
}),
|
|
713
|
+
z.object({
|
|
714
|
+
strategy: z.literal("sqlite.integer"),
|
|
715
|
+
column: z.string().min(1)
|
|
716
|
+
}),
|
|
717
|
+
z.object({
|
|
718
|
+
strategy: z.literal("sqlite.real"),
|
|
719
|
+
column: z.string().min(1)
|
|
720
|
+
}),
|
|
721
|
+
z.object({
|
|
722
|
+
strategy: z.literal("sqlite.temporalInstantEpochMs"),
|
|
723
|
+
column: z.string().min(1)
|
|
724
|
+
}),
|
|
725
|
+
z.object({
|
|
726
|
+
strategy: z.literal("sqlite.temporalPlainDateText"),
|
|
727
|
+
column: z.string().min(1)
|
|
728
|
+
})
|
|
729
|
+
]);
|
|
730
|
+
var defaultSpecSchema = z.discriminatedUnion("kind", [
|
|
731
|
+
z.object({
|
|
732
|
+
kind: z.literal("literal"),
|
|
733
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.null()])
|
|
734
|
+
}),
|
|
735
|
+
z.object({
|
|
736
|
+
kind: z.literal("now")
|
|
737
|
+
}),
|
|
738
|
+
z.object({
|
|
739
|
+
kind: z.literal("generatedId"),
|
|
740
|
+
prefix: z.string().min(1)
|
|
741
|
+
})
|
|
742
|
+
]);
|
|
743
|
+
var fieldSpecSchema = z.object({
|
|
744
|
+
id: z.string().min(1),
|
|
745
|
+
name: z.string().min(1),
|
|
746
|
+
logical: logicalTypeSpecSchema,
|
|
747
|
+
storage: storageSpecSchema,
|
|
748
|
+
nullable: z.boolean().default(true),
|
|
749
|
+
default: defaultSpecSchema.optional(),
|
|
750
|
+
primaryKey: z.boolean().default(false),
|
|
751
|
+
unique: z.boolean().default(false),
|
|
752
|
+
description: z.string().min(1).optional(),
|
|
753
|
+
references: fieldReferenceSpecSchema.optional()
|
|
754
|
+
});
|
|
755
|
+
var indexSpecSchema = z.object({
|
|
756
|
+
name: z.string().min(1).optional(),
|
|
757
|
+
fields: z.array(z.string().min(1)).min(1)
|
|
758
|
+
});
|
|
759
|
+
var uniqueSpecSchema = z.object({
|
|
760
|
+
name: z.string().min(1).optional(),
|
|
761
|
+
fields: z.array(z.string().min(1)).min(1)
|
|
762
|
+
});
|
|
763
|
+
var tableSpecSchema = z.object({
|
|
764
|
+
id: z.string().min(1),
|
|
765
|
+
name: z.string().min(1),
|
|
766
|
+
description: z.string().min(1).optional(),
|
|
767
|
+
fields: z.array(fieldSpecSchema).default([]),
|
|
768
|
+
indexes: z.array(indexSpecSchema).default([]),
|
|
769
|
+
uniques: z.array(uniqueSpecSchema).default([])
|
|
770
|
+
});
|
|
771
|
+
var schemaDocumentSchema = z.object({
|
|
772
|
+
version: z.literal(1),
|
|
773
|
+
dialect: z.literal("sqlite"),
|
|
774
|
+
schemaId: z.string().min(1),
|
|
775
|
+
tables: z.array(tableSpecSchema).default([])
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// src/schema.ts
|
|
779
|
+
function createEmptySchema(schemaId = "schema") {
|
|
780
|
+
return schemaDocumentSchema.parse({
|
|
781
|
+
version: 1,
|
|
782
|
+
dialect: "sqlite",
|
|
783
|
+
schemaId,
|
|
784
|
+
tables: []
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
function parseSchemaDocument(input) {
|
|
788
|
+
return schemaDocumentSchema.parse(input);
|
|
789
|
+
}
|
|
790
|
+
function validateSchemaDocument(input) {
|
|
791
|
+
const schema = parseSchemaDocument(input);
|
|
792
|
+
return {
|
|
793
|
+
schema,
|
|
794
|
+
issues: validateSchemaDocumentCompatibility(schema)
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function assertValidSchemaDocument(input) {
|
|
798
|
+
const { schema, issues } = validateSchemaDocument(input);
|
|
799
|
+
if (issues.length > 0) {
|
|
800
|
+
throw new Error(
|
|
801
|
+
`Schema validation failed:
|
|
802
|
+
${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
return schema;
|
|
806
|
+
}
|
|
807
|
+
function schemaHash(input) {
|
|
808
|
+
return createSchemaHash(input);
|
|
809
|
+
}
|
|
810
|
+
function findTable(schema, tableName) {
|
|
811
|
+
return schema.tables.find((table) => table.name === tableName) ?? null;
|
|
812
|
+
}
|
|
813
|
+
function findField(table, fieldName) {
|
|
814
|
+
return table.fields.find((field) => field.name === fieldName) ?? null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// src/operations.ts
|
|
818
|
+
function applyOperationsToSchema(schemaInput, operations) {
|
|
819
|
+
const schema = cloneSchema(schemaInput);
|
|
820
|
+
for (const operation of operations) {
|
|
821
|
+
applyOperationToSchemaMutating(schema, operation);
|
|
822
|
+
}
|
|
823
|
+
return assertValidSchemaDocument(schema);
|
|
824
|
+
}
|
|
825
|
+
function applyOperationToSchema(schemaInput, operation) {
|
|
826
|
+
const schema = cloneSchema(schemaInput);
|
|
827
|
+
applyOperationToSchemaMutating(schema, operation);
|
|
828
|
+
return assertValidSchemaDocument(schema);
|
|
829
|
+
}
|
|
830
|
+
function applyOperationToSchemaMutating(schema, operation) {
|
|
831
|
+
switch (operation.kind) {
|
|
832
|
+
case "createTable":
|
|
833
|
+
if (findTable(schema, operation.table.name)) {
|
|
834
|
+
throw new Error(`Table ${operation.table.name} already exists`);
|
|
835
|
+
}
|
|
836
|
+
schema.tables.push(cloneSchema(operation.table));
|
|
837
|
+
return;
|
|
838
|
+
case "dropTable": {
|
|
839
|
+
const referencedBy = schema.tables.flatMap(
|
|
840
|
+
(table) => table.fields.filter((field) => field.references?.table === operation.tableName).map((field) => `${table.name}.${field.name}`)
|
|
841
|
+
);
|
|
842
|
+
if (referencedBy.length > 0) {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`Cannot drop table ${operation.tableName}; still referenced by ${referencedBy.join(", ")}`
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
const index = schema.tables.findIndex((table) => table.name === operation.tableName);
|
|
848
|
+
if (index < 0) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
849
|
+
schema.tables.splice(index, 1);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
case "renameTable": {
|
|
853
|
+
const table = findTable(schema, operation.from);
|
|
854
|
+
if (!table) throw new Error(`Table ${operation.from} does not exist`);
|
|
855
|
+
if (findTable(schema, operation.to)) {
|
|
856
|
+
throw new Error(`Table ${operation.to} already exists`);
|
|
857
|
+
}
|
|
858
|
+
table.name = operation.to;
|
|
859
|
+
for (const candidateTable of schema.tables) {
|
|
860
|
+
for (const field of candidateTable.fields) {
|
|
861
|
+
if (field.references?.table === operation.from) {
|
|
862
|
+
field.references = {
|
|
863
|
+
...field.references,
|
|
864
|
+
table: operation.to
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
case "addField": {
|
|
872
|
+
const table = findTable(schema, operation.tableName);
|
|
873
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
874
|
+
if (findField(table, operation.field.name)) {
|
|
875
|
+
throw new Error(`Field ${operation.tableName}.${operation.field.name} already exists`);
|
|
876
|
+
}
|
|
877
|
+
table.fields.push(cloneSchema(operation.field));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
case "dropField": {
|
|
881
|
+
const table = findTable(schema, operation.tableName);
|
|
882
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
883
|
+
const fieldIndex = table.fields.findIndex((field) => field.name === operation.fieldName);
|
|
884
|
+
if (fieldIndex < 0) {
|
|
885
|
+
throw new Error(`Field ${operation.tableName}.${operation.fieldName} does not exist`);
|
|
886
|
+
}
|
|
887
|
+
const referencedBy = schema.tables.flatMap(
|
|
888
|
+
(candidateTable) => candidateTable.fields.filter(
|
|
889
|
+
(field) => field.references?.table === operation.tableName && field.references.field === operation.fieldName
|
|
890
|
+
).map((field) => `${candidateTable.name}.${field.name}`)
|
|
891
|
+
);
|
|
892
|
+
if (referencedBy.length > 0) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`Cannot drop field ${operation.tableName}.${operation.fieldName}; still referenced by ${referencedBy.join(", ")}`
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
table.fields.splice(fieldIndex, 1);
|
|
898
|
+
table.indexes = table.indexes.filter((index) => !index.fields.includes(operation.fieldName));
|
|
899
|
+
table.uniques = table.uniques.filter((unique) => !unique.fields.includes(operation.fieldName));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
case "renameField": {
|
|
903
|
+
const table = findTable(schema, operation.tableName);
|
|
904
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
905
|
+
const field = findField(table, operation.from);
|
|
906
|
+
if (!field) throw new Error(`Field ${operation.tableName}.${operation.from} does not exist`);
|
|
907
|
+
if (findField(table, operation.to)) {
|
|
908
|
+
throw new Error(`Field ${operation.tableName}.${operation.to} already exists`);
|
|
909
|
+
}
|
|
910
|
+
const renamed = renameStorageColumn(field, operation.to);
|
|
911
|
+
const index = table.fields.findIndex((candidate) => candidate.id === field.id);
|
|
912
|
+
table.fields[index] = renamed;
|
|
913
|
+
table.indexes = renameFieldInIndexes(table.indexes, operation.from, operation.to);
|
|
914
|
+
table.uniques = renameFieldInUniques(table.uniques, operation.from, operation.to);
|
|
915
|
+
for (const candidateTable of schema.tables) {
|
|
916
|
+
for (const candidateField of candidateTable.fields) {
|
|
917
|
+
if (candidateField.references?.table === table.name && candidateField.references.field === operation.from) {
|
|
918
|
+
candidateField.references = {
|
|
919
|
+
...candidateField.references,
|
|
920
|
+
field: operation.to
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
case "alterField": {
|
|
928
|
+
const table = findTable(schema, operation.tableName);
|
|
929
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
930
|
+
const field = findField(table, operation.fieldName);
|
|
931
|
+
if (!field) {
|
|
932
|
+
throw new Error(`Field ${operation.tableName}.${operation.fieldName} does not exist`);
|
|
933
|
+
}
|
|
934
|
+
const nextField = applyAlterFieldPatch(operation.tableName, field, operation.patch);
|
|
935
|
+
const fieldIndex = table.fields.findIndex((candidate) => candidate.id === field.id);
|
|
936
|
+
table.fields[fieldIndex] = nextField;
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
case "addIndex": {
|
|
940
|
+
const table = findTable(schema, operation.tableName);
|
|
941
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
942
|
+
table.indexes.push(cloneSchema(operation.index));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
case "dropIndex": {
|
|
946
|
+
const table = findTable(schema, operation.tableName);
|
|
947
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
948
|
+
const nextIndexes = table.indexes.filter(
|
|
949
|
+
(index) => resolveIndexName(table.name, index) !== operation.indexName
|
|
950
|
+
);
|
|
951
|
+
if (nextIndexes.length === table.indexes.length) {
|
|
952
|
+
throw new Error(`Index ${operation.indexName} does not exist on ${table.name}`);
|
|
953
|
+
}
|
|
954
|
+
table.indexes = nextIndexes;
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
case "addUnique": {
|
|
958
|
+
const table = findTable(schema, operation.tableName);
|
|
959
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
960
|
+
table.uniques.push(cloneSchema(operation.unique));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
case "dropUnique": {
|
|
964
|
+
const table = findTable(schema, operation.tableName);
|
|
965
|
+
if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
|
|
966
|
+
const nextUniques = table.uniques.filter(
|
|
967
|
+
(unique) => resolveUniqueName(table.name, unique) !== operation.uniqueName
|
|
968
|
+
);
|
|
969
|
+
if (nextUniques.length === table.uniques.length) {
|
|
970
|
+
throw new Error(`Unique ${operation.uniqueName} does not exist on ${table.name}`);
|
|
971
|
+
}
|
|
972
|
+
table.uniques = nextUniques;
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function applyAlterFieldPatch(tableName, field, patch) {
|
|
978
|
+
if (field.primaryKey) {
|
|
979
|
+
throw new Error(`Field ${tableName}.${field.name} is a primary key and cannot be altered in v1`);
|
|
980
|
+
}
|
|
981
|
+
if (patch.logical?.kind === "id") {
|
|
982
|
+
throw new Error(`Field ${tableName}.${field.name} cannot be altered into an id field in v1`);
|
|
983
|
+
}
|
|
984
|
+
const nextLogical = patch.logical ?? field.logical;
|
|
985
|
+
const nextColumn = patch.column ?? patch.storage?.column ?? field.storage.column;
|
|
986
|
+
const nextStorage = patch.storage ?? (patch.logical ? defaultStorageForLogical(nextLogical, nextColumn) : { ...field.storage, column: nextColumn });
|
|
987
|
+
const nextField = {
|
|
988
|
+
...cloneSchema(field),
|
|
989
|
+
logical: cloneSchema(nextLogical),
|
|
990
|
+
storage: cloneSchema(nextStorage)
|
|
991
|
+
};
|
|
992
|
+
if (patch.nullable !== void 0) nextField.nullable = patch.nullable;
|
|
993
|
+
if (patch.default !== void 0) nextField.default = patch.default ?? void 0;
|
|
994
|
+
if (patch.unique !== void 0) nextField.unique = patch.unique;
|
|
995
|
+
if (patch.description !== void 0) nextField.description = patch.description ?? void 0;
|
|
996
|
+
if (patch.references !== void 0) nextField.references = patch.references ?? void 0;
|
|
997
|
+
const storageIssue = validateLogicalAndStorageCompatibility(nextField.logical, nextField.storage);
|
|
998
|
+
if (storageIssue) {
|
|
999
|
+
throw new Error(`Field ${tableName}.${field.name}: ${storageIssue}`);
|
|
1000
|
+
}
|
|
1001
|
+
if (nextField.default) {
|
|
1002
|
+
const defaultIssue = validateDefaultCompatibility(nextField, nextField.default);
|
|
1003
|
+
if (defaultIssue) {
|
|
1004
|
+
throw new Error(`Field ${tableName}.${field.name}: ${defaultIssue}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return nextField;
|
|
1008
|
+
}
|
|
1009
|
+
function fieldStorageStrategyChanged(before, after) {
|
|
1010
|
+
return before.storage.strategy !== after.storage.strategy;
|
|
1011
|
+
}
|
|
1012
|
+
function fieldNullabilityTightened(before, after) {
|
|
1013
|
+
return before.nullable && !after.nullable;
|
|
1014
|
+
}
|
|
1015
|
+
function resolveIndexName(tableName, index) {
|
|
1016
|
+
return index.name ?? `${tableName}_${index.fields.join("_")}_idx`;
|
|
1017
|
+
}
|
|
1018
|
+
function resolveUniqueName(tableName, unique) {
|
|
1019
|
+
return unique.name ?? `${tableName}_${unique.fields.join("_")}_unique`;
|
|
1020
|
+
}
|
|
1021
|
+
function renameFieldInIndexes(indexes, from, to) {
|
|
1022
|
+
return indexes.map((index) => ({
|
|
1023
|
+
...index,
|
|
1024
|
+
fields: index.fields.map((field) => field === from ? to : field)
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
function renameFieldInUniques(uniques, from, to) {
|
|
1028
|
+
return uniques.map((unique) => ({
|
|
1029
|
+
...unique,
|
|
1030
|
+
fields: unique.fields.map((field) => field === from ? to : field)
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/sqlite.ts
|
|
1035
|
+
function renderSqliteMigration(operations, options = {}) {
|
|
1036
|
+
const statements = [];
|
|
1037
|
+
const warnings = [];
|
|
1038
|
+
let workingSchema = options.currentSchema ?? createEmptySchema();
|
|
1039
|
+
let rebuildSequence = 0;
|
|
1040
|
+
for (const operation of operations) {
|
|
1041
|
+
const nextSchema = applyOperationToSchema(workingSchema, operation);
|
|
1042
|
+
switch (operation.kind) {
|
|
1043
|
+
case "createTable":
|
|
1044
|
+
statements.push(renderCreateTableStatement(operation.table));
|
|
1045
|
+
statements.push(...renderCreateIndexStatements(operation.table));
|
|
1046
|
+
break;
|
|
1047
|
+
case "dropTable":
|
|
1048
|
+
statements.push(`DROP TABLE ${quoteIdentifier(operation.tableName)};`);
|
|
1049
|
+
break;
|
|
1050
|
+
case "renameTable":
|
|
1051
|
+
statements.push(
|
|
1052
|
+
`ALTER TABLE ${quoteIdentifier(operation.from)} RENAME TO ${quoteIdentifier(operation.to)};`
|
|
1053
|
+
);
|
|
1054
|
+
break;
|
|
1055
|
+
case "renameField":
|
|
1056
|
+
statements.push(
|
|
1057
|
+
`ALTER TABLE ${quoteIdentifier(operation.tableName)} RENAME COLUMN ${quoteIdentifier(toSnakeCase(operation.from))} TO ${quoteIdentifier(toSnakeCase(operation.to))};`
|
|
1058
|
+
);
|
|
1059
|
+
break;
|
|
1060
|
+
case "addIndex":
|
|
1061
|
+
statements.push(renderCreateIndexStatement(operation.tableName, operation.index));
|
|
1062
|
+
break;
|
|
1063
|
+
case "dropIndex":
|
|
1064
|
+
statements.push(`DROP INDEX ${quoteIdentifier(operation.indexName)};`);
|
|
1065
|
+
break;
|
|
1066
|
+
case "addUnique":
|
|
1067
|
+
statements.push(renderCreateUniqueStatement(operation.tableName, operation.unique));
|
|
1068
|
+
break;
|
|
1069
|
+
case "dropUnique":
|
|
1070
|
+
statements.push(`DROP INDEX ${quoteIdentifier(operation.uniqueName)};`);
|
|
1071
|
+
break;
|
|
1072
|
+
case "addField":
|
|
1073
|
+
case "dropField":
|
|
1074
|
+
case "alterField": {
|
|
1075
|
+
const tableName = operation.tableName;
|
|
1076
|
+
const beforeTable = findTable(workingSchema, tableName);
|
|
1077
|
+
const afterTable = findTable(nextSchema, tableName);
|
|
1078
|
+
if (!beforeTable || !afterTable) {
|
|
1079
|
+
warnings.push(
|
|
1080
|
+
`operation ${operation.kind} on ${tableName} could not be emitted because the table state was incomplete`
|
|
1081
|
+
);
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
const rebuild = renderTableRebuild({
|
|
1085
|
+
beforeTable,
|
|
1086
|
+
afterTable,
|
|
1087
|
+
operation,
|
|
1088
|
+
sequence: rebuildSequence
|
|
1089
|
+
});
|
|
1090
|
+
rebuildSequence += 1;
|
|
1091
|
+
statements.push(...rebuild.statements);
|
|
1092
|
+
warnings.push(...rebuild.warnings);
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
workingSchema = nextSchema;
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
statements,
|
|
1100
|
+
warnings
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
function renderCreateTableStatement(table) {
|
|
1104
|
+
const columnLines = table.fields.map((field) => ` ${renderColumnDefinition(field)}`);
|
|
1105
|
+
return `CREATE TABLE ${quoteIdentifier(table.name)} (
|
|
1106
|
+
${columnLines.join(",\n")}
|
|
1107
|
+
);`;
|
|
1108
|
+
}
|
|
1109
|
+
function renderCreateIndexStatements(table) {
|
|
1110
|
+
const statements = table.indexes.map(
|
|
1111
|
+
(index) => renderCreateIndexStatement(table.name, index, table)
|
|
1112
|
+
);
|
|
1113
|
+
statements.push(
|
|
1114
|
+
...table.uniques.map((unique) => renderCreateUniqueStatement(table.name, unique, table))
|
|
1115
|
+
);
|
|
1116
|
+
return statements;
|
|
1117
|
+
}
|
|
1118
|
+
function renderCreateIndexStatement(tableName, index, table) {
|
|
1119
|
+
const name = index.name ?? `${tableName}_${index.fields.join("_")}_idx`;
|
|
1120
|
+
return `CREATE INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(tableName)} (${index.fields.map((field) => quoteIdentifier(resolveColumnName(table, field))).join(", ")});`;
|
|
1121
|
+
}
|
|
1122
|
+
function renderCreateUniqueStatement(tableName, unique, table) {
|
|
1123
|
+
const name = unique.name ?? `${tableName}_${unique.fields.join("_")}_unique`;
|
|
1124
|
+
return `CREATE UNIQUE INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(tableName)} (${unique.fields.map((field) => quoteIdentifier(resolveColumnName(table, field))).join(", ")});`;
|
|
1125
|
+
}
|
|
1126
|
+
function renderColumnDefinition(field) {
|
|
1127
|
+
const parts = [quoteIdentifier(field.storage.column), renderSqlType(field.storage)];
|
|
1128
|
+
if (field.primaryKey) parts.push("PRIMARY KEY");
|
|
1129
|
+
if (!field.nullable) parts.push("NOT NULL");
|
|
1130
|
+
if (field.unique && !field.primaryKey) parts.push("UNIQUE");
|
|
1131
|
+
const defaultSql = renderSqlDefault(field.default, field);
|
|
1132
|
+
if (defaultSql) parts.push(`DEFAULT ${defaultSql}`);
|
|
1133
|
+
if (field.references) {
|
|
1134
|
+
parts.push(
|
|
1135
|
+
`REFERENCES ${quoteIdentifier(field.references.table)}(${quoteIdentifier(toSnakeCase(field.references.field))})`
|
|
1136
|
+
);
|
|
1137
|
+
if (field.references.onDelete)
|
|
1138
|
+
parts.push(`ON DELETE ${field.references.onDelete.toUpperCase()}`);
|
|
1139
|
+
if (field.references.onUpdate)
|
|
1140
|
+
parts.push(`ON UPDATE ${field.references.onUpdate.toUpperCase()}`);
|
|
1141
|
+
}
|
|
1142
|
+
return parts.join(" ");
|
|
1143
|
+
}
|
|
1144
|
+
function renderSqlType(storage) {
|
|
1145
|
+
switch (storage.strategy) {
|
|
1146
|
+
case "sqlite.text":
|
|
1147
|
+
case "sqlite.temporalPlainDateText":
|
|
1148
|
+
return "TEXT";
|
|
1149
|
+
case "sqlite.integer":
|
|
1150
|
+
case "sqlite.temporalInstantEpochMs":
|
|
1151
|
+
return "INTEGER";
|
|
1152
|
+
case "sqlite.real":
|
|
1153
|
+
return "REAL";
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
function renderSqlDefault(defaultValue, field) {
|
|
1157
|
+
if (!defaultValue) return null;
|
|
1158
|
+
switch (defaultValue.kind) {
|
|
1159
|
+
case "generatedId":
|
|
1160
|
+
return null;
|
|
1161
|
+
case "now":
|
|
1162
|
+
return `(${sqliteEpochMsNowSql()})`;
|
|
1163
|
+
case "literal":
|
|
1164
|
+
if (defaultValue.value === null) return "NULL";
|
|
1165
|
+
if (typeof defaultValue.value === "string") {
|
|
1166
|
+
return `'${defaultValue.value.replace(/'/g, "''")}'`;
|
|
1167
|
+
}
|
|
1168
|
+
if (typeof defaultValue.value === "boolean") {
|
|
1169
|
+
return field.storage.strategy === "sqlite.integer" ? defaultValue.value ? "1" : "0" : defaultValue.value ? "TRUE" : "FALSE";
|
|
1170
|
+
}
|
|
1171
|
+
return `${defaultValue.value}`;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function resolveColumnName(table, fieldName) {
|
|
1175
|
+
const field = table?.fields.find((candidate) => candidate.name === fieldName);
|
|
1176
|
+
return field?.storage.column ?? toSnakeCase(fieldName);
|
|
1177
|
+
}
|
|
1178
|
+
function renderTableRebuild(args) {
|
|
1179
|
+
const warnings = [];
|
|
1180
|
+
const tempName = `__sedrino_rebuild_${args.afterTable.name}_${args.sequence}`;
|
|
1181
|
+
const insertColumns = args.afterTable.fields.map((field) => quoteIdentifier(field.storage.column));
|
|
1182
|
+
const selectExpressions = args.afterTable.fields.map(
|
|
1183
|
+
(field) => renderRebuildSelectExpression({
|
|
1184
|
+
beforeTable: args.beforeTable,
|
|
1185
|
+
afterTable: args.afterTable,
|
|
1186
|
+
operation: args.operation,
|
|
1187
|
+
targetField: field,
|
|
1188
|
+
warnings
|
|
1189
|
+
})
|
|
1190
|
+
);
|
|
1191
|
+
const statements = [
|
|
1192
|
+
"PRAGMA foreign_keys = OFF;",
|
|
1193
|
+
`ALTER TABLE ${quoteIdentifier(args.beforeTable.name)} RENAME TO ${quoteIdentifier(tempName)};`,
|
|
1194
|
+
renderCreateTableStatement(args.afterTable),
|
|
1195
|
+
...renderCreateIndexStatements(args.afterTable),
|
|
1196
|
+
`INSERT INTO ${quoteIdentifier(args.afterTable.name)} (${insertColumns.join(", ")}) SELECT ${selectExpressions.join(", ")} FROM ${quoteIdentifier(tempName)};`,
|
|
1197
|
+
`DROP TABLE ${quoteIdentifier(tempName)};`,
|
|
1198
|
+
"PRAGMA foreign_keys = ON;"
|
|
1199
|
+
];
|
|
1200
|
+
return {
|
|
1201
|
+
statements,
|
|
1202
|
+
warnings
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
function renderRebuildSelectExpression(args) {
|
|
1206
|
+
const sourceField = args.beforeTable.fields.find((candidate) => candidate.id === args.targetField.id) ?? null;
|
|
1207
|
+
if (!sourceField) {
|
|
1208
|
+
if (args.operation.kind === "addField" && args.operation.field.id === args.targetField.id && args.operation.backfill) {
|
|
1209
|
+
return `(${args.operation.backfill.sql}) AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
1210
|
+
}
|
|
1211
|
+
const defaultSql = renderSqlDefault(args.targetField.default, args.targetField);
|
|
1212
|
+
if (defaultSql) return `${defaultSql} AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
1213
|
+
if (args.targetField.nullable) {
|
|
1214
|
+
return `NULL AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
1215
|
+
}
|
|
1216
|
+
args.warnings.push(
|
|
1217
|
+
`addField ${args.afterTable.name}.${args.targetField.name} is required with no default and cannot be backfilled safely`
|
|
1218
|
+
);
|
|
1219
|
+
return `NULL AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
1220
|
+
}
|
|
1221
|
+
if (fieldStorageStrategyChanged(sourceField, args.targetField)) {
|
|
1222
|
+
if (args.operation.kind === "alterField" && args.operation.transform && args.operation.fieldName === args.targetField.name) {
|
|
1223
|
+
return `(${args.operation.transform.sql}) AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
1224
|
+
}
|
|
1225
|
+
args.warnings.push(
|
|
1226
|
+
`alterField ${args.afterTable.name}.${args.targetField.name} changes storage strategy from ${sourceField.storage.strategy} to ${args.targetField.storage.strategy}; explicit data transforms are not supported in v1`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
let expression = quoteIdentifier(sourceField.storage.column);
|
|
1230
|
+
if (args.operation.kind === "alterField" && args.operation.transform && args.operation.fieldName === args.targetField.name) {
|
|
1231
|
+
expression = `(${args.operation.transform.sql})`;
|
|
1232
|
+
}
|
|
1233
|
+
if (fieldNullabilityTightened(sourceField, args.targetField) && !(args.operation.kind === "alterField" && args.operation.transform && args.operation.fieldName === args.targetField.name)) {
|
|
1234
|
+
const defaultSql = renderSqlDefault(args.targetField.default, args.targetField);
|
|
1235
|
+
if (defaultSql) {
|
|
1236
|
+
expression = `COALESCE(${expression}, ${defaultSql})`;
|
|
1237
|
+
} else {
|
|
1238
|
+
args.warnings.push(
|
|
1239
|
+
`alterField ${args.afterTable.name}.${args.targetField.name} makes a nullable field required without a default; existing NULL rows would fail during rebuild`
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return `${expression} AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/planner.ts
|
|
1247
|
+
function planMigration(args) {
|
|
1248
|
+
const currentSchema = args.currentSchema ? assertValidSchemaDocument(args.currentSchema) : createEmptySchema(args.migration.meta.id);
|
|
1249
|
+
const operations = args.migration.buildOperations();
|
|
1250
|
+
const nextSchema = applyOperationsToSchema(currentSchema, operations);
|
|
1251
|
+
return {
|
|
1252
|
+
migrationId: args.migration.meta.id,
|
|
1253
|
+
migrationName: args.migration.meta.name,
|
|
1254
|
+
fromSchemaHash: createSchemaHash(currentSchema),
|
|
1255
|
+
toSchemaHash: createSchemaHash(nextSchema),
|
|
1256
|
+
operations,
|
|
1257
|
+
nextSchema,
|
|
1258
|
+
sql: renderSqliteMigration(operations, { currentSchema })
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
function materializeSchema(args) {
|
|
1262
|
+
let schema = args.baseSchema ? assertValidSchemaDocument(args.baseSchema) : createEmptySchema();
|
|
1263
|
+
const plans = [];
|
|
1264
|
+
for (const migration of args.migrations) {
|
|
1265
|
+
const plan = planMigration({
|
|
1266
|
+
currentSchema: schema,
|
|
1267
|
+
migration
|
|
1268
|
+
});
|
|
1269
|
+
plans.push(plan);
|
|
1270
|
+
schema = plan.nextSchema;
|
|
1271
|
+
}
|
|
1272
|
+
return {
|
|
1273
|
+
schema,
|
|
1274
|
+
plans
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/apply.ts
|
|
1279
|
+
var MIGRATIONS_TABLE = "_sedrino_schema_migrations";
|
|
1280
|
+
var STATE_TABLE = "_sedrino_schema_state";
|
|
1281
|
+
function createLibsqlClient(options) {
|
|
1282
|
+
return createClient({
|
|
1283
|
+
url: options.url,
|
|
1284
|
+
authToken: options.authToken,
|
|
1285
|
+
concurrency: 0
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
async function applyMigrations(args) {
|
|
1289
|
+
const client = args.client ?? createLibsqlClient(assertConnection(args.connection));
|
|
1290
|
+
await ensureMetadataTables(client);
|
|
1291
|
+
const appliedRows = await listAppliedMigrations(client);
|
|
1292
|
+
const appliedIds = new Set(appliedRows.map((row) => row.migrationId));
|
|
1293
|
+
const migrationMap = new Map(args.migrations.map((migration) => [migration.meta.id, migration]));
|
|
1294
|
+
for (const applied of appliedRows) {
|
|
1295
|
+
if (!migrationMap.has(applied.migrationId)) {
|
|
1296
|
+
throw new Error(
|
|
1297
|
+
`Database contains applied migration ${applied.migrationId}, but it is not present locally`
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const appliedLocalMigrations = args.migrations.filter(
|
|
1302
|
+
(migration) => appliedIds.has(migration.meta.id)
|
|
1303
|
+
);
|
|
1304
|
+
const expectedCurrent = materializeSchema({
|
|
1305
|
+
baseSchema: args.baseSchema,
|
|
1306
|
+
migrations: appliedLocalMigrations
|
|
1307
|
+
}).schema;
|
|
1308
|
+
const expectedHash = schemaHash(expectedCurrent);
|
|
1309
|
+
const currentState = await getSchemaState(client);
|
|
1310
|
+
if (currentState) {
|
|
1311
|
+
if (currentState.schemaHash !== expectedHash) {
|
|
1312
|
+
throw new Error(
|
|
1313
|
+
`Schema drift detected. Database hash ${currentState.schemaHash} does not match expected local hash ${expectedHash}`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
} else if (appliedRows.length > 0) {
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`Database has applied migrations recorded in ${MIGRATIONS_TABLE} but is missing ${STATE_TABLE}`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
const pendingMigrations = args.migrations.filter(
|
|
1322
|
+
(migration) => !appliedIds.has(migration.meta.id)
|
|
1323
|
+
);
|
|
1324
|
+
const appliedPlans = [];
|
|
1325
|
+
let currentSchema = expectedCurrent;
|
|
1326
|
+
for (const migration of pendingMigrations) {
|
|
1327
|
+
const plan = planMigration({
|
|
1328
|
+
currentSchema,
|
|
1329
|
+
migration
|
|
1330
|
+
});
|
|
1331
|
+
if (plan.sql.warnings.length > 0) {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`Migration ${plan.migrationId} cannot be applied safely:
|
|
1334
|
+
${plan.sql.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
await executePlan(client, plan);
|
|
1338
|
+
currentSchema = plan.nextSchema;
|
|
1339
|
+
appliedPlans.push(plan);
|
|
1340
|
+
}
|
|
1341
|
+
return {
|
|
1342
|
+
appliedPlans,
|
|
1343
|
+
skippedMigrationIds: appliedLocalMigrations.map((migration) => migration.meta.id),
|
|
1344
|
+
currentSchema,
|
|
1345
|
+
currentSchemaHash: schemaHash(currentSchema)
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
async function listAppliedMigrations(client) {
|
|
1349
|
+
const result = await client.execute(
|
|
1350
|
+
`SELECT migration_id, migration_name, schema_hash, applied_at
|
|
1351
|
+
FROM ${MIGRATIONS_TABLE}
|
|
1352
|
+
ORDER BY applied_at ASC, migration_id ASC`
|
|
1353
|
+
);
|
|
1354
|
+
return result.rows.map((row) => ({
|
|
1355
|
+
migrationId: getString(row.migration_id) ?? "",
|
|
1356
|
+
migrationName: getString(row.migration_name) ?? "",
|
|
1357
|
+
schemaHash: getString(row.schema_hash) ?? "",
|
|
1358
|
+
appliedAt: getNumber(row.applied_at) ?? 0
|
|
1359
|
+
}));
|
|
1360
|
+
}
|
|
1361
|
+
async function inspectMigrationStatus(args) {
|
|
1362
|
+
const client = args.client ?? createLibsqlClient(assertConnection(args.connection));
|
|
1363
|
+
const metadataTablesPresent = await hasMetadataTables(client);
|
|
1364
|
+
const appliedRows = metadataTablesPresent ? await listAppliedMigrations(client) : [];
|
|
1365
|
+
const appliedIds = new Set(appliedRows.map((row) => row.migrationId));
|
|
1366
|
+
const localMigrationIds = args.migrations.map((migration) => migration.meta.id);
|
|
1367
|
+
const pendingMigrationIds = localMigrationIds.filter((id) => !appliedIds.has(id));
|
|
1368
|
+
const unexpectedDatabaseMigrationIds = appliedRows.map((row) => row.migrationId).filter((id) => !localMigrationIds.includes(id));
|
|
1369
|
+
const appliedLocalMigrations = args.migrations.filter((migration) => appliedIds.has(migration.meta.id));
|
|
1370
|
+
const expectedCurrent = materializeSchema({
|
|
1371
|
+
baseSchema: args.baseSchema,
|
|
1372
|
+
migrations: appliedLocalMigrations
|
|
1373
|
+
}).schema;
|
|
1374
|
+
const currentState = metadataTablesPresent ? await getSchemaState(client) : null;
|
|
1375
|
+
const localSchemaHash = schemaHash(expectedCurrent);
|
|
1376
|
+
const databaseSchemaHash = currentState?.schemaHash ?? null;
|
|
1377
|
+
return {
|
|
1378
|
+
localMigrationIds,
|
|
1379
|
+
appliedMigrationIds: appliedRows.map((row) => row.migrationId),
|
|
1380
|
+
pendingMigrationIds,
|
|
1381
|
+
unexpectedDatabaseMigrationIds,
|
|
1382
|
+
schemaHash: {
|
|
1383
|
+
local: localSchemaHash,
|
|
1384
|
+
database: databaseSchemaHash,
|
|
1385
|
+
driftDetected: databaseSchemaHash !== null && databaseSchemaHash !== localSchemaHash
|
|
1386
|
+
},
|
|
1387
|
+
metadataTablesPresent
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
async function getSchemaState(client) {
|
|
1391
|
+
const result = await client.execute(
|
|
1392
|
+
`SELECT schema_hash, schema_json
|
|
1393
|
+
FROM ${STATE_TABLE}
|
|
1394
|
+
WHERE singleton_id = 1`
|
|
1395
|
+
);
|
|
1396
|
+
const row = result.rows[0];
|
|
1397
|
+
if (!row) return null;
|
|
1398
|
+
const schemaHashValue = getString(row.schema_hash);
|
|
1399
|
+
const schemaJsonValue = getString(row.schema_json);
|
|
1400
|
+
if (!schemaHashValue || !schemaJsonValue) return null;
|
|
1401
|
+
return {
|
|
1402
|
+
schemaHash: schemaHashValue,
|
|
1403
|
+
schemaJson: schemaJsonValue
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
async function ensureMetadataTables(client) {
|
|
1407
|
+
await client.batch(
|
|
1408
|
+
[
|
|
1409
|
+
"PRAGMA foreign_keys = ON",
|
|
1410
|
+
`
|
|
1411
|
+
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
1412
|
+
migration_id TEXT PRIMARY KEY,
|
|
1413
|
+
migration_name TEXT NOT NULL,
|
|
1414
|
+
schema_hash TEXT NOT NULL,
|
|
1415
|
+
applied_at INTEGER NOT NULL,
|
|
1416
|
+
sql_statements_json TEXT NOT NULL
|
|
1417
|
+
)
|
|
1418
|
+
`,
|
|
1419
|
+
`
|
|
1420
|
+
CREATE TABLE IF NOT EXISTS ${STATE_TABLE} (
|
|
1421
|
+
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
|
|
1422
|
+
schema_hash TEXT NOT NULL,
|
|
1423
|
+
schema_json TEXT NOT NULL,
|
|
1424
|
+
updated_at INTEGER NOT NULL
|
|
1425
|
+
)
|
|
1426
|
+
`
|
|
1427
|
+
],
|
|
1428
|
+
"write"
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
async function hasMetadataTables(client) {
|
|
1432
|
+
const result = await client.execute({
|
|
1433
|
+
sql: `SELECT name FROM sqlite_master
|
|
1434
|
+
WHERE type = 'table' AND name IN (?, ?)`,
|
|
1435
|
+
args: [MIGRATIONS_TABLE, STATE_TABLE]
|
|
1436
|
+
});
|
|
1437
|
+
const names = new Set(
|
|
1438
|
+
result.rows.map((row) => getString(row.name)).filter((value) => value !== null)
|
|
1439
|
+
);
|
|
1440
|
+
return names.has(MIGRATIONS_TABLE) && names.has(STATE_TABLE);
|
|
1441
|
+
}
|
|
1442
|
+
async function executePlan(client, plan) {
|
|
1443
|
+
const appliedAt = Date.now();
|
|
1444
|
+
const statements = [
|
|
1445
|
+
...plan.sql.statements,
|
|
1446
|
+
{
|
|
1447
|
+
sql: `INSERT INTO ${MIGRATIONS_TABLE} (
|
|
1448
|
+
migration_id,
|
|
1449
|
+
migration_name,
|
|
1450
|
+
schema_hash,
|
|
1451
|
+
applied_at,
|
|
1452
|
+
sql_statements_json
|
|
1453
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
1454
|
+
args: [
|
|
1455
|
+
plan.migrationId,
|
|
1456
|
+
plan.migrationName,
|
|
1457
|
+
plan.toSchemaHash,
|
|
1458
|
+
appliedAt,
|
|
1459
|
+
JSON.stringify(plan.sql.statements)
|
|
1460
|
+
]
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
sql: `INSERT INTO ${STATE_TABLE} (
|
|
1464
|
+
singleton_id,
|
|
1465
|
+
schema_hash,
|
|
1466
|
+
schema_json,
|
|
1467
|
+
updated_at
|
|
1468
|
+
) VALUES (1, ?, ?, ?)
|
|
1469
|
+
ON CONFLICT(singleton_id) DO UPDATE SET
|
|
1470
|
+
schema_hash = excluded.schema_hash,
|
|
1471
|
+
schema_json = excluded.schema_json,
|
|
1472
|
+
updated_at = excluded.updated_at`,
|
|
1473
|
+
args: [plan.toSchemaHash, JSON.stringify(plan.nextSchema), appliedAt]
|
|
1474
|
+
}
|
|
1475
|
+
];
|
|
1476
|
+
await client.batch(statements, "write");
|
|
1477
|
+
}
|
|
1478
|
+
function assertConnection(connection) {
|
|
1479
|
+
if (!connection?.url) {
|
|
1480
|
+
throw new Error("Missing database connection. Provide --url or pass a connection object.");
|
|
1481
|
+
}
|
|
1482
|
+
return connection;
|
|
1483
|
+
}
|
|
1484
|
+
function getString(value) {
|
|
1485
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
1486
|
+
}
|
|
1487
|
+
function getNumber(value) {
|
|
1488
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
1489
|
+
if (typeof value === "bigint") return Number(value);
|
|
1490
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1491
|
+
const parsed = Number(value);
|
|
1492
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1493
|
+
}
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// src/project.ts
|
|
1498
|
+
import { mkdir, readFile, readdir, writeFile } from "fs/promises";
|
|
1499
|
+
import path from "path";
|
|
1500
|
+
import { pathToFileURL } from "url";
|
|
1501
|
+
var MIGRATION_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".mts", ".js", ".mjs"]);
|
|
1502
|
+
function resolveDbProjectLayout(dbDir = "db") {
|
|
1503
|
+
const absoluteDbDir = path.resolve(dbDir);
|
|
1504
|
+
return {
|
|
1505
|
+
dbDir: absoluteDbDir,
|
|
1506
|
+
migrationsDir: path.join(absoluteDbDir, "migrations"),
|
|
1507
|
+
schemaDir: path.join(absoluteDbDir, "schema"),
|
|
1508
|
+
snapshotPath: path.join(absoluteDbDir, "schema", "schema.snapshot.json"),
|
|
1509
|
+
drizzlePath: path.join(absoluteDbDir, "schema", "schema.generated.ts")
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
async function loadMigrationDefinitionsFromDirectory(migrationsDir) {
|
|
1513
|
+
const files = await readdir(migrationsDir, { withFileTypes: true });
|
|
1514
|
+
const migrationFiles = files.filter((entry) => entry.isFile()).map((entry) => path.join(migrationsDir, entry.name)).filter((filePath) => MIGRATION_EXTENSIONS.has(path.extname(filePath))).sort((left, right) => path.basename(left).localeCompare(path.basename(right)));
|
|
1515
|
+
const migrations = [];
|
|
1516
|
+
for (const filePath of migrationFiles) {
|
|
1517
|
+
const imported = await import(pathToFileURL(filePath).href);
|
|
1518
|
+
const definition = imported.default;
|
|
1519
|
+
if (!definition || !definition.meta || typeof definition.meta.id !== "string" || typeof definition.meta.name !== "string" || typeof definition.buildOperations !== "function") {
|
|
1520
|
+
throw new Error(`Migration file ${filePath} does not export a valid default migration`);
|
|
1521
|
+
}
|
|
1522
|
+
migrations.push(definition);
|
|
1523
|
+
}
|
|
1524
|
+
return migrations;
|
|
1525
|
+
}
|
|
1526
|
+
async function materializeProjectMigrations(layout) {
|
|
1527
|
+
const migrations = await loadMigrationDefinitionsFromDirectory(layout.migrationsDir);
|
|
1528
|
+
assertUniqueMigrationIds(migrations);
|
|
1529
|
+
const materialized = materializeSchema({ migrations });
|
|
1530
|
+
return {
|
|
1531
|
+
...materialized,
|
|
1532
|
+
migrations
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
async function createMigrationScaffold(layout, rawName, now = /* @__PURE__ */ new Date()) {
|
|
1536
|
+
const slug = toKebabCase(rawName);
|
|
1537
|
+
if (!slug) {
|
|
1538
|
+
throw new Error("Migration name must contain at least one letter or number");
|
|
1539
|
+
}
|
|
1540
|
+
await mkdir(layout.migrationsDir, { recursive: true });
|
|
1541
|
+
const datePrefix = formatMigrationDate(now);
|
|
1542
|
+
const sequence = await nextMigrationSequence(layout.migrationsDir, datePrefix);
|
|
1543
|
+
const sequenceText = String(sequence).padStart(3, "0");
|
|
1544
|
+
const migrationId = `${datePrefix}-${sequenceText}-${slug}`;
|
|
1545
|
+
const fileName = `${migrationId}.ts`;
|
|
1546
|
+
const filePath = path.join(layout.migrationsDir, fileName);
|
|
1547
|
+
const migrationName = humanizeMigrationName(slug);
|
|
1548
|
+
const source = renderMigrationTemplate({
|
|
1549
|
+
migrationId,
|
|
1550
|
+
migrationName
|
|
1551
|
+
});
|
|
1552
|
+
await writeFile(filePath, source, { encoding: "utf8", flag: "wx" });
|
|
1553
|
+
return {
|
|
1554
|
+
filePath,
|
|
1555
|
+
fileName,
|
|
1556
|
+
migrationId,
|
|
1557
|
+
migrationName
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
async function validateDbProject(layout) {
|
|
1561
|
+
const materialized = await materializeProjectMigrations(layout);
|
|
1562
|
+
const expectedSnapshot = `${JSON.stringify(materialized.schema, null, 2)}
|
|
1563
|
+
`;
|
|
1564
|
+
const expectedDrizzle = compileSchemaToDrizzle(materialized.schema);
|
|
1565
|
+
const warnings = materialized.plans.flatMap(
|
|
1566
|
+
(plan) => plan.sql.warnings.map((warning) => `${plan.migrationId}: ${warning}`)
|
|
1567
|
+
);
|
|
1568
|
+
const [snapshotContents, drizzleContents] = await Promise.all([
|
|
1569
|
+
readTextIfExists(layout.snapshotPath),
|
|
1570
|
+
readTextIfExists(layout.drizzlePath)
|
|
1571
|
+
]);
|
|
1572
|
+
return {
|
|
1573
|
+
...materialized,
|
|
1574
|
+
warnings,
|
|
1575
|
+
expectedSnapshot,
|
|
1576
|
+
expectedDrizzle,
|
|
1577
|
+
artifacts: {
|
|
1578
|
+
snapshotExists: snapshotContents !== null,
|
|
1579
|
+
drizzleExists: drizzleContents !== null,
|
|
1580
|
+
snapshotUpToDate: snapshotContents === expectedSnapshot,
|
|
1581
|
+
drizzleUpToDate: drizzleContents === expectedDrizzle
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
async function writeSchemaSnapshot(schema, snapshotPath) {
|
|
1586
|
+
await mkdir(path.dirname(snapshotPath), { recursive: true });
|
|
1587
|
+
await writeFile(snapshotPath, `${JSON.stringify(schema, null, 2)}
|
|
1588
|
+
`, "utf8");
|
|
1589
|
+
}
|
|
1590
|
+
async function writeDrizzleSchema(schema, drizzlePath) {
|
|
1591
|
+
await mkdir(path.dirname(drizzlePath), { recursive: true });
|
|
1592
|
+
await writeFile(drizzlePath, compileSchemaToDrizzle(schema), "utf8");
|
|
1593
|
+
}
|
|
1594
|
+
function assertUniqueMigrationIds(migrations) {
|
|
1595
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1596
|
+
for (const migration of migrations) {
|
|
1597
|
+
if (seen.has(migration.meta.id)) {
|
|
1598
|
+
throw new Error(`Duplicate migration id ${migration.meta.id}`);
|
|
1599
|
+
}
|
|
1600
|
+
seen.add(migration.meta.id);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
async function readTextIfExists(filePath) {
|
|
1604
|
+
try {
|
|
1605
|
+
return await readFile(filePath, "utf8");
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
throw error;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
async function nextMigrationSequence(migrationsDir, datePrefix) {
|
|
1614
|
+
const files = await readdir(migrationsDir, { withFileTypes: true });
|
|
1615
|
+
let maxSequence = 0;
|
|
1616
|
+
for (const entry of files) {
|
|
1617
|
+
if (!entry.isFile()) continue;
|
|
1618
|
+
const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(\d{3})(?:-[^.]+)?\.(?:ts|mts|js|mjs)$/);
|
|
1619
|
+
if (!match) continue;
|
|
1620
|
+
if (match[1] !== datePrefix) continue;
|
|
1621
|
+
const parsed = Number(match[2]);
|
|
1622
|
+
if (Number.isInteger(parsed) && parsed > maxSequence) {
|
|
1623
|
+
maxSequence = parsed;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return maxSequence + 1;
|
|
1627
|
+
}
|
|
1628
|
+
function formatMigrationDate(date) {
|
|
1629
|
+
const year = date.getFullYear();
|
|
1630
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
1631
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
1632
|
+
return `${year}-${month}-${day}`;
|
|
1633
|
+
}
|
|
1634
|
+
function toKebabCase(value) {
|
|
1635
|
+
return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1-$2").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
1636
|
+
}
|
|
1637
|
+
function humanizeMigrationName(slug) {
|
|
1638
|
+
const parts = slug.split("-").filter(Boolean);
|
|
1639
|
+
const first = parts[0];
|
|
1640
|
+
if (!first) return "New migration";
|
|
1641
|
+
const rest = parts.slice(1);
|
|
1642
|
+
return [
|
|
1643
|
+
`${first[0].toUpperCase()}${first.slice(1)}`,
|
|
1644
|
+
...rest
|
|
1645
|
+
].join(" ");
|
|
1646
|
+
}
|
|
1647
|
+
function renderMigrationTemplate(args) {
|
|
1648
|
+
return `import { createMigration } from "@sedrino/db-schema";
|
|
1649
|
+
|
|
1650
|
+
export default createMigration(
|
|
1651
|
+
{
|
|
1652
|
+
id: ${JSON.stringify(args.migrationId)},
|
|
1653
|
+
name: ${JSON.stringify(args.migrationName)},
|
|
1654
|
+
},
|
|
1655
|
+
(m) => {
|
|
1656
|
+
// TODO: define migration operations.
|
|
1657
|
+
},
|
|
1658
|
+
);
|
|
1659
|
+
`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// src/cli.ts
|
|
1663
|
+
async function main() {
|
|
1664
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1665
|
+
const [group, command] = args.positionals;
|
|
1666
|
+
if (!group || group === "help" || hasFlag(args, "help")) {
|
|
1667
|
+
printHelp();
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
switch (`${group} ${command ?? ""}`.trim()) {
|
|
1671
|
+
case "migrate plan":
|
|
1672
|
+
await handleMigratePlan(args);
|
|
1673
|
+
return;
|
|
1674
|
+
case "migrate create":
|
|
1675
|
+
await handleMigrateCreate(args);
|
|
1676
|
+
return;
|
|
1677
|
+
case "migrate apply":
|
|
1678
|
+
await handleMigrateApply(args);
|
|
1679
|
+
return;
|
|
1680
|
+
case "migrate validate":
|
|
1681
|
+
await handleMigrateValidate(args);
|
|
1682
|
+
return;
|
|
1683
|
+
case "migrate status":
|
|
1684
|
+
await handleMigrateStatus(args);
|
|
1685
|
+
return;
|
|
1686
|
+
case "schema print":
|
|
1687
|
+
await handleSchemaPrint(args);
|
|
1688
|
+
return;
|
|
1689
|
+
case "schema drizzle":
|
|
1690
|
+
await handleSchemaDrizzle(args);
|
|
1691
|
+
return;
|
|
1692
|
+
default:
|
|
1693
|
+
throw new Error(`Unknown command: ${[group, command].filter(Boolean).join(" ")}`);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
async function handleMigratePlan(args) {
|
|
1697
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1698
|
+
const { schema, plans } = await materializeProjectMigrations(layout);
|
|
1699
|
+
const snapshotPath = path2.resolve(getStringOption(args, "snapshot") ?? layout.snapshotPath);
|
|
1700
|
+
const drizzleOutputPath = path2.resolve(getStringOption(args, "drizzle-out") ?? layout.drizzlePath);
|
|
1701
|
+
await writeSchemaSnapshot(schema, snapshotPath);
|
|
1702
|
+
await writeDrizzleSchema(schema, drizzleOutputPath);
|
|
1703
|
+
const warnings = plans.flatMap(
|
|
1704
|
+
(plan) => plan.sql.warnings.map((warning) => `${plan.migrationId}: ${warning}`)
|
|
1705
|
+
);
|
|
1706
|
+
const statementCount = plans.reduce((total, plan) => total + plan.sql.statements.length, 0);
|
|
1707
|
+
console.log(`Planned ${plans.length} migration(s)`);
|
|
1708
|
+
console.log(`Schema snapshot: ${snapshotPath}`);
|
|
1709
|
+
console.log(`Drizzle output: ${drizzleOutputPath}`);
|
|
1710
|
+
console.log(`SQL statements: ${statementCount}`);
|
|
1711
|
+
console.log(`Schema hash: ${plans.at(-1)?.toSchemaHash ?? "schema_00000000"}`);
|
|
1712
|
+
if (warnings.length > 0) {
|
|
1713
|
+
console.log("");
|
|
1714
|
+
console.log("Warnings:");
|
|
1715
|
+
for (const warning of warnings) console.log(`- ${warning}`);
|
|
1716
|
+
}
|
|
1717
|
+
if (hasFlag(args, "sql")) {
|
|
1718
|
+
console.log("");
|
|
1719
|
+
for (const plan of plans) {
|
|
1720
|
+
console.log(`# ${plan.migrationId} ${plan.migrationName}`);
|
|
1721
|
+
for (const statement of plan.sql.statements) console.log(statement);
|
|
1722
|
+
if (plan.sql.warnings.length > 0) {
|
|
1723
|
+
for (const warning of plan.sql.warnings) console.log(`-- warning: ${warning}`);
|
|
1724
|
+
}
|
|
1725
|
+
console.log("");
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
async function handleMigrateCreate(args) {
|
|
1730
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1731
|
+
const rawName = args.positionals.slice(2).join(" ").trim();
|
|
1732
|
+
if (!rawName) {
|
|
1733
|
+
throw new Error("Missing migration name. Usage: sedrino-db migrate create <name> [--dir db]");
|
|
1734
|
+
}
|
|
1735
|
+
const created = await createMigrationScaffold(layout, rawName);
|
|
1736
|
+
console.log(`Created migration: ${created.filePath}`);
|
|
1737
|
+
console.log(`Migration id: ${created.migrationId}`);
|
|
1738
|
+
console.log(`Migration name: ${created.migrationName}`);
|
|
1739
|
+
}
|
|
1740
|
+
async function handleMigrateApply(args) {
|
|
1741
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1742
|
+
const { migrations } = await materializeProjectMigrations(layout);
|
|
1743
|
+
const url = getStringOption(args, "url") ?? env.LIBSQL_URL;
|
|
1744
|
+
const authToken = getStringOption(args, "auth-token") ?? env.LIBSQL_AUTH_TOKEN;
|
|
1745
|
+
if (!url) {
|
|
1746
|
+
throw new Error("Missing database URL. Use --url or set LIBSQL_URL.");
|
|
1747
|
+
}
|
|
1748
|
+
const result = await applyMigrations({
|
|
1749
|
+
client: createLibsqlClient({ url, authToken }),
|
|
1750
|
+
migrations
|
|
1751
|
+
});
|
|
1752
|
+
await writeSchemaSnapshot(
|
|
1753
|
+
result.currentSchema,
|
|
1754
|
+
getStringOption(args, "snapshot") ?? layout.snapshotPath
|
|
1755
|
+
);
|
|
1756
|
+
await writeDrizzleSchema(
|
|
1757
|
+
result.currentSchema,
|
|
1758
|
+
getStringOption(args, "drizzle-out") ?? layout.drizzlePath
|
|
1759
|
+
);
|
|
1760
|
+
console.log(`Applied ${result.appliedPlans.length} migration(s)`);
|
|
1761
|
+
console.log(`Skipped ${result.skippedMigrationIds.length} already-applied migration(s)`);
|
|
1762
|
+
console.log(`Current schema hash: ${result.currentSchemaHash}`);
|
|
1763
|
+
if (result.appliedPlans.length > 0) {
|
|
1764
|
+
console.log("");
|
|
1765
|
+
console.log("Applied:");
|
|
1766
|
+
for (const plan of result.appliedPlans) {
|
|
1767
|
+
console.log(`- ${plan.migrationId} (${plan.sql.statements.length} statement(s))`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
async function handleMigrateValidate(args) {
|
|
1772
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1773
|
+
const result = await validateDbProject(layout);
|
|
1774
|
+
console.log(`Validated ${result.migrations.length} migration(s)`);
|
|
1775
|
+
console.log(`Schema hash: ${result.plans.at(-1)?.toSchemaHash ?? "schema_00000000"}`);
|
|
1776
|
+
console.log(`Snapshot up to date: ${result.artifacts.snapshotUpToDate ? "yes" : "no"}`);
|
|
1777
|
+
console.log(`Drizzle output up to date: ${result.artifacts.drizzleUpToDate ? "yes" : "no"}`);
|
|
1778
|
+
if (result.warnings.length > 0) {
|
|
1779
|
+
console.log("");
|
|
1780
|
+
console.log("Warnings:");
|
|
1781
|
+
for (const warning of result.warnings) console.log(`- ${warning}`);
|
|
1782
|
+
}
|
|
1783
|
+
const hasIssues = result.warnings.length > 0 || !result.artifacts.snapshotUpToDate || !result.artifacts.drizzleUpToDate;
|
|
1784
|
+
if (hasIssues) {
|
|
1785
|
+
exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
async function handleMigrateStatus(args) {
|
|
1789
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1790
|
+
const { migrations, plans } = await materializeProjectMigrations(layout);
|
|
1791
|
+
console.log(`Local migrations: ${migrations.length}`);
|
|
1792
|
+
console.log(`Local schema hash: ${plans.at(-1)?.toSchemaHash ?? "schema_00000000"}`);
|
|
1793
|
+
const url = getStringOption(args, "url") ?? env.LIBSQL_URL;
|
|
1794
|
+
const authToken = getStringOption(args, "auth-token") ?? env.LIBSQL_AUTH_TOKEN;
|
|
1795
|
+
if (!url) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const status = await inspectMigrationStatus({
|
|
1799
|
+
client: createLibsqlClient({ url, authToken }),
|
|
1800
|
+
migrations,
|
|
1801
|
+
baseSchema: void 0
|
|
1802
|
+
});
|
|
1803
|
+
console.log(`Metadata tables present: ${status.metadataTablesPresent ? "yes" : "no"}`);
|
|
1804
|
+
console.log(`Applied in database: ${status.appliedMigrationIds.length}`);
|
|
1805
|
+
console.log(`Pending locally: ${status.pendingMigrationIds.length}`);
|
|
1806
|
+
console.log(`Unexpected in database: ${status.unexpectedDatabaseMigrationIds.length}`);
|
|
1807
|
+
console.log(
|
|
1808
|
+
`Database schema hash: ${status.schemaHash.database ?? (status.metadataTablesPresent ? "missing" : "none")}`
|
|
1809
|
+
);
|
|
1810
|
+
console.log(`Drift detected: ${status.schemaHash.driftDetected ? "yes" : "no"}`);
|
|
1811
|
+
if (status.pendingMigrationIds.length > 0) {
|
|
1812
|
+
console.log("");
|
|
1813
|
+
console.log("Pending:");
|
|
1814
|
+
for (const migrationId of status.pendingMigrationIds) console.log(`- ${migrationId}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (status.unexpectedDatabaseMigrationIds.length > 0) {
|
|
1817
|
+
console.log("");
|
|
1818
|
+
console.log("Unexpected in database:");
|
|
1819
|
+
for (const migrationId of status.unexpectedDatabaseMigrationIds) console.log(`- ${migrationId}`);
|
|
1820
|
+
}
|
|
1821
|
+
if (status.schemaHash.driftDetected || status.unexpectedDatabaseMigrationIds.length > 0) {
|
|
1822
|
+
exit(1);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
async function handleSchemaPrint(args) {
|
|
1826
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1827
|
+
const { schema } = await materializeProjectMigrations(layout);
|
|
1828
|
+
console.log(JSON.stringify(schema, null, 2));
|
|
1829
|
+
}
|
|
1830
|
+
async function handleSchemaDrizzle(args) {
|
|
1831
|
+
const layout = resolveLayoutFromArgs(args);
|
|
1832
|
+
const { schema } = await materializeProjectMigrations(layout);
|
|
1833
|
+
const source = compileSchemaToDrizzle(schema);
|
|
1834
|
+
const outputPath = getStringOption(args, "out");
|
|
1835
|
+
if (outputPath) {
|
|
1836
|
+
await writeDrizzleSchema(schema, path2.resolve(outputPath));
|
|
1837
|
+
console.log(`Wrote Drizzle schema to ${path2.resolve(outputPath)}`);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
process.stdout.write(source);
|
|
1841
|
+
}
|
|
1842
|
+
function resolveLayoutFromArgs(args) {
|
|
1843
|
+
return resolveDbProjectLayout(getStringOption(args, "dir") ?? "db");
|
|
1844
|
+
}
|
|
1845
|
+
function parseArgs(argv) {
|
|
1846
|
+
const positionals = [];
|
|
1847
|
+
const options = /* @__PURE__ */ new Map();
|
|
1848
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1849
|
+
const value = argv[index];
|
|
1850
|
+
if (!value.startsWith("--")) {
|
|
1851
|
+
positionals.push(value);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
const withoutPrefix = value.slice(2);
|
|
1855
|
+
const [rawKey, inlineValue] = withoutPrefix.split("=", 2);
|
|
1856
|
+
const key = rawKey ?? "";
|
|
1857
|
+
if (inlineValue !== void 0) {
|
|
1858
|
+
options.set(key, inlineValue);
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
const next = argv[index + 1];
|
|
1862
|
+
if (next && !next.startsWith("--")) {
|
|
1863
|
+
options.set(key, next);
|
|
1864
|
+
index += 1;
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
options.set(key, true);
|
|
1868
|
+
}
|
|
1869
|
+
return {
|
|
1870
|
+
positionals,
|
|
1871
|
+
options
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
function hasFlag(args, name) {
|
|
1875
|
+
return args.options.get(name) === true;
|
|
1876
|
+
}
|
|
1877
|
+
function getStringOption(args, name) {
|
|
1878
|
+
const value = args.options.get(name);
|
|
1879
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1880
|
+
}
|
|
1881
|
+
function printHelp() {
|
|
1882
|
+
console.log(`sedrino-db
|
|
1883
|
+
|
|
1884
|
+
Usage:
|
|
1885
|
+
sedrino-db migrate create <name> [--dir db]
|
|
1886
|
+
sedrino-db migrate plan [--dir db] [--sql] [--snapshot path] [--drizzle-out path]
|
|
1887
|
+
sedrino-db migrate apply --url <libsql-url> [--auth-token token] [--dir db]
|
|
1888
|
+
sedrino-db migrate validate [--dir db]
|
|
1889
|
+
sedrino-db migrate status [--dir db] [--url <libsql-url>] [--auth-token token]
|
|
1890
|
+
sedrino-db schema print [--dir db]
|
|
1891
|
+
sedrino-db schema drizzle [--dir db] [--out path]
|
|
1892
|
+
|
|
1893
|
+
Defaults:
|
|
1894
|
+
--dir defaults to ./db
|
|
1895
|
+
schema snapshot defaults to ./db/schema/schema.snapshot.json
|
|
1896
|
+
drizzle output defaults to ./db/schema/schema.generated.ts
|
|
1897
|
+
`);
|
|
1898
|
+
}
|
|
1899
|
+
main().catch((error) => {
|
|
1900
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1901
|
+
console.error(message);
|
|
1902
|
+
exit(1);
|
|
1903
|
+
});
|
|
1904
|
+
//# sourceMappingURL=cli.js.map
|