@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 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
+ })