@sedrino/db-schema 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/project.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { DatabaseSchemaDocument } from "./types";
5
+ import type { MigrationDefinition } from "./migration";
6
+ import { compileSchemaToDrizzle } from "./drizzle";
7
+ import { materializeSchema } from "./planner";
8
+
9
+ const MIGRATION_EXTENSIONS = new Set([".ts", ".mts", ".js", ".mjs"]);
10
+
11
+ export type DbProjectLayout = {
12
+ dbDir: string;
13
+ migrationsDir: string;
14
+ schemaDir: string;
15
+ snapshotPath: string;
16
+ drizzlePath: string;
17
+ };
18
+
19
+ export function resolveDbProjectLayout(dbDir = "db"): DbProjectLayout {
20
+ const absoluteDbDir = path.resolve(dbDir);
21
+ return {
22
+ dbDir: absoluteDbDir,
23
+ migrationsDir: path.join(absoluteDbDir, "migrations"),
24
+ schemaDir: path.join(absoluteDbDir, "schema"),
25
+ snapshotPath: path.join(absoluteDbDir, "schema", "schema.snapshot.json"),
26
+ drizzlePath: path.join(absoluteDbDir, "schema", "schema.generated.ts"),
27
+ };
28
+ }
29
+
30
+ export async function loadMigrationDefinitionsFromDirectory(
31
+ migrationsDir: string,
32
+ ): Promise<MigrationDefinition[]> {
33
+ const files = await readdir(migrationsDir, { withFileTypes: true });
34
+ const migrationFiles = files
35
+ .filter((entry) => entry.isFile())
36
+ .map((entry) => path.join(migrationsDir, entry.name))
37
+ .filter((filePath) => MIGRATION_EXTENSIONS.has(path.extname(filePath)))
38
+ .sort((left, right) => path.basename(left).localeCompare(path.basename(right)));
39
+
40
+ const migrations: MigrationDefinition[] = [];
41
+
42
+ for (const filePath of migrationFiles) {
43
+ const imported = await import(pathToFileURL(filePath).href);
44
+ const definition = imported.default as Partial<MigrationDefinition> | undefined;
45
+
46
+ if (
47
+ !definition ||
48
+ !definition.meta ||
49
+ typeof definition.meta.id !== "string" ||
50
+ typeof definition.meta.name !== "string" ||
51
+ typeof definition.buildOperations !== "function"
52
+ ) {
53
+ throw new Error(`Migration file ${filePath} does not export a valid default migration`);
54
+ }
55
+
56
+ migrations.push(definition as MigrationDefinition);
57
+ }
58
+
59
+ return migrations;
60
+ }
61
+
62
+ export async function materializeProjectMigrations(layout: DbProjectLayout) {
63
+ const migrations = await loadMigrationDefinitionsFromDirectory(layout.migrationsDir);
64
+ const materialized = materializeSchema({ migrations });
65
+ return {
66
+ ...materialized,
67
+ migrations,
68
+ };
69
+ }
70
+
71
+ export async function writeSchemaSnapshot(schema: DatabaseSchemaDocument, snapshotPath: string) {
72
+ await mkdir(path.dirname(snapshotPath), { recursive: true });
73
+ await writeFile(snapshotPath, `${JSON.stringify(schema, null, 2)}\n`, "utf8");
74
+ }
75
+
76
+ export async function writeDrizzleSchema(schema: DatabaseSchemaDocument, drizzlePath: string) {
77
+ await mkdir(path.dirname(drizzlePath), { recursive: true });
78
+ await writeFile(drizzlePath, compileSchemaToDrizzle(schema), "utf8");
79
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,53 @@
1
+ import {
2
+ type DatabaseSchemaDocument,
3
+ type FieldSpec,
4
+ type SchemaValidationIssue,
5
+ schemaDocumentSchema,
6
+ } from "./types";
7
+ import { createSchemaHash, validateSchemaDocumentCompatibility } from "./utils";
8
+
9
+ export function createEmptySchema(schemaId = "schema") {
10
+ return schemaDocumentSchema.parse({
11
+ version: 1,
12
+ dialect: "sqlite",
13
+ schemaId,
14
+ tables: [],
15
+ });
16
+ }
17
+
18
+ export function parseSchemaDocument(input: unknown): DatabaseSchemaDocument {
19
+ return schemaDocumentSchema.parse(input);
20
+ }
21
+
22
+ export function validateSchemaDocument(input: unknown): {
23
+ schema: DatabaseSchemaDocument;
24
+ issues: SchemaValidationIssue[];
25
+ } {
26
+ const schema = parseSchemaDocument(input);
27
+ return {
28
+ schema,
29
+ issues: validateSchemaDocumentCompatibility(schema),
30
+ };
31
+ }
32
+
33
+ export function assertValidSchemaDocument(input: unknown): DatabaseSchemaDocument {
34
+ const { schema, issues } = validateSchemaDocument(input);
35
+ if (issues.length > 0) {
36
+ throw new Error(
37
+ `Schema validation failed:\n${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`,
38
+ );
39
+ }
40
+ return schema;
41
+ }
42
+
43
+ export function schemaHash(input: DatabaseSchemaDocument) {
44
+ return createSchemaHash(input);
45
+ }
46
+
47
+ export function findTable(schema: DatabaseSchemaDocument, tableName: string) {
48
+ return schema.tables.find((table) => table.name === tableName) ?? null;
49
+ }
50
+
51
+ export function findField(table: { fields: FieldSpec[] }, fieldName: string) {
52
+ return table.fields.find((field) => field.name === fieldName) ?? null;
53
+ }
package/src/sqlite.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type {
2
+ DatabaseSchemaDocument,
3
+ DefaultSpec,
4
+ FieldSpec,
5
+ IndexSpec,
6
+ StorageSpec,
7
+ TableSpec,
8
+ UniqueSpec,
9
+ } from "./types";
10
+ import type { MigrationOperation } from "./migration";
11
+ import { quoteIdentifier, sqliteEpochMsNowSql, toSnakeCase } from "./utils";
12
+
13
+ export function compileSchemaToSqlite(schema: DatabaseSchemaDocument) {
14
+ const statements: string[] = [];
15
+
16
+ for (const table of schema.tables) {
17
+ statements.push(renderCreateTableStatement(table));
18
+ statements.push(...renderCreateIndexStatements(table));
19
+ }
20
+
21
+ return `${statements.join("\n\n")}\n`;
22
+ }
23
+
24
+ export function renderSqliteMigration(operations: MigrationOperation[]) {
25
+ const statements: string[] = [];
26
+ const warnings: string[] = [];
27
+
28
+ for (const operation of operations) {
29
+ switch (operation.kind) {
30
+ case "createTable":
31
+ statements.push(renderCreateTableStatement(operation.table));
32
+ statements.push(...renderCreateIndexStatements(operation.table));
33
+ break;
34
+ case "dropTable":
35
+ statements.push(`DROP TABLE ${quoteIdentifier(operation.tableName)};`);
36
+ break;
37
+ case "renameTable":
38
+ statements.push(
39
+ `ALTER TABLE ${quoteIdentifier(operation.from)} RENAME TO ${quoteIdentifier(operation.to)};`,
40
+ );
41
+ break;
42
+ case "addField":
43
+ statements.push(
44
+ `ALTER TABLE ${quoteIdentifier(operation.tableName)} ADD COLUMN ${renderColumnDefinition(operation.field)};`,
45
+ );
46
+ break;
47
+ case "renameField":
48
+ statements.push(
49
+ `ALTER TABLE ${quoteIdentifier(operation.tableName)} RENAME COLUMN ${quoteIdentifier(toSnakeCase(operation.from))} TO ${quoteIdentifier(toSnakeCase(operation.to))};`,
50
+ );
51
+ break;
52
+ case "addIndex":
53
+ statements.push(renderCreateIndexStatement(operation.tableName, operation.index));
54
+ break;
55
+ case "dropIndex":
56
+ statements.push(`DROP INDEX ${quoteIdentifier(operation.indexName)};`);
57
+ break;
58
+ case "addUnique":
59
+ statements.push(renderCreateUniqueStatement(operation.tableName, operation.unique));
60
+ break;
61
+ case "dropUnique":
62
+ statements.push(`DROP INDEX ${quoteIdentifier(operation.uniqueName)};`);
63
+ break;
64
+ case "dropField":
65
+ warnings.push(
66
+ `dropField ${operation.tableName}.${operation.fieldName} requires a table rebuild and is not emitted in v1`,
67
+ );
68
+ break;
69
+ }
70
+ }
71
+
72
+ return {
73
+ statements,
74
+ warnings,
75
+ };
76
+ }
77
+
78
+ export function renderCreateTableStatement(table: TableSpec) {
79
+ const columnLines = table.fields.map((field) => ` ${renderColumnDefinition(field)}`);
80
+ return `CREATE TABLE ${quoteIdentifier(table.name)} (\n${columnLines.join(",\n")}\n);`;
81
+ }
82
+
83
+ export function renderCreateIndexStatements(table: TableSpec) {
84
+ const statements = table.indexes.map((index) =>
85
+ renderCreateIndexStatement(table.name, index, table),
86
+ );
87
+ statements.push(
88
+ ...table.uniques.map((unique) => renderCreateUniqueStatement(table.name, unique, table)),
89
+ );
90
+ return statements;
91
+ }
92
+
93
+ function renderCreateIndexStatement(tableName: string, index: IndexSpec, table?: TableSpec) {
94
+ const name = index.name ?? `${tableName}_${index.fields.join("_")}_idx`;
95
+ return `CREATE INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(tableName)} (${index.fields
96
+ .map((field) => quoteIdentifier(resolveColumnName(table, field)))
97
+ .join(", ")});`;
98
+ }
99
+
100
+ function renderCreateUniqueStatement(tableName: string, unique: UniqueSpec, table?: TableSpec) {
101
+ const name = unique.name ?? `${tableName}_${unique.fields.join("_")}_unique`;
102
+ return `CREATE UNIQUE INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(tableName)} (${unique.fields
103
+ .map((field) => quoteIdentifier(resolveColumnName(table, field)))
104
+ .join(", ")});`;
105
+ }
106
+
107
+ function renderColumnDefinition(field: FieldSpec) {
108
+ const parts = [quoteIdentifier(field.storage.column), renderSqlType(field.storage)];
109
+
110
+ if (field.primaryKey) parts.push("PRIMARY KEY");
111
+ if (!field.nullable) parts.push("NOT NULL");
112
+ if (field.unique && !field.primaryKey) parts.push("UNIQUE");
113
+
114
+ const defaultSql = renderSqlDefault(field.default, field);
115
+ if (defaultSql) parts.push(`DEFAULT ${defaultSql}`);
116
+
117
+ if (field.references) {
118
+ parts.push(
119
+ `REFERENCES ${quoteIdentifier(field.references.table)}(${quoteIdentifier(toSnakeCase(field.references.field))})`,
120
+ );
121
+ if (field.references.onDelete)
122
+ parts.push(`ON DELETE ${field.references.onDelete.toUpperCase()}`);
123
+ if (field.references.onUpdate)
124
+ parts.push(`ON UPDATE ${field.references.onUpdate.toUpperCase()}`);
125
+ }
126
+
127
+ return parts.join(" ");
128
+ }
129
+
130
+ function renderSqlType(storage: StorageSpec) {
131
+ switch (storage.strategy) {
132
+ case "sqlite.text":
133
+ case "sqlite.temporalPlainDateText":
134
+ return "TEXT";
135
+ case "sqlite.integer":
136
+ case "sqlite.temporalInstantEpochMs":
137
+ return "INTEGER";
138
+ case "sqlite.real":
139
+ return "REAL";
140
+ }
141
+ }
142
+
143
+ function renderSqlDefault(defaultValue: DefaultSpec | undefined, field: FieldSpec) {
144
+ if (!defaultValue) return null;
145
+
146
+ switch (defaultValue.kind) {
147
+ case "generatedId":
148
+ return null;
149
+ case "now":
150
+ return `(${sqliteEpochMsNowSql()})`;
151
+ case "literal":
152
+ if (defaultValue.value === null) return "NULL";
153
+ if (typeof defaultValue.value === "string") {
154
+ return `'${defaultValue.value.replace(/'/g, "''")}'`;
155
+ }
156
+ if (typeof defaultValue.value === "boolean") {
157
+ return field.storage.strategy === "sqlite.integer"
158
+ ? defaultValue.value
159
+ ? "1"
160
+ : "0"
161
+ : defaultValue.value
162
+ ? "TRUE"
163
+ : "FALSE";
164
+ }
165
+ return `${defaultValue.value}`;
166
+ }
167
+ }
168
+
169
+ function resolveColumnName(table: TableSpec | undefined, fieldName: string) {
170
+ const field = table?.fields.find((candidate) => candidate.name === fieldName);
171
+ return field?.storage.column ?? toSnakeCase(fieldName);
172
+ }
package/src/types.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { z } from "zod";
2
+
3
+ export const foreignKeyActionSchema = z.enum([
4
+ "cascade",
5
+ "restrict",
6
+ "set null",
7
+ "set default",
8
+ "no action",
9
+ ]);
10
+
11
+ export const fieldReferenceSpecSchema = z.object({
12
+ table: z.string().min(1),
13
+ field: z.string().min(1),
14
+ onDelete: foreignKeyActionSchema.optional(),
15
+ onUpdate: foreignKeyActionSchema.optional(),
16
+ });
17
+
18
+ export const logicalTypeSpecSchema = z.discriminatedUnion("kind", [
19
+ z.object({
20
+ kind: z.literal("id"),
21
+ prefix: z.string().min(1),
22
+ }),
23
+ z.object({
24
+ kind: z.literal("string"),
25
+ format: z.enum(["email", "url", "slug"]).optional(),
26
+ }),
27
+ z.object({
28
+ kind: z.literal("text"),
29
+ }),
30
+ z.object({
31
+ kind: z.literal("boolean"),
32
+ }),
33
+ z.object({
34
+ kind: z.literal("integer"),
35
+ }),
36
+ z.object({
37
+ kind: z.literal("number"),
38
+ }),
39
+ z.object({
40
+ kind: z.literal("enum"),
41
+ values: z.array(z.string()).min(1),
42
+ }),
43
+ z.object({
44
+ kind: z.literal("json"),
45
+ tsType: z.string().min(1),
46
+ }),
47
+ z.object({
48
+ kind: z.literal("temporal.instant"),
49
+ }),
50
+ z.object({
51
+ kind: z.literal("temporal.plainDate"),
52
+ }),
53
+ ]);
54
+
55
+ export const storageSpecSchema = z.discriminatedUnion("strategy", [
56
+ z.object({
57
+ strategy: z.literal("sqlite.text"),
58
+ column: z.string().min(1),
59
+ }),
60
+ z.object({
61
+ strategy: z.literal("sqlite.integer"),
62
+ column: z.string().min(1),
63
+ }),
64
+ z.object({
65
+ strategy: z.literal("sqlite.real"),
66
+ column: z.string().min(1),
67
+ }),
68
+ z.object({
69
+ strategy: z.literal("sqlite.temporalInstantEpochMs"),
70
+ column: z.string().min(1),
71
+ }),
72
+ z.object({
73
+ strategy: z.literal("sqlite.temporalPlainDateText"),
74
+ column: z.string().min(1),
75
+ }),
76
+ ]);
77
+
78
+ export const defaultSpecSchema = z.discriminatedUnion("kind", [
79
+ z.object({
80
+ kind: z.literal("literal"),
81
+ value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
82
+ }),
83
+ z.object({
84
+ kind: z.literal("now"),
85
+ }),
86
+ z.object({
87
+ kind: z.literal("generatedId"),
88
+ prefix: z.string().min(1),
89
+ }),
90
+ ]);
91
+
92
+ export const fieldSpecSchema = z.object({
93
+ id: z.string().min(1),
94
+ name: z.string().min(1),
95
+ logical: logicalTypeSpecSchema,
96
+ storage: storageSpecSchema,
97
+ nullable: z.boolean().default(true),
98
+ default: defaultSpecSchema.optional(),
99
+ primaryKey: z.boolean().default(false),
100
+ unique: z.boolean().default(false),
101
+ description: z.string().min(1).optional(),
102
+ references: fieldReferenceSpecSchema.optional(),
103
+ });
104
+
105
+ export const indexSpecSchema = z.object({
106
+ name: z.string().min(1).optional(),
107
+ fields: z.array(z.string().min(1)).min(1),
108
+ });
109
+
110
+ export const uniqueSpecSchema = z.object({
111
+ name: z.string().min(1).optional(),
112
+ fields: z.array(z.string().min(1)).min(1),
113
+ });
114
+
115
+ export const tableSpecSchema = z.object({
116
+ id: z.string().min(1),
117
+ name: z.string().min(1),
118
+ description: z.string().min(1).optional(),
119
+ fields: z.array(fieldSpecSchema).default([]),
120
+ indexes: z.array(indexSpecSchema).default([]),
121
+ uniques: z.array(uniqueSpecSchema).default([]),
122
+ });
123
+
124
+ export const schemaDocumentSchema = z.object({
125
+ version: z.literal(1),
126
+ dialect: z.literal("sqlite"),
127
+ schemaId: z.string().min(1),
128
+ tables: z.array(tableSpecSchema).default([]),
129
+ });
130
+
131
+ export type ForeignKeyAction = z.infer<typeof foreignKeyActionSchema>;
132
+ export type FieldReferenceSpec = z.infer<typeof fieldReferenceSpecSchema>;
133
+ export type LogicalTypeSpec = z.infer<typeof logicalTypeSpecSchema>;
134
+ export type StorageSpec = z.infer<typeof storageSpecSchema>;
135
+ export type DefaultSpec = z.infer<typeof defaultSpecSchema>;
136
+ export type FieldSpec = z.infer<typeof fieldSpecSchema>;
137
+ export type IndexSpec = z.infer<typeof indexSpecSchema>;
138
+ export type UniqueSpec = z.infer<typeof uniqueSpecSchema>;
139
+ export type TableSpec = z.infer<typeof tableSpecSchema>;
140
+ export type DatabaseSchemaDocument = z.infer<typeof schemaDocumentSchema>;
141
+
142
+ export type SchemaValidationIssue = {
143
+ path: string;
144
+ message: string;
145
+ };