@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/src/planner.ts CHANGED
@@ -1,8 +1,11 @@
1
- import type { DatabaseSchemaDocument, IndexSpec, TableSpec, UniqueSpec } from "./types";
1
+ import type { DatabaseSchemaDocument } from "./types";
2
2
  import { type MigrationDefinition, type MigrationOperation } from "./migration";
3
- import { assertValidSchemaDocument, createEmptySchema, findField, findTable } from "./schema";
3
+ import { assertValidSchemaDocument, createEmptySchema } from "./schema";
4
+ import { applyOperationsToSchema } from "./operations";
4
5
  import { renderSqliteMigration } from "./sqlite";
5
- import { cloneSchema, createSchemaHash, renameStorageColumn } from "./utils";
6
+ import { createSchemaHash } from "./utils";
7
+
8
+ export { applyOperationsToSchema } from "./operations";
6
9
 
7
10
  export type PlannedMigration = {
8
11
  migrationId: string;
@@ -34,7 +37,7 @@ export function planMigration(args: {
34
37
  toSchemaHash: createSchemaHash(nextSchema),
35
38
  operations,
36
39
  nextSchema,
37
- sql: renderSqliteMigration(operations),
40
+ sql: renderSqliteMigration(operations, { currentSchema }),
38
41
  };
39
42
  }
40
43
 
@@ -59,189 +62,3 @@ export function materializeSchema(args: {
59
62
  plans,
60
63
  };
61
64
  }
62
-
63
- export function applyOperationsToSchema(
64
- schemaInput: DatabaseSchemaDocument,
65
- operations: MigrationOperation[],
66
- ) {
67
- const schema = cloneSchema(schemaInput);
68
-
69
- for (const operation of operations) {
70
- switch (operation.kind) {
71
- case "createTable":
72
- if (findTable(schema, operation.table.name)) {
73
- throw new Error(`Table ${operation.table.name} already exists`);
74
- }
75
- schema.tables.push(cloneSchema(operation.table));
76
- break;
77
- case "dropTable": {
78
- const referencedBy = schema.tables.flatMap((table) =>
79
- table.fields
80
- .filter((field) => field.references?.table === operation.tableName)
81
- .map((field) => `${table.name}.${field.name}`),
82
- );
83
-
84
- if (referencedBy.length > 0) {
85
- throw new Error(
86
- `Cannot drop table ${operation.tableName}; still referenced by ${referencedBy.join(", ")}`,
87
- );
88
- }
89
-
90
- const index = schema.tables.findIndex((table) => table.name === operation.tableName);
91
- if (index < 0) throw new Error(`Table ${operation.tableName} does not exist`);
92
- schema.tables.splice(index, 1);
93
- break;
94
- }
95
- case "renameTable": {
96
- const table = findTable(schema, operation.from);
97
- if (!table) throw new Error(`Table ${operation.from} does not exist`);
98
- if (findTable(schema, operation.to)) {
99
- throw new Error(`Table ${operation.to} already exists`);
100
- }
101
-
102
- table.name = operation.to;
103
- for (const candidateTable of schema.tables) {
104
- for (const field of candidateTable.fields) {
105
- if (field.references?.table === operation.from) {
106
- field.references = {
107
- ...field.references,
108
- table: operation.to,
109
- };
110
- }
111
- }
112
- }
113
- break;
114
- }
115
- case "addField": {
116
- const table = findTable(schema, operation.tableName);
117
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
118
- if (findField(table, operation.field.name)) {
119
- throw new Error(`Field ${operation.tableName}.${operation.field.name} already exists`);
120
- }
121
- table.fields.push(cloneSchema(operation.field));
122
- break;
123
- }
124
- case "dropField": {
125
- const table = findTable(schema, operation.tableName);
126
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
127
- const fieldIndex = table.fields.findIndex((field) => field.name === operation.fieldName);
128
- if (fieldIndex < 0) {
129
- throw new Error(`Field ${operation.tableName}.${operation.fieldName} does not exist`);
130
- }
131
- const referencedBy = schema.tables.flatMap((candidateTable) =>
132
- candidateTable.fields
133
- .filter(
134
- (field) =>
135
- field.references?.table === operation.tableName &&
136
- field.references.field === operation.fieldName,
137
- )
138
- .map((field) => `${candidateTable.name}.${field.name}`),
139
- );
140
- if (referencedBy.length > 0) {
141
- throw new Error(
142
- `Cannot drop field ${operation.tableName}.${operation.fieldName}; still referenced by ${referencedBy.join(", ")}`,
143
- );
144
- }
145
- table.fields.splice(fieldIndex, 1);
146
- table.indexes = table.indexes.filter(
147
- (index) => !index.fields.includes(operation.fieldName),
148
- );
149
- table.uniques = table.uniques.filter(
150
- (unique) => !unique.fields.includes(operation.fieldName),
151
- );
152
- break;
153
- }
154
- case "renameField": {
155
- const table = findTable(schema, operation.tableName);
156
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
157
- const field = findField(table, operation.from);
158
- if (!field)
159
- throw new Error(`Field ${operation.tableName}.${operation.from} does not exist`);
160
- if (findField(table, operation.to)) {
161
- throw new Error(`Field ${operation.tableName}.${operation.to} already exists`);
162
- }
163
-
164
- const renamed = renameStorageColumn(field, operation.to);
165
- const index = table.fields.findIndex((candidate) => candidate.id === field.id);
166
- table.fields[index] = renamed;
167
- table.indexes = renameFieldInIndexes(table.indexes, operation.from, operation.to);
168
- table.uniques = renameFieldInUniques(table.uniques, operation.from, operation.to);
169
-
170
- for (const candidateTable of schema.tables) {
171
- for (const candidateField of candidateTable.fields) {
172
- if (
173
- candidateField.references?.table === table.name &&
174
- candidateField.references.field === operation.from
175
- ) {
176
- candidateField.references = {
177
- ...candidateField.references,
178
- field: operation.to,
179
- };
180
- }
181
- }
182
- }
183
- break;
184
- }
185
- case "addIndex": {
186
- const table = findTable(schema, operation.tableName);
187
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
188
- table.indexes.push(cloneSchema(operation.index));
189
- break;
190
- }
191
- case "dropIndex": {
192
- const table = findTable(schema, operation.tableName);
193
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
194
- const nextIndexes = table.indexes.filter(
195
- (index) => resolveIndexName(table.name, index) !== operation.indexName,
196
- );
197
- if (nextIndexes.length === table.indexes.length) {
198
- throw new Error(`Index ${operation.indexName} does not exist on ${table.name}`);
199
- }
200
- table.indexes = nextIndexes;
201
- break;
202
- }
203
- case "addUnique": {
204
- const table = findTable(schema, operation.tableName);
205
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
206
- table.uniques.push(cloneSchema(operation.unique));
207
- break;
208
- }
209
- case "dropUnique": {
210
- const table = findTable(schema, operation.tableName);
211
- if (!table) throw new Error(`Table ${operation.tableName} does not exist`);
212
- const nextUniques = table.uniques.filter(
213
- (unique) => resolveUniqueName(table.name, unique) !== operation.uniqueName,
214
- );
215
- if (nextUniques.length === table.uniques.length) {
216
- throw new Error(`Unique ${operation.uniqueName} does not exist on ${table.name}`);
217
- }
218
- table.uniques = nextUniques;
219
- break;
220
- }
221
- }
222
- }
223
-
224
- return assertValidSchemaDocument(schema);
225
- }
226
-
227
- function renameFieldInIndexes(indexes: IndexSpec[], from: string, to: string): IndexSpec[] {
228
- return indexes.map((index) => ({
229
- ...index,
230
- fields: index.fields.map((field) => (field === from ? to : field)),
231
- }));
232
- }
233
-
234
- function renameFieldInUniques(uniques: UniqueSpec[], from: string, to: string): UniqueSpec[] {
235
- return uniques.map((unique) => ({
236
- ...unique,
237
- fields: unique.fields.map((field) => (field === from ? to : field)),
238
- }));
239
- }
240
-
241
- function resolveIndexName(tableName: string, index: IndexSpec) {
242
- return index.name ?? `${tableName}_${index.fields.join("_")}_idx`;
243
- }
244
-
245
- function resolveUniqueName(tableName: string, unique: UniqueSpec) {
246
- return unique.name ?? `${tableName}_${unique.fields.join("_")}_unique`;
247
- }
package/src/project.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import type { DatabaseSchemaDocument } from "./types";
@@ -16,6 +16,13 @@ export type DbProjectLayout = {
16
16
  drizzlePath: string;
17
17
  };
18
18
 
19
+ export type CreatedMigrationFile = {
20
+ filePath: string;
21
+ fileName: string;
22
+ migrationId: string;
23
+ migrationName: string;
24
+ };
25
+
19
26
  export function resolveDbProjectLayout(dbDir = "db"): DbProjectLayout {
20
27
  const absoluteDbDir = path.resolve(dbDir);
21
28
  return {
@@ -61,6 +68,7 @@ export async function loadMigrationDefinitionsFromDirectory(
61
68
 
62
69
  export async function materializeProjectMigrations(layout: DbProjectLayout) {
63
70
  const migrations = await loadMigrationDefinitionsFromDirectory(layout.migrationsDir);
71
+ assertUniqueMigrationIds(migrations);
64
72
  const materialized = materializeSchema({ migrations });
65
73
  return {
66
74
  ...materialized,
@@ -68,6 +76,67 @@ export async function materializeProjectMigrations(layout: DbProjectLayout) {
68
76
  };
69
77
  }
70
78
 
79
+ export async function createMigrationScaffold(
80
+ layout: DbProjectLayout,
81
+ rawName: string,
82
+ now = new Date(),
83
+ ): Promise<CreatedMigrationFile> {
84
+ const slug = toKebabCase(rawName);
85
+ if (!slug) {
86
+ throw new Error("Migration name must contain at least one letter or number");
87
+ }
88
+
89
+ await mkdir(layout.migrationsDir, { recursive: true });
90
+
91
+ const datePrefix = formatMigrationDate(now);
92
+ const sequence = await nextMigrationSequence(layout.migrationsDir, datePrefix);
93
+ const sequenceText = String(sequence).padStart(3, "0");
94
+ const migrationId = `${datePrefix}-${sequenceText}-${slug}`;
95
+ const fileName = `${migrationId}.ts`;
96
+ const filePath = path.join(layout.migrationsDir, fileName);
97
+ const migrationName = humanizeMigrationName(slug);
98
+ const source = renderMigrationTemplate({
99
+ migrationId,
100
+ migrationName,
101
+ });
102
+
103
+ await writeFile(filePath, source, { encoding: "utf8", flag: "wx" });
104
+
105
+ return {
106
+ filePath,
107
+ fileName,
108
+ migrationId,
109
+ migrationName,
110
+ };
111
+ }
112
+
113
+ export async function validateDbProject(layout: DbProjectLayout) {
114
+ const materialized = await materializeProjectMigrations(layout);
115
+ const expectedSnapshot = `${JSON.stringify(materialized.schema, null, 2)}\n`;
116
+ const expectedDrizzle = compileSchemaToDrizzle(materialized.schema);
117
+ const warnings = materialized.plans.flatMap((plan) =>
118
+ plan.sql.warnings.map((warning) => `${plan.migrationId}: ${warning}`),
119
+ );
120
+
121
+ const [snapshotContents, drizzleContents] = await Promise.all([
122
+ readTextIfExists(layout.snapshotPath),
123
+ readTextIfExists(layout.drizzlePath),
124
+ ]);
125
+
126
+ return {
127
+ ...materialized,
128
+ warnings,
129
+ expectedSnapshot,
130
+ expectedDrizzle,
131
+ artifacts: {
132
+ snapshotExists: snapshotContents !== null,
133
+ drizzleExists: drizzleContents !== null,
134
+ snapshotUpToDate: snapshotContents === expectedSnapshot,
135
+ drizzleUpToDate: drizzleContents === expectedDrizzle,
136
+ },
137
+ };
138
+ }
139
+
71
140
  export async function writeSchemaSnapshot(schema: DatabaseSchemaDocument, snapshotPath: string) {
72
141
  await mkdir(path.dirname(snapshotPath), { recursive: true });
73
142
  await writeFile(snapshotPath, `${JSON.stringify(schema, null, 2)}\n`, "utf8");
@@ -77,3 +146,90 @@ export async function writeDrizzleSchema(schema: DatabaseSchemaDocument, drizzle
77
146
  await mkdir(path.dirname(drizzlePath), { recursive: true });
78
147
  await writeFile(drizzlePath, compileSchemaToDrizzle(schema), "utf8");
79
148
  }
149
+
150
+ function assertUniqueMigrationIds(migrations: MigrationDefinition[]) {
151
+ const seen = new Set<string>();
152
+ for (const migration of migrations) {
153
+ if (seen.has(migration.meta.id)) {
154
+ throw new Error(`Duplicate migration id ${migration.meta.id}`);
155
+ }
156
+ seen.add(migration.meta.id);
157
+ }
158
+ }
159
+
160
+ async function readTextIfExists(filePath: string) {
161
+ try {
162
+ return await readFile(filePath, "utf8");
163
+ } catch (error) {
164
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
165
+ return null;
166
+ }
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ async function nextMigrationSequence(migrationsDir: string, datePrefix: string) {
172
+ const files = await readdir(migrationsDir, { withFileTypes: true });
173
+ let maxSequence = 0;
174
+
175
+ for (const entry of files) {
176
+ if (!entry.isFile()) continue;
177
+ const match = entry.name.match(/^(\d{4}-\d{2}-\d{2})-(\d{3})(?:-[^.]+)?\.(?:ts|mts|js|mjs)$/);
178
+ if (!match) continue;
179
+ if (match[1] !== datePrefix) continue;
180
+
181
+ const parsed = Number(match[2]);
182
+ if (Number.isInteger(parsed) && parsed > maxSequence) {
183
+ maxSequence = parsed;
184
+ }
185
+ }
186
+
187
+ return maxSequence + 1;
188
+ }
189
+
190
+ function formatMigrationDate(date: Date) {
191
+ const year = date.getFullYear();
192
+ const month = String(date.getMonth() + 1).padStart(2, "0");
193
+ const day = String(date.getDate()).padStart(2, "0");
194
+ return `${year}-${month}-${day}`;
195
+ }
196
+
197
+ function toKebabCase(value: string) {
198
+ return value
199
+ .trim()
200
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
201
+ .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1-$2")
202
+ .replace(/[^a-zA-Z0-9]+/g, "-")
203
+ .replace(/^-+|-+$/g, "")
204
+ .toLowerCase();
205
+ }
206
+
207
+ function humanizeMigrationName(slug: string) {
208
+ const parts = slug.split("-").filter(Boolean);
209
+ const first = parts[0];
210
+ if (!first) return "New migration";
211
+
212
+ const rest = parts.slice(1);
213
+ return [
214
+ `${first[0]!.toUpperCase()}${first.slice(1)}`,
215
+ ...rest,
216
+ ].join(" ");
217
+ }
218
+
219
+ function renderMigrationTemplate(args: {
220
+ migrationId: string;
221
+ migrationName: string;
222
+ }) {
223
+ return `import { createMigration } from "@sedrino/db-schema";
224
+
225
+ export default createMigration(
226
+ {
227
+ id: ${JSON.stringify(args.migrationId)},
228
+ name: ${JSON.stringify(args.migrationName)},
229
+ },
230
+ (m) => {
231
+ // TODO: define migration operations.
232
+ },
233
+ );
234
+ `;
235
+ }
@@ -0,0 +1,123 @@
1
+ import { toSnakeCase } from "./utils";
2
+ import type { MigrationSqlExpression } from "./migration";
3
+
4
+ class SqlExpressionBuilder {
5
+ constructor(private readonly text: string) {}
6
+
7
+ toSqlExpression(): MigrationSqlExpression {
8
+ return { sql: this.text };
9
+ }
10
+
11
+ toString() {
12
+ return this.text;
13
+ }
14
+ }
15
+
16
+ export function sqlExpression(sql: string): MigrationSqlExpression {
17
+ const value = sql.trim();
18
+ if (!value) throw new Error("SQL expression must be non-empty");
19
+ return { sql: value };
20
+ }
21
+
22
+ export function column(fieldName: string): MigrationSqlExpression {
23
+ return sqlExpression(quoteIdentifier(toSnakeCase(fieldName)));
24
+ }
25
+
26
+ export function raw(sql: string): MigrationSqlExpression {
27
+ return sqlExpression(sql);
28
+ }
29
+
30
+ export function literal(value: string | number | boolean | null): MigrationSqlExpression {
31
+ if (value === null) return sqlExpression("NULL");
32
+ if (typeof value === "string") {
33
+ return sqlExpression(`'${value.replace(/'/g, "''")}'`);
34
+ }
35
+ if (typeof value === "boolean") {
36
+ return sqlExpression(value ? "TRUE" : "FALSE");
37
+ }
38
+ return sqlExpression(String(value));
39
+ }
40
+
41
+ export function lower(expression: MigrationSqlExpression | string): MigrationSqlExpression {
42
+ return sqlExpression(`lower(${normalizeExpression(expression)})`);
43
+ }
44
+
45
+ export function trim(expression: MigrationSqlExpression | string): MigrationSqlExpression {
46
+ return sqlExpression(`trim(${normalizeExpression(expression)})`);
47
+ }
48
+
49
+ export function replace(
50
+ expression: MigrationSqlExpression | string,
51
+ search: string,
52
+ replacement: string,
53
+ ): MigrationSqlExpression {
54
+ return sqlExpression(
55
+ `replace(${normalizeExpression(expression)}, ${literal(search).sql}, ${literal(replacement).sql})`,
56
+ );
57
+ }
58
+
59
+ export function cast(
60
+ expression: MigrationSqlExpression | string,
61
+ sqlType: "INTEGER" | "REAL" | "TEXT",
62
+ ): MigrationSqlExpression {
63
+ return sqlExpression(`CAST(${normalizeExpression(expression)} AS ${sqlType})`);
64
+ }
65
+
66
+ export function unixepoch(expression: MigrationSqlExpression | string): MigrationSqlExpression {
67
+ return sqlExpression(`unixepoch(${normalizeExpression(expression)})`);
68
+ }
69
+
70
+ export function date(expression: MigrationSqlExpression | string): MigrationSqlExpression {
71
+ return sqlExpression(`date(${normalizeExpression(expression)})`);
72
+ }
73
+
74
+ export function multiply(
75
+ expression: MigrationSqlExpression | string,
76
+ factor: number,
77
+ ): MigrationSqlExpression {
78
+ return sqlExpression(`${normalizeExpression(expression)} * ${factor}`);
79
+ }
80
+
81
+ export function coalesce(
82
+ ...expressions: Array<MigrationSqlExpression | string>
83
+ ): MigrationSqlExpression {
84
+ if (expressions.length === 0) {
85
+ throw new Error("coalesce requires at least one expression");
86
+ }
87
+ return sqlExpression(`COALESCE(${expressions.map((value) => normalizeExpression(value)).join(", ")})`);
88
+ }
89
+
90
+ export function concat(
91
+ ...expressions: Array<MigrationSqlExpression | string>
92
+ ): MigrationSqlExpression {
93
+ if (expressions.length === 0) {
94
+ throw new Error("concat requires at least one expression");
95
+ }
96
+ return sqlExpression(expressions.map((value) => normalizeExpression(value)).join(" || "));
97
+ }
98
+
99
+ export const sqlExpr = {
100
+ raw,
101
+ column,
102
+ literal,
103
+ lower,
104
+ trim,
105
+ replace,
106
+ cast,
107
+ unixepoch,
108
+ date,
109
+ multiply,
110
+ coalesce,
111
+ concat,
112
+ build(sql: string) {
113
+ return new SqlExpressionBuilder(sql).toSqlExpression();
114
+ },
115
+ };
116
+
117
+ function normalizeExpression(expression: MigrationSqlExpression | string) {
118
+ return typeof expression === "string" ? sqlExpression(expression).sql : expression.sql;
119
+ }
120
+
121
+ function quoteIdentifier(value: string) {
122
+ return `"${value.replace(/"/g, '""')}"`;
123
+ }