@omg-dev/schema 0.4.24
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/dist/index.mjs +288 -0
- package/package.json +29 -0
- package/src/index.ts +443 -0
- package/src/test/schema.test.ts +252 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
var CollectionBuilder = class {
|
|
3
|
+
config;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
this.config = {
|
|
6
|
+
fields: opts.fields,
|
|
7
|
+
scope: "user",
|
|
8
|
+
indexes: [],
|
|
9
|
+
uniqueIndexes: []
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
scoped(scope) {
|
|
13
|
+
this.config.scope = scope;
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
index(...columns) {
|
|
17
|
+
this.config.indexes.push(columns);
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
/** Enforce that one row owns each value (or tuple) of these columns. */
|
|
21
|
+
unique(...columns) {
|
|
22
|
+
this.config.uniqueIndexes.push(columns);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
build() {
|
|
26
|
+
return this.config;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const fields = {
|
|
30
|
+
string() {
|
|
31
|
+
return { type: "string" };
|
|
32
|
+
},
|
|
33
|
+
number() {
|
|
34
|
+
return { type: "number" };
|
|
35
|
+
},
|
|
36
|
+
boolean() {
|
|
37
|
+
return { type: "boolean" };
|
|
38
|
+
},
|
|
39
|
+
date() {
|
|
40
|
+
return { type: "date" };
|
|
41
|
+
},
|
|
42
|
+
array(itemType) {
|
|
43
|
+
return { type: { array: itemType } };
|
|
44
|
+
},
|
|
45
|
+
enum(values) {
|
|
46
|
+
return { type: { enum: values } };
|
|
47
|
+
},
|
|
48
|
+
ref(collectionName) {
|
|
49
|
+
return { type: { ref: collectionName } };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
function collection(opts) {
|
|
53
|
+
return new CollectionBuilder(opts);
|
|
54
|
+
}
|
|
55
|
+
function defineSchema(config) {
|
|
56
|
+
const resolved = {};
|
|
57
|
+
for (const [name, col] of Object.entries(config.collections)) resolved[name] = col instanceof CollectionBuilder ? col.build() : col;
|
|
58
|
+
return { collections: resolved };
|
|
59
|
+
}
|
|
60
|
+
function fieldTypeToSQL(type) {
|
|
61
|
+
if (type === "string") return "TEXT";
|
|
62
|
+
if (type === "number") return "REAL";
|
|
63
|
+
if (type === "boolean") return "INTEGER";
|
|
64
|
+
if (type === "date") return "TEXT";
|
|
65
|
+
if (typeof type === "object") {
|
|
66
|
+
if ("array" in type) return "TEXT";
|
|
67
|
+
if ("enum" in type) return "TEXT";
|
|
68
|
+
if ("ref" in type) return "TEXT";
|
|
69
|
+
}
|
|
70
|
+
return "TEXT";
|
|
71
|
+
}
|
|
72
|
+
function schemaToSQL(schema) {
|
|
73
|
+
const statements = [];
|
|
74
|
+
for (const [tableName, col] of Object.entries(schema.collections)) {
|
|
75
|
+
const cols = [
|
|
76
|
+
"id TEXT PRIMARY KEY",
|
|
77
|
+
"created_at TEXT",
|
|
78
|
+
"updated_at TEXT"
|
|
79
|
+
];
|
|
80
|
+
if (col.scope === "user") cols.push("_owner TEXT");
|
|
81
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) cols.push(`${fieldName} ${fieldTypeToSQL(fieldDef.type)}`);
|
|
82
|
+
statements.push(`CREATE TABLE IF NOT EXISTS ${tableName} (\n ${cols.join(",\n ")}\n);`);
|
|
83
|
+
for (const indexCols of col.indexes) {
|
|
84
|
+
const indexName = `idx_${tableName}_${indexCols.join("_")}`;
|
|
85
|
+
statements.push(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${indexCols.join(", ")});`);
|
|
86
|
+
}
|
|
87
|
+
for (const indexCols of col.uniqueIndexes) {
|
|
88
|
+
const indexName = `uidx_${tableName}_${indexCols.join("_")}`;
|
|
89
|
+
statements.push(`CREATE UNIQUE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${indexCols.join(", ")});`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return statements;
|
|
93
|
+
}
|
|
94
|
+
function fieldTypeToZod(type) {
|
|
95
|
+
if (type === "string") return "z.string()";
|
|
96
|
+
if (type === "number") return "z.number()";
|
|
97
|
+
if (type === "boolean") return "z.boolean()";
|
|
98
|
+
if (type === "date") return "z.string()";
|
|
99
|
+
if (typeof type === "object") {
|
|
100
|
+
if ("array" in type) return `z.array(z.${type.array === "string" ? "string" : "unknown"}())`;
|
|
101
|
+
if ("enum" in type) return `z.enum([${type.enum.map((v) => `"${v}"`).join(", ")}])`;
|
|
102
|
+
if ("ref" in type) return "z.string()";
|
|
103
|
+
}
|
|
104
|
+
return "z.unknown()";
|
|
105
|
+
}
|
|
106
|
+
function schemaToZod(schema) {
|
|
107
|
+
const lines = [`import { z } from "zod"`, ""];
|
|
108
|
+
for (const [name, col] of Object.entries(schema.collections)) {
|
|
109
|
+
const schemaName = `${name}Schema`;
|
|
110
|
+
const fieldLines = [
|
|
111
|
+
` id: z.string()`,
|
|
112
|
+
` created_at: z.string()`,
|
|
113
|
+
` updated_at: z.string()`
|
|
114
|
+
];
|
|
115
|
+
if (col.scope === "user") fieldLines.push(` _owner: z.string()`);
|
|
116
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) fieldLines.push(` ${fieldName}: ${fieldTypeToZod(fieldDef.type)}`);
|
|
117
|
+
lines.push(`export const ${schemaName} = z.object({`);
|
|
118
|
+
lines.push(...fieldLines.map((l) => l + ","));
|
|
119
|
+
lines.push(`})`);
|
|
120
|
+
lines.push(`export type ${capitalize(name)} = z.infer<typeof ${schemaName}>`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function fieldTypeToTS(type) {
|
|
126
|
+
if (type === "string") return "string";
|
|
127
|
+
if (type === "number") return "number";
|
|
128
|
+
if (type === "boolean") return "boolean";
|
|
129
|
+
if (type === "date") return "string";
|
|
130
|
+
if (typeof type === "object") {
|
|
131
|
+
if ("array" in type) return `${type.array}[]`;
|
|
132
|
+
if ("enum" in type) return type.enum.map((v) => `"${v}"`).join(" | ");
|
|
133
|
+
if ("ref" in type) return "string";
|
|
134
|
+
}
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
function schemaToTypes(schema) {
|
|
138
|
+
const lines = [];
|
|
139
|
+
for (const [name, col] of Object.entries(schema.collections)) {
|
|
140
|
+
lines.push(`export interface ${capitalize(name)} {`);
|
|
141
|
+
lines.push(` id: string`);
|
|
142
|
+
lines.push(` created_at: string`);
|
|
143
|
+
lines.push(` updated_at: string`);
|
|
144
|
+
if (col.scope === "user") lines.push(` _owner: string`);
|
|
145
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) {
|
|
146
|
+
const opt = fieldDef.optional ? "?" : "";
|
|
147
|
+
lines.push(` ${fieldName}${opt}: ${fieldTypeToTS(fieldDef.type)}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(`}`);
|
|
150
|
+
lines.push("");
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
function schemaToCollections(schema) {
|
|
155
|
+
const lines = [`import { vibesCollectionOptions } from "@omg-dev/db-collection"`, ""];
|
|
156
|
+
for (const [name] of Object.entries(schema.collections)) {
|
|
157
|
+
const varName = `${name}Collection`;
|
|
158
|
+
lines.push(`export const ${varName} = vibesCollectionOptions({`);
|
|
159
|
+
lines.push(` id: "${name}",`);
|
|
160
|
+
lines.push(` schema: undefined,`);
|
|
161
|
+
lines.push(` api: "/api/${name}",`);
|
|
162
|
+
lines.push(` ws: "ws://localhost:3000/ws",`);
|
|
163
|
+
lines.push(`})`);
|
|
164
|
+
lines.push("");
|
|
165
|
+
}
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
function fieldTypeToDrizzle(type) {
|
|
169
|
+
if (type === "number") return "sqlite.real";
|
|
170
|
+
if (type === "boolean") return "sqlite.integer";
|
|
171
|
+
return "sqlite.text";
|
|
172
|
+
}
|
|
173
|
+
function drizzleColumn(name, field) {
|
|
174
|
+
const builder = fieldTypeToDrizzle(field.type);
|
|
175
|
+
if (builder === "sqlite.integer" && field.type === "boolean") return `sqlite.integer(${JSON.stringify(name)}, { mode: "boolean" })`;
|
|
176
|
+
return `${builder}(${JSON.stringify(name)})`;
|
|
177
|
+
}
|
|
178
|
+
function toIdentifier(name, fallbackPrefix) {
|
|
179
|
+
const normalized = name.replace(/^[^a-zA-Z_$]+/, "").replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
180
|
+
if (!normalized) return fallbackPrefix;
|
|
181
|
+
if (/^(?:break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|function|if|import|in|instanceof|new|return|super|switch|this|throw|try|typeof|var|void|while|with|yield|let|static|enum|await|implements|package|protected|interface|private|public)$/.test(normalized)) return `${normalized}Table`;
|
|
182
|
+
return normalized;
|
|
183
|
+
}
|
|
184
|
+
function schemaToDrizzle(schema) {
|
|
185
|
+
const lines = [];
|
|
186
|
+
const collections = Object.entries(schema.collections);
|
|
187
|
+
if (collections.length > 0) {
|
|
188
|
+
lines.push(`import * as sqlite from "drizzle-orm/sqlite-core"`);
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
const tableEntries = [];
|
|
192
|
+
const used = /* @__PURE__ */ new Set();
|
|
193
|
+
for (const [tableName, col] of collections) {
|
|
194
|
+
const baseIdentifier = toIdentifier(tableName, "table");
|
|
195
|
+
let identifier = baseIdentifier;
|
|
196
|
+
let suffix = 2;
|
|
197
|
+
while (used.has(identifier)) {
|
|
198
|
+
identifier = `${baseIdentifier}${suffix}`;
|
|
199
|
+
suffix += 1;
|
|
200
|
+
}
|
|
201
|
+
used.add(identifier);
|
|
202
|
+
tableEntries.push({
|
|
203
|
+
key: tableName,
|
|
204
|
+
identifier
|
|
205
|
+
});
|
|
206
|
+
lines.push(`export const ${identifier} = sqlite.sqliteTable(${JSON.stringify(tableName)}, {`);
|
|
207
|
+
lines.push(` id: sqlite.text("id").primaryKey(),`);
|
|
208
|
+
lines.push(` created_at: sqlite.text("created_at"),`);
|
|
209
|
+
lines.push(` updated_at: sqlite.text("updated_at"),`);
|
|
210
|
+
if (col.scope === "user") lines.push(` _owner: sqlite.text("_owner"),`);
|
|
211
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) lines.push(` ${JSON.stringify(fieldName)}: ${drizzleColumn(fieldName, fieldDef)},`);
|
|
212
|
+
lines.push(`})`);
|
|
213
|
+
lines.push("");
|
|
214
|
+
}
|
|
215
|
+
lines.push(`export const drizzleSchema = {`);
|
|
216
|
+
for (const { key, identifier } of tableEntries) lines.push(` ${JSON.stringify(key)}: ${identifier},`);
|
|
217
|
+
lines.push(`}`);
|
|
218
|
+
lines.push("");
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
function schemaDiff(oldSchema, newSchema) {
|
|
222
|
+
const migrations = [];
|
|
223
|
+
const oldTables = new Set(Object.keys(oldSchema.collections));
|
|
224
|
+
const newTables = new Set(Object.keys(newSchema.collections));
|
|
225
|
+
for (const table of oldTables) if (!newTables.has(table)) migrations.push({
|
|
226
|
+
type: "drop_table",
|
|
227
|
+
table
|
|
228
|
+
});
|
|
229
|
+
for (const table of newTables) if (!oldTables.has(table)) {
|
|
230
|
+
migrations.push({
|
|
231
|
+
type: "create_table",
|
|
232
|
+
table,
|
|
233
|
+
fields: newSchema.collections[table].fields
|
|
234
|
+
});
|
|
235
|
+
for (const indexCols of newSchema.collections[table].indexes) migrations.push({
|
|
236
|
+
type: "add_index",
|
|
237
|
+
table,
|
|
238
|
+
columns: indexCols
|
|
239
|
+
});
|
|
240
|
+
for (const indexCols of newSchema.collections[table].uniqueIndexes) migrations.push({
|
|
241
|
+
type: "add_unique_index",
|
|
242
|
+
table,
|
|
243
|
+
columns: indexCols
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
for (const table of oldTables) {
|
|
247
|
+
if (!newTables.has(table)) continue;
|
|
248
|
+
const oldFields = oldSchema.collections[table].fields;
|
|
249
|
+
const newFields = newSchema.collections[table].fields;
|
|
250
|
+
const oldCols = new Set(Object.keys(oldFields));
|
|
251
|
+
const newCols = new Set(Object.keys(newFields));
|
|
252
|
+
for (const col of oldCols) if (!newCols.has(col)) migrations.push({
|
|
253
|
+
type: "drop_column",
|
|
254
|
+
table,
|
|
255
|
+
column: col
|
|
256
|
+
});
|
|
257
|
+
for (const col of newCols) if (!oldCols.has(col)) migrations.push({
|
|
258
|
+
type: "add_column",
|
|
259
|
+
table,
|
|
260
|
+
column: col,
|
|
261
|
+
field: newFields[col]
|
|
262
|
+
});
|
|
263
|
+
const oldIndexKeys = new Set(oldSchema.collections[table].indexes.map((idx) => idx.join(",")));
|
|
264
|
+
for (const indexCols of newSchema.collections[table].indexes) {
|
|
265
|
+
const key = indexCols.join(",");
|
|
266
|
+
if (!oldIndexKeys.has(key)) migrations.push({
|
|
267
|
+
type: "add_index",
|
|
268
|
+
table,
|
|
269
|
+
columns: indexCols
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const oldUniqueIndexKeys = new Set(oldSchema.collections[table].uniqueIndexes.map((idx) => idx.join(",")));
|
|
273
|
+
for (const indexCols of newSchema.collections[table].uniqueIndexes) {
|
|
274
|
+
const key = indexCols.join(",");
|
|
275
|
+
if (!oldUniqueIndexKeys.has(key)) migrations.push({
|
|
276
|
+
type: "add_unique_index",
|
|
277
|
+
table,
|
|
278
|
+
columns: indexCols
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return migrations;
|
|
283
|
+
}
|
|
284
|
+
function capitalize(s) {
|
|
285
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
export { CollectionBuilder, collection, defineSchema, fields, schemaDiff, schemaToCollections, schemaToDrizzle, schemaToSQL, schemaToTypes, schemaToZod };
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omg-dev/schema",
|
|
3
|
+
"version": "0.4.24",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./dist/index.mjs"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "vp test run",
|
|
13
|
+
"build": "vp pack src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/BennyKok/vibes.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://docs.omg.dev",
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// @omg-dev/schema — pure TypeScript, no side effects
|
|
2
|
+
|
|
3
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type FieldType =
|
|
6
|
+
| "string"
|
|
7
|
+
| "number"
|
|
8
|
+
| "boolean"
|
|
9
|
+
| "date"
|
|
10
|
+
| { array: string }
|
|
11
|
+
| { enum: string[] }
|
|
12
|
+
| { ref: string }
|
|
13
|
+
|
|
14
|
+
export interface FieldDef {
|
|
15
|
+
type: FieldType
|
|
16
|
+
optional?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CollectionConfig {
|
|
20
|
+
fields: Record<string, FieldDef>
|
|
21
|
+
scope: "user" | "global"
|
|
22
|
+
indexes: string[][]
|
|
23
|
+
uniqueIndexes: string[][]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Schema {
|
|
27
|
+
collections: Record<string, CollectionConfig>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Migration =
|
|
31
|
+
| { type: "create_table"; table: string; fields: Record<string, FieldDef> }
|
|
32
|
+
| { type: "drop_table"; table: string }
|
|
33
|
+
| { type: "add_column"; table: string; column: string; field: FieldDef }
|
|
34
|
+
| { type: "drop_column"; table: string; column: string }
|
|
35
|
+
| { type: "add_index"; table: string; columns: string[] }
|
|
36
|
+
| { type: "add_unique_index"; table: string; columns: string[] }
|
|
37
|
+
|
|
38
|
+
// ── CollectionBuilder ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export class CollectionBuilder {
|
|
41
|
+
private config: CollectionConfig
|
|
42
|
+
|
|
43
|
+
constructor(opts: { fields: Record<string, FieldDef> }) {
|
|
44
|
+
this.config = {
|
|
45
|
+
// Private by default: an unmarked collection is `scope:"user"`, so the
|
|
46
|
+
// generic surfaces (auto-CRUD + the WS live-query layer) filter every
|
|
47
|
+
// read/write by the `_owner` column and reject anonymous access. To make
|
|
48
|
+
// a collection world-readable/shared you must OPT IN explicitly with
|
|
49
|
+
// `.scoped("global")`. This is a deliberate secure-by-default posture:
|
|
50
|
+
// forgetting to scope a collection fails closed (private), never open.
|
|
51
|
+
fields: opts.fields,
|
|
52
|
+
scope: "user",
|
|
53
|
+
indexes: [],
|
|
54
|
+
uniqueIndexes: [],
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `.scoped("user")` is the default and rarely needs stating; call
|
|
59
|
+
// `.scoped("global")` to make a collection public (no `_owner`, world-
|
|
60
|
+
// readable on auto-CRUD + WS).
|
|
61
|
+
scoped(scope: "user" | "global"): this {
|
|
62
|
+
this.config.scope = scope
|
|
63
|
+
return this
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
index(...columns: string[]): this {
|
|
67
|
+
this.config.indexes.push(columns)
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Enforce that one row owns each value (or tuple) of these columns. */
|
|
72
|
+
unique(...columns: string[]): this {
|
|
73
|
+
this.config.uniqueIndexes.push(columns)
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
build(): CollectionConfig {
|
|
78
|
+
return this.config
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Field builders ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export const fields = {
|
|
85
|
+
string(): FieldDef { return { type: "string" } },
|
|
86
|
+
number(): FieldDef { return { type: "number" } },
|
|
87
|
+
boolean(): FieldDef { return { type: "boolean" } },
|
|
88
|
+
date(): FieldDef { return { type: "date" } },
|
|
89
|
+
array(itemType: string): FieldDef { return { type: { array: itemType } } },
|
|
90
|
+
enum(values: string[]): FieldDef { return { type: { enum: values } } },
|
|
91
|
+
ref(collectionName: string): FieldDef { return { type: { ref: collectionName } } },
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── defineSchema ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function collection(opts: { fields: Record<string, FieldDef> }): CollectionBuilder {
|
|
97
|
+
return new CollectionBuilder(opts)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function defineSchema(config: {
|
|
101
|
+
collections: Record<string, CollectionBuilder | CollectionConfig>
|
|
102
|
+
}): Schema {
|
|
103
|
+
const resolved: Record<string, CollectionConfig> = {}
|
|
104
|
+
for (const [name, col] of Object.entries(config.collections)) {
|
|
105
|
+
resolved[name] = col instanceof CollectionBuilder ? col.build() : col
|
|
106
|
+
}
|
|
107
|
+
return { collections: resolved }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── SQL type mapping ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function fieldTypeToSQL(type: FieldType): string {
|
|
113
|
+
if (type === "string") return "TEXT"
|
|
114
|
+
if (type === "number") return "REAL"
|
|
115
|
+
if (type === "boolean") return "INTEGER"
|
|
116
|
+
if (type === "date") return "TEXT"
|
|
117
|
+
if (typeof type === "object") {
|
|
118
|
+
if ("array" in type) return "TEXT" // JSON serialized
|
|
119
|
+
if ("enum" in type) return "TEXT"
|
|
120
|
+
if ("ref" in type) return "TEXT"
|
|
121
|
+
}
|
|
122
|
+
return "TEXT"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── schemaToSQL ───────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export function schemaToSQL(schema: Schema): string[] {
|
|
128
|
+
const statements: string[] = []
|
|
129
|
+
|
|
130
|
+
for (const [tableName, col] of Object.entries(schema.collections)) {
|
|
131
|
+
const cols: string[] = [
|
|
132
|
+
"id TEXT PRIMARY KEY",
|
|
133
|
+
"created_at TEXT",
|
|
134
|
+
"updated_at TEXT",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
if (col.scope === "user") {
|
|
138
|
+
cols.push("_owner TEXT")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) {
|
|
142
|
+
cols.push(`${fieldName} ${fieldTypeToSQL(fieldDef.type)}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
statements.push(
|
|
146
|
+
`CREATE TABLE IF NOT EXISTS ${tableName} (\n ${cols.join(",\n ")}\n);`
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// Add indexes
|
|
150
|
+
for (const indexCols of col.indexes) {
|
|
151
|
+
const indexName = `idx_${tableName}_${indexCols.join("_")}`
|
|
152
|
+
statements.push(
|
|
153
|
+
`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${indexCols.join(", ")});`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
for (const indexCols of col.uniqueIndexes) {
|
|
157
|
+
const indexName = `uidx_${tableName}_${indexCols.join("_")}`
|
|
158
|
+
statements.push(
|
|
159
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${indexCols.join(", ")});`
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return statements
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Zod type mapping ──────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function fieldTypeToZod(type: FieldType): string {
|
|
170
|
+
if (type === "string") return "z.string()"
|
|
171
|
+
if (type === "number") return "z.number()"
|
|
172
|
+
if (type === "boolean") return "z.boolean()"
|
|
173
|
+
if (type === "date") return "z.string()" // ISO date string
|
|
174
|
+
if (typeof type === "object") {
|
|
175
|
+
if ("array" in type) return `z.array(z.${type.array === "string" ? "string" : "unknown"}())`
|
|
176
|
+
if ("enum" in type) return `z.enum([${type.enum.map(v => `"${v}"`).join(", ")}])`
|
|
177
|
+
if ("ref" in type) return "z.string()" // foreign key
|
|
178
|
+
}
|
|
179
|
+
return "z.unknown()"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── schemaToZod ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export function schemaToZod(schema: Schema): string {
|
|
185
|
+
const lines: string[] = [
|
|
186
|
+
`import { z } from "zod"`,
|
|
187
|
+
"",
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
for (const [name, col] of Object.entries(schema.collections)) {
|
|
191
|
+
const schemaName = `${name}Schema`
|
|
192
|
+
const fieldLines: string[] = [
|
|
193
|
+
` id: z.string()`,
|
|
194
|
+
` created_at: z.string()`,
|
|
195
|
+
` updated_at: z.string()`,
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
if (col.scope === "user") {
|
|
199
|
+
fieldLines.push(` _owner: z.string()`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) {
|
|
203
|
+
fieldLines.push(` ${fieldName}: ${fieldTypeToZod(fieldDef.type)}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
lines.push(`export const ${schemaName} = z.object({`)
|
|
207
|
+
lines.push(...fieldLines.map(l => l + ","))
|
|
208
|
+
lines.push(`})`)
|
|
209
|
+
lines.push(`export type ${capitalize(name)} = z.infer<typeof ${schemaName}>`)
|
|
210
|
+
lines.push("")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join("\n")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── schemaToTypes ─────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
function fieldTypeToTS(type: FieldType): string {
|
|
219
|
+
if (type === "string") return "string"
|
|
220
|
+
if (type === "number") return "number"
|
|
221
|
+
if (type === "boolean") return "boolean"
|
|
222
|
+
if (type === "date") return "string"
|
|
223
|
+
if (typeof type === "object") {
|
|
224
|
+
if ("array" in type) return `${type.array}[]`
|
|
225
|
+
if ("enum" in type) return type.enum.map(v => `"${v}"`).join(" | ")
|
|
226
|
+
if ("ref" in type) return "string"
|
|
227
|
+
}
|
|
228
|
+
return "unknown"
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function schemaToTypes(schema: Schema): string {
|
|
232
|
+
const lines: string[] = []
|
|
233
|
+
|
|
234
|
+
for (const [name, col] of Object.entries(schema.collections)) {
|
|
235
|
+
lines.push(`export interface ${capitalize(name)} {`)
|
|
236
|
+
lines.push(` id: string`)
|
|
237
|
+
lines.push(` created_at: string`)
|
|
238
|
+
lines.push(` updated_at: string`)
|
|
239
|
+
|
|
240
|
+
if (col.scope === "user") {
|
|
241
|
+
lines.push(` _owner: string`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) {
|
|
245
|
+
const opt = fieldDef.optional ? "?" : ""
|
|
246
|
+
lines.push(` ${fieldName}${opt}: ${fieldTypeToTS(fieldDef.type)}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
lines.push(`}`)
|
|
250
|
+
lines.push("")
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return lines.join("\n")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── schemaToCollections ───────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export function schemaToCollections(schema: Schema): string {
|
|
259
|
+
const lines: string[] = [
|
|
260
|
+
`import { vibesCollectionOptions } from "@omg-dev/db-collection"`,
|
|
261
|
+
"",
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
for (const [name] of Object.entries(schema.collections)) {
|
|
265
|
+
const varName = `${name}Collection`
|
|
266
|
+
lines.push(`export const ${varName} = vibesCollectionOptions({`)
|
|
267
|
+
lines.push(` id: "${name}",`)
|
|
268
|
+
lines.push(` schema: undefined,`)
|
|
269
|
+
lines.push(` api: "/api/${name}",`)
|
|
270
|
+
lines.push(` ws: "ws://localhost:3000/ws",`)
|
|
271
|
+
lines.push(`})`)
|
|
272
|
+
lines.push("")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return lines.join("\n")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── schemaToDrizzle ──────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function fieldTypeToDrizzle(type: FieldType): string {
|
|
281
|
+
if (type === "number") return "sqlite.real"
|
|
282
|
+
if (type === "boolean") return "sqlite.integer"
|
|
283
|
+
return "sqlite.text"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function drizzleColumn(name: string, field: FieldDef): string {
|
|
287
|
+
const builder = fieldTypeToDrizzle(field.type)
|
|
288
|
+
if (builder === "sqlite.integer" && field.type === "boolean") {
|
|
289
|
+
return `sqlite.integer(${JSON.stringify(name)}, { mode: "boolean" })`
|
|
290
|
+
}
|
|
291
|
+
return `${builder}(${JSON.stringify(name)})`
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function toIdentifier(name: string, fallbackPrefix: string): string {
|
|
295
|
+
const normalized = name
|
|
296
|
+
.replace(/^[^a-zA-Z_$]+/, "")
|
|
297
|
+
.replace(/[^a-zA-Z0-9_$]/g, "_")
|
|
298
|
+
|
|
299
|
+
if (!normalized) return fallbackPrefix
|
|
300
|
+
if (/^(?:break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|function|if|import|in|instanceof|new|return|super|switch|this|throw|try|typeof|var|void|while|with|yield|let|static|enum|await|implements|package|protected|interface|private|public)$/.test(normalized)) {
|
|
301
|
+
return `${normalized}Table`
|
|
302
|
+
}
|
|
303
|
+
return normalized
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function schemaToDrizzle(schema: Schema): string {
|
|
307
|
+
const lines: string[] = []
|
|
308
|
+
const collections = Object.entries(schema.collections)
|
|
309
|
+
|
|
310
|
+
if (collections.length > 0) {
|
|
311
|
+
lines.push(`import * as sqlite from "drizzle-orm/sqlite-core"`)
|
|
312
|
+
lines.push("")
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const tableEntries: Array<{ key: string; identifier: string }> = []
|
|
316
|
+
const used = new Set<string>()
|
|
317
|
+
|
|
318
|
+
for (const [tableName, col] of collections) {
|
|
319
|
+
const baseIdentifier = toIdentifier(tableName, "table")
|
|
320
|
+
let identifier = baseIdentifier
|
|
321
|
+
let suffix = 2
|
|
322
|
+
while (used.has(identifier)) {
|
|
323
|
+
identifier = `${baseIdentifier}${suffix}`
|
|
324
|
+
suffix += 1
|
|
325
|
+
}
|
|
326
|
+
used.add(identifier)
|
|
327
|
+
tableEntries.push({ key: tableName, identifier })
|
|
328
|
+
|
|
329
|
+
lines.push(`export const ${identifier} = sqlite.sqliteTable(${JSON.stringify(tableName)}, {`)
|
|
330
|
+
lines.push(` id: sqlite.text("id").primaryKey(),`)
|
|
331
|
+
lines.push(` created_at: sqlite.text("created_at"),`)
|
|
332
|
+
lines.push(` updated_at: sqlite.text("updated_at"),`)
|
|
333
|
+
|
|
334
|
+
if (col.scope === "user") {
|
|
335
|
+
lines.push(` _owner: sqlite.text("_owner"),`)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const [fieldName, fieldDef] of Object.entries(col.fields)) {
|
|
339
|
+
lines.push(` ${JSON.stringify(fieldName)}: ${drizzleColumn(fieldName, fieldDef)},`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
lines.push(`})`)
|
|
343
|
+
lines.push("")
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
lines.push(`export const drizzleSchema = {`)
|
|
347
|
+
for (const { key, identifier } of tableEntries) {
|
|
348
|
+
lines.push(` ${JSON.stringify(key)}: ${identifier},`)
|
|
349
|
+
}
|
|
350
|
+
lines.push(`}`)
|
|
351
|
+
lines.push("")
|
|
352
|
+
|
|
353
|
+
return lines.join("\n")
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── schemaDiff ────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
export function schemaDiff(oldSchema: Schema, newSchema: Schema): Migration[] {
|
|
359
|
+
const migrations: Migration[] = []
|
|
360
|
+
|
|
361
|
+
const oldTables = new Set(Object.keys(oldSchema.collections))
|
|
362
|
+
const newTables = new Set(Object.keys(newSchema.collections))
|
|
363
|
+
|
|
364
|
+
// Dropped tables
|
|
365
|
+
for (const table of oldTables) {
|
|
366
|
+
if (!newTables.has(table)) {
|
|
367
|
+
migrations.push({ type: "drop_table", table })
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// New tables
|
|
372
|
+
for (const table of newTables) {
|
|
373
|
+
if (!oldTables.has(table)) {
|
|
374
|
+
migrations.push({
|
|
375
|
+
type: "create_table",
|
|
376
|
+
table,
|
|
377
|
+
fields: newSchema.collections[table].fields,
|
|
378
|
+
})
|
|
379
|
+
// Add indexes for new table
|
|
380
|
+
for (const indexCols of newSchema.collections[table].indexes) {
|
|
381
|
+
migrations.push({ type: "add_index", table, columns: indexCols })
|
|
382
|
+
}
|
|
383
|
+
for (const indexCols of newSchema.collections[table].uniqueIndexes) {
|
|
384
|
+
migrations.push({ type: "add_unique_index", table, columns: indexCols })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Existing tables — column diff
|
|
390
|
+
for (const table of oldTables) {
|
|
391
|
+
if (!newTables.has(table)) continue
|
|
392
|
+
|
|
393
|
+
const oldFields = oldSchema.collections[table].fields
|
|
394
|
+
const newFields = newSchema.collections[table].fields
|
|
395
|
+
|
|
396
|
+
const oldCols = new Set(Object.keys(oldFields))
|
|
397
|
+
const newCols = new Set(Object.keys(newFields))
|
|
398
|
+
|
|
399
|
+
// Dropped columns
|
|
400
|
+
for (const col of oldCols) {
|
|
401
|
+
if (!newCols.has(col)) {
|
|
402
|
+
migrations.push({ type: "drop_column", table, column: col })
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// New columns
|
|
407
|
+
for (const col of newCols) {
|
|
408
|
+
if (!oldCols.has(col)) {
|
|
409
|
+
migrations.push({ type: "add_column", table, column: col, field: newFields[col] })
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// New indexes
|
|
414
|
+
const oldIndexKeys = new Set(
|
|
415
|
+
oldSchema.collections[table].indexes.map(idx => idx.join(","))
|
|
416
|
+
)
|
|
417
|
+
for (const indexCols of newSchema.collections[table].indexes) {
|
|
418
|
+
const key = indexCols.join(",")
|
|
419
|
+
if (!oldIndexKeys.has(key)) {
|
|
420
|
+
migrations.push({ type: "add_index", table, columns: indexCols })
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// New unique indexes
|
|
425
|
+
const oldUniqueIndexKeys = new Set(
|
|
426
|
+
oldSchema.collections[table].uniqueIndexes.map(idx => idx.join(","))
|
|
427
|
+
)
|
|
428
|
+
for (const indexCols of newSchema.collections[table].uniqueIndexes) {
|
|
429
|
+
const key = indexCols.join(",")
|
|
430
|
+
if (!oldUniqueIndexKeys.has(key)) {
|
|
431
|
+
migrations.push({ type: "add_unique_index", table, columns: indexCols })
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return migrations
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
function capitalize(s: string): string {
|
|
442
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
443
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
defineSchema,
|
|
4
|
+
collection,
|
|
5
|
+
fields,
|
|
6
|
+
schemaToSQL,
|
|
7
|
+
schemaToDrizzle,
|
|
8
|
+
schemaDiff,
|
|
9
|
+
type Schema,
|
|
10
|
+
} from "../index.ts"
|
|
11
|
+
|
|
12
|
+
// ── Fixture schema ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const fixtureSchema = defineSchema({
|
|
15
|
+
collections: {
|
|
16
|
+
entries: collection({
|
|
17
|
+
fields: {
|
|
18
|
+
title: fields.string(),
|
|
19
|
+
body: fields.string(),
|
|
20
|
+
mood: fields.enum(["happy", "neutral", "sad"]),
|
|
21
|
+
tagIds: fields.array("string"),
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
.scoped("user")
|
|
25
|
+
.index("created_at"),
|
|
26
|
+
|
|
27
|
+
tags: collection({
|
|
28
|
+
fields: {
|
|
29
|
+
name: fields.string(),
|
|
30
|
+
color: fields.string(),
|
|
31
|
+
},
|
|
32
|
+
}).scoped("user"),
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// ── schemaToSQL tests ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe("schemaToSQL", () => {
|
|
39
|
+
it("generates CREATE TABLE for entries with _owner and index", () => {
|
|
40
|
+
const sqls = schemaToSQL(fixtureSchema)
|
|
41
|
+
const entriesSQL = sqls.find(s => s.startsWith("CREATE TABLE IF NOT EXISTS entries"))
|
|
42
|
+
expect(entriesSQL).toBeDefined()
|
|
43
|
+
expect(entriesSQL).toContain("id TEXT PRIMARY KEY")
|
|
44
|
+
expect(entriesSQL).toContain("created_at TEXT")
|
|
45
|
+
expect(entriesSQL).toContain("updated_at TEXT")
|
|
46
|
+
expect(entriesSQL).toContain("_owner TEXT")
|
|
47
|
+
expect(entriesSQL).toContain("title TEXT")
|
|
48
|
+
expect(entriesSQL).toContain("body TEXT")
|
|
49
|
+
expect(entriesSQL).toContain("mood TEXT")
|
|
50
|
+
expect(entriesSQL).toContain("tagIds TEXT")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("generates CREATE INDEX for entries.created_at", () => {
|
|
54
|
+
const sqls = schemaToSQL(fixtureSchema)
|
|
55
|
+
const indexSQL = sqls.find(s => s.includes("idx_entries_created_at"))
|
|
56
|
+
expect(indexSQL).toBeDefined()
|
|
57
|
+
expect(indexSQL).toContain("ON entries (created_at)")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("generates CREATE UNIQUE INDEX for unique columns", () => {
|
|
61
|
+
const schema = defineSchema({
|
|
62
|
+
collections: {
|
|
63
|
+
deliveries: collection({ fields: { eventId: fields.string() } })
|
|
64
|
+
.scoped("global")
|
|
65
|
+
.unique("eventId"),
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
const sqls = schemaToSQL(schema)
|
|
69
|
+
expect(sqls).toContain("CREATE UNIQUE INDEX IF NOT EXISTS uidx_deliveries_eventId ON deliveries (eventId);")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("generates CREATE TABLE for tags with _owner", () => {
|
|
73
|
+
const sqls = schemaToSQL(fixtureSchema)
|
|
74
|
+
const tagsSQL = sqls.find(s => s.startsWith("CREATE TABLE IF NOT EXISTS tags"))
|
|
75
|
+
expect(tagsSQL).toBeDefined()
|
|
76
|
+
expect(tagsSQL).toContain("_owner TEXT")
|
|
77
|
+
expect(tagsSQL).toContain("name TEXT")
|
|
78
|
+
expect(tagsSQL).toContain("color TEXT")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("does not generate extra indexes for tags (no .index() call)", () => {
|
|
82
|
+
const sqls = schemaToSQL(fixtureSchema)
|
|
83
|
+
const tagIndexSQL = sqls.find(s => s.includes("idx_tags_"))
|
|
84
|
+
expect(tagIndexSQL).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("generates correct SQL for global-scoped collection (no _owner)", () => {
|
|
88
|
+
const schema = defineSchema({
|
|
89
|
+
collections: {
|
|
90
|
+
posts: collection({ fields: { content: fields.string() } }).scoped("global"),
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
const sqls = schemaToSQL(schema)
|
|
94
|
+
expect(sqls[0]).not.toContain("_owner")
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ── schemaToDrizzle tests ─────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("schemaToDrizzle", () => {
|
|
101
|
+
it("generates sqlite tables from the same Vibes schema", () => {
|
|
102
|
+
const source = schemaToDrizzle(fixtureSchema)
|
|
103
|
+
expect(source).toContain(`import * as sqlite from "drizzle-orm/sqlite-core"`)
|
|
104
|
+
expect(source).toContain(`export const entries = sqlite.sqliteTable("entries", {`)
|
|
105
|
+
expect(source).toContain(`id: sqlite.text("id").primaryKey()`)
|
|
106
|
+
expect(source).toContain(`created_at: sqlite.text("created_at")`)
|
|
107
|
+
expect(source).toContain(`updated_at: sqlite.text("updated_at")`)
|
|
108
|
+
expect(source).toContain(`_owner: sqlite.text("_owner")`)
|
|
109
|
+
expect(source).toContain(`"title": sqlite.text("title")`)
|
|
110
|
+
expect(source).toContain(`"mood": sqlite.text("mood")`)
|
|
111
|
+
expect(source).toContain(`"tagIds": sqlite.text("tagIds")`)
|
|
112
|
+
expect(source).toContain(`"entries": entries`)
|
|
113
|
+
expect(source).toContain(`"tags": tags`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("maps numbers and booleans to sqlite-aware Drizzle column builders", () => {
|
|
117
|
+
const schema = defineSchema({
|
|
118
|
+
collections: {
|
|
119
|
+
metrics: collection({
|
|
120
|
+
fields: {
|
|
121
|
+
value: fields.number(),
|
|
122
|
+
enabled: fields.boolean(),
|
|
123
|
+
},
|
|
124
|
+
}).scoped("global"),
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const source = schemaToDrizzle(schema)
|
|
129
|
+
expect(source).toContain(`"value": sqlite.real("value")`)
|
|
130
|
+
expect(source).toContain(`"enabled": sqlite.integer("enabled", { mode: "boolean" })`)
|
|
131
|
+
expect(source).not.toContain(`_owner: sqlite.text("_owner")`)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("emits no imports for an empty schema", () => {
|
|
135
|
+
const source = schemaToDrizzle(defineSchema({ collections: {} }))
|
|
136
|
+
expect(source).not.toContain("import")
|
|
137
|
+
expect(source).toContain("export const drizzleSchema = {")
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ── schemaDiff tests ──────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe("schemaDiff", () => {
|
|
144
|
+
it("detects add_column", () => {
|
|
145
|
+
const oldSchema = defineSchema({
|
|
146
|
+
collections: {
|
|
147
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
const newSchema = defineSchema({
|
|
151
|
+
collections: {
|
|
152
|
+
entries: collection({
|
|
153
|
+
fields: { title: fields.string(), body: fields.string() },
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
const diff = schemaDiff(oldSchema, newSchema)
|
|
158
|
+
const addCol = diff.find(m => m.type === "add_column")
|
|
159
|
+
expect(addCol).toBeDefined()
|
|
160
|
+
expect(addCol?.type).toBe("add_column")
|
|
161
|
+
if (addCol?.type === "add_column") {
|
|
162
|
+
expect(addCol.table).toBe("entries")
|
|
163
|
+
expect(addCol.column).toBe("body")
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("detects create_table", () => {
|
|
168
|
+
const oldSchema = defineSchema({
|
|
169
|
+
collections: {
|
|
170
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
const newSchema = defineSchema({
|
|
174
|
+
collections: {
|
|
175
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
176
|
+
tags: collection({ fields: { name: fields.string() } }),
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
const diff = schemaDiff(oldSchema, newSchema)
|
|
180
|
+
const createTable = diff.find(m => m.type === "create_table")
|
|
181
|
+
expect(createTable).toBeDefined()
|
|
182
|
+
if (createTable?.type === "create_table") {
|
|
183
|
+
expect(createTable.table).toBe("tags")
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("detects drop_table", () => {
|
|
188
|
+
const oldSchema = defineSchema({
|
|
189
|
+
collections: {
|
|
190
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
191
|
+
tags: collection({ fields: { name: fields.string() } }),
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
const newSchema = defineSchema({
|
|
195
|
+
collections: {
|
|
196
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
const diff = schemaDiff(oldSchema, newSchema)
|
|
200
|
+
const dropTable = diff.find(m => m.type === "drop_table")
|
|
201
|
+
expect(dropTable).toBeDefined()
|
|
202
|
+
if (dropTable?.type === "drop_table") {
|
|
203
|
+
expect(dropTable.table).toBe("tags")
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("detects drop_column", () => {
|
|
208
|
+
const oldSchema = defineSchema({
|
|
209
|
+
collections: {
|
|
210
|
+
entries: collection({
|
|
211
|
+
fields: { title: fields.string(), body: fields.string() },
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
const newSchema = defineSchema({
|
|
216
|
+
collections: {
|
|
217
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
const diff = schemaDiff(oldSchema, newSchema)
|
|
221
|
+
const dropCol = diff.find(m => m.type === "drop_column")
|
|
222
|
+
expect(dropCol).toBeDefined()
|
|
223
|
+
if (dropCol?.type === "drop_column") {
|
|
224
|
+
expect(dropCol.table).toBe("entries")
|
|
225
|
+
expect(dropCol.column).toBe("body")
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("returns empty diff for identical schemas", () => {
|
|
230
|
+
const schema = defineSchema({
|
|
231
|
+
collections: {
|
|
232
|
+
entries: collection({ fields: { title: fields.string() } }),
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
const diff = schemaDiff(schema, schema)
|
|
236
|
+
expect(diff).toHaveLength(0)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("detects a new unique index", () => {
|
|
240
|
+
const oldSchema = defineSchema({
|
|
241
|
+
collections: { deliveries: collection({ fields: { eventId: fields.string() } }) },
|
|
242
|
+
})
|
|
243
|
+
const newSchema = defineSchema({
|
|
244
|
+
collections: { deliveries: collection({ fields: { eventId: fields.string() } }).unique("eventId") },
|
|
245
|
+
})
|
|
246
|
+
expect(schemaDiff(oldSchema, newSchema)).toContainEqual({
|
|
247
|
+
type: "add_unique_index",
|
|
248
|
+
table: "deliveries",
|
|
249
|
+
columns: ["eventId"],
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
})
|