@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/README.md +69 -0
- package/dist/index.d.ts +1222 -0
- package/dist/index.js +1456 -0
- package/dist/index.js.map +1 -0
- package/docs/cli.md +70 -0
- package/docs/index.md +11 -0
- package/docs/migrations.md +65 -0
- package/docs/schema-document.md +48 -0
- package/package.json +41 -0
- package/src/apply.ts +234 -0
- package/src/cli.ts +209 -0
- package/src/drizzle.ts +178 -0
- package/src/index.ts +62 -0
- package/src/migration.ts +456 -0
- package/src/planner.ts +247 -0
- package/src/project.ts +79 -0
- package/src/schema.ts +53 -0
- package/src/sqlite.ts +172 -0
- package/src/types.ts +145 -0
- package/src/utils.ts +286 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DatabaseSchemaDocument,
|
|
3
|
+
DefaultSpec,
|
|
4
|
+
FieldSpec,
|
|
5
|
+
LogicalTypeSpec,
|
|
6
|
+
SchemaValidationIssue,
|
|
7
|
+
StorageSpec,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
const SQLITE_EPOCH_MS_NOW =
|
|
11
|
+
"CAST(strftime('%s','now') AS integer) * 1000 + CAST((strftime('%f','now') - strftime('%S','now')) * 1000 AS integer)";
|
|
12
|
+
|
|
13
|
+
export function sqliteEpochMsNowSql() {
|
|
14
|
+
return SQLITE_EPOCH_MS_NOW;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toSnakeCase(value: string) {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/-/g, "_")
|
|
20
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
21
|
+
.replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1_$2")
|
|
22
|
+
.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toPascalCase(value: string) {
|
|
26
|
+
return value
|
|
27
|
+
.replace(/[_-]+/g, " ")
|
|
28
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`)
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function quoteIdentifier(value: string) {
|
|
36
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function tableVariableName(tableName: string) {
|
|
40
|
+
return `table${toPascalCase(tableName)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function fieldAutoId(tableName: string, fieldName: string) {
|
|
44
|
+
return `fld_${toSnakeCase(tableName)}_${toSnakeCase(fieldName)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function tableAutoId(tableName: string) {
|
|
48
|
+
return `tbl_${toSnakeCase(tableName)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function defaultStorageForLogical(logical: LogicalTypeSpec, column: string): StorageSpec {
|
|
52
|
+
switch (logical.kind) {
|
|
53
|
+
case "id":
|
|
54
|
+
case "string":
|
|
55
|
+
case "text":
|
|
56
|
+
case "enum":
|
|
57
|
+
case "json":
|
|
58
|
+
return { strategy: "sqlite.text", column };
|
|
59
|
+
case "boolean":
|
|
60
|
+
case "integer":
|
|
61
|
+
return { strategy: "sqlite.integer", column };
|
|
62
|
+
case "number":
|
|
63
|
+
return { strategy: "sqlite.real", column };
|
|
64
|
+
case "temporal.instant":
|
|
65
|
+
return { strategy: "sqlite.temporalInstantEpochMs", column };
|
|
66
|
+
case "temporal.plainDate":
|
|
67
|
+
return { strategy: "sqlite.temporalPlainDateText", column };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateLogicalAndStorageCompatibility(
|
|
72
|
+
logical: LogicalTypeSpec,
|
|
73
|
+
storage: StorageSpec,
|
|
74
|
+
): string | null {
|
|
75
|
+
switch (storage.strategy) {
|
|
76
|
+
case "sqlite.temporalInstantEpochMs":
|
|
77
|
+
return logical.kind === "temporal.instant"
|
|
78
|
+
? null
|
|
79
|
+
: "sqlite.temporalInstantEpochMs storage requires logical kind temporal.instant";
|
|
80
|
+
case "sqlite.temporalPlainDateText":
|
|
81
|
+
return logical.kind === "temporal.plainDate"
|
|
82
|
+
? null
|
|
83
|
+
: "sqlite.temporalPlainDateText storage requires logical kind temporal.plainDate";
|
|
84
|
+
case "sqlite.integer":
|
|
85
|
+
return logical.kind === "boolean" || logical.kind === "integer"
|
|
86
|
+
? null
|
|
87
|
+
: "sqlite.integer storage requires logical kind boolean or integer";
|
|
88
|
+
case "sqlite.real":
|
|
89
|
+
return logical.kind === "number" ? null : "sqlite.real storage requires logical kind number";
|
|
90
|
+
case "sqlite.text":
|
|
91
|
+
return logical.kind === "id" ||
|
|
92
|
+
logical.kind === "string" ||
|
|
93
|
+
logical.kind === "text" ||
|
|
94
|
+
logical.kind === "enum" ||
|
|
95
|
+
logical.kind === "json"
|
|
96
|
+
? null
|
|
97
|
+
: "sqlite.text storage requires logical kind id, string, text, enum, or json";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function validateDefaultCompatibility(
|
|
102
|
+
field: FieldSpec,
|
|
103
|
+
defaultValue: DefaultSpec,
|
|
104
|
+
): string | null {
|
|
105
|
+
if (defaultValue.kind === "generatedId") {
|
|
106
|
+
return field.logical.kind === "id" ? null : "generatedId default requires logical kind id";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (defaultValue.kind === "now") {
|
|
110
|
+
return field.logical.kind === "temporal.instant"
|
|
111
|
+
? null
|
|
112
|
+
: "now default requires logical kind temporal.instant";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (field.logical.kind === "boolean" && typeof defaultValue.value !== "boolean") {
|
|
116
|
+
return "boolean fields require boolean literal defaults";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (field.logical.kind === "integer" && !Number.isInteger(defaultValue.value)) {
|
|
120
|
+
return "integer fields require integer literal defaults";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (field.logical.kind === "number" && typeof defaultValue.value !== "number") {
|
|
124
|
+
return "number fields require numeric literal defaults";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function validateSchemaDocumentCompatibility(
|
|
131
|
+
schema: DatabaseSchemaDocument,
|
|
132
|
+
): SchemaValidationIssue[] {
|
|
133
|
+
const issues: SchemaValidationIssue[] = [];
|
|
134
|
+
const tableNames = new Set<string>();
|
|
135
|
+
|
|
136
|
+
for (const table of schema.tables) {
|
|
137
|
+
if (tableNames.has(table.name)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
path: `tables.${table.name}`,
|
|
140
|
+
message: `Duplicate table name ${table.name}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
tableNames.add(table.name);
|
|
144
|
+
|
|
145
|
+
const fieldNames = new Set<string>();
|
|
146
|
+
let primaryKeyCount = 0;
|
|
147
|
+
|
|
148
|
+
for (const field of table.fields) {
|
|
149
|
+
if (fieldNames.has(field.name)) {
|
|
150
|
+
issues.push({
|
|
151
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
152
|
+
message: `Duplicate field name ${field.name}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
fieldNames.add(field.name);
|
|
156
|
+
|
|
157
|
+
if (field.primaryKey) primaryKeyCount += 1;
|
|
158
|
+
if (field.primaryKey && field.nullable) {
|
|
159
|
+
issues.push({
|
|
160
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
161
|
+
message: "Primary key fields cannot be nullable",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const storageIssue = validateLogicalAndStorageCompatibility(field.logical, field.storage);
|
|
166
|
+
if (storageIssue) {
|
|
167
|
+
issues.push({
|
|
168
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
169
|
+
message: storageIssue,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (field.default) {
|
|
174
|
+
const defaultIssue = validateDefaultCompatibility(field, field.default);
|
|
175
|
+
if (defaultIssue) {
|
|
176
|
+
issues.push({
|
|
177
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
178
|
+
message: defaultIssue,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (field.references && !tableNames.has(field.references.table)) {
|
|
184
|
+
const targetTableExists = schema.tables.some(
|
|
185
|
+
(candidate) => candidate.name === field.references!.table,
|
|
186
|
+
);
|
|
187
|
+
if (!targetTableExists) {
|
|
188
|
+
issues.push({
|
|
189
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
190
|
+
message: `Referenced table ${field.references.table} does not exist`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (field.references) {
|
|
196
|
+
const targetTable = schema.tables.find(
|
|
197
|
+
(candidate) => candidate.name === field.references!.table,
|
|
198
|
+
);
|
|
199
|
+
const targetFieldExists = targetTable?.fields.some(
|
|
200
|
+
(candidate) => candidate.name === field.references!.field,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (targetTable && !targetFieldExists) {
|
|
204
|
+
issues.push({
|
|
205
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
206
|
+
message: `Referenced field ${field.references.field} does not exist on table ${field.references.table}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (primaryKeyCount > 1) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path: `tables.${table.name}`,
|
|
215
|
+
message: "Composite primary keys are not supported in v1",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const index of table.indexes) {
|
|
220
|
+
for (const fieldName of index.fields) {
|
|
221
|
+
if (!fieldNames.has(fieldName)) {
|
|
222
|
+
issues.push({
|
|
223
|
+
path: `tables.${table.name}.indexes.${index.name ?? fieldName}`,
|
|
224
|
+
message: `Index references missing field ${fieldName}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const unique of table.uniques) {
|
|
231
|
+
for (const fieldName of unique.fields) {
|
|
232
|
+
if (!fieldNames.has(fieldName)) {
|
|
233
|
+
issues.push({
|
|
234
|
+
path: `tables.${table.name}.uniques.${unique.name ?? fieldName}`,
|
|
235
|
+
message: `Unique constraint references missing field ${fieldName}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return issues;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function stableStringify(value: unknown): string {
|
|
246
|
+
return JSON.stringify(sortValue(value));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sortValue(value: unknown): unknown {
|
|
250
|
+
if (Array.isArray(value)) {
|
|
251
|
+
return value.map((entry) => sortValue(entry));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (value && typeof value === "object") {
|
|
255
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
256
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
257
|
+
.map(([key, innerValue]) => [key, sortValue(innerValue)]);
|
|
258
|
+
return Object.fromEntries(entries);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createSchemaHash(schema: DatabaseSchemaDocument) {
|
|
265
|
+
const text = stableStringify(schema);
|
|
266
|
+
let hash = 2166136261;
|
|
267
|
+
|
|
268
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
269
|
+
hash ^= text.charCodeAt(index);
|
|
270
|
+
hash = Math.imul(hash, 16777619);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return `schema_${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function cloneSchema<T>(value: T): T {
|
|
277
|
+
return structuredClone(value);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function renameStorageColumn(field: FieldSpec, newFieldName: string): FieldSpec {
|
|
281
|
+
const next = cloneSchema(field);
|
|
282
|
+
const column = toSnakeCase(newFieldName);
|
|
283
|
+
next.name = newFieldName;
|
|
284
|
+
next.storage = { ...next.storage, column };
|
|
285
|
+
return next;
|
|
286
|
+
}
|