@antislop/zero-lucid-generator 1.0.0

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 ADDED
@@ -0,0 +1,196 @@
1
+ # @antislop/zero-lucid-generator
2
+
3
+ Generate [Zero](https://zero.rocicorp.dev) schemas directly from your [Lucid ORM](https://lucid.adonisjs.com) models — no manual schema duplication.
4
+
5
+ Inspired by [`drizzle-zero`](https://github.com/rocicorp/drizzle-zero) and [`prisma-zero`](https://github.com/rocicorp/prisma-zero).
6
+
7
+ ---
8
+
9
+ ## How it works
10
+
11
+ `@antislop/zero-lucid-generator` scans your Lucid model files, introspects their column and relation definitions at runtime, infers TypeScript types via [ts-morph](https://ts-morph.com), and writes a typed Zero schema file you can import directly into your app.
12
+
13
+ ---
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ npm add -D @antislop/zero-lucid-generator
19
+ ```
20
+
21
+ `@antislop/zero-lucid-generator` is a dev-time generator. You only need it during development to regenerate the schema when models change.
22
+
23
+ ---
24
+
25
+ ## Setup
26
+
27
+ ### 1. Create a config file
28
+
29
+ Create `lucid-zero.config.ts` at the root of your project:
30
+
31
+ ```ts
32
+ import type { Config } from '@antislop/zero-lucid-generator'
33
+
34
+ const config: Config = {
35
+ modelsSourcePath: './app/models',
36
+ }
37
+
38
+ export default config
39
+ ```
40
+
41
+ ### 2. Run the generator
42
+
43
+ ```sh
44
+ npx lucid-zero generate
45
+ ```
46
+
47
+ This writes `zero-schema.gen.ts` next to your config file.
48
+
49
+ ### 3. Import the schema
50
+
51
+ ```ts
52
+ import { schema, zql } from './zero-schema.gen.js'
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Config options
58
+
59
+ | Option | Type | Default | Description |
60
+ |---|---|---|---|
61
+ | `modelsSourcePath` | `string` | **required** | Path to your models directory, relative to the config file |
62
+ | `output` | `string` | `zero-schema.gen.ts` | Output file path, relative to the config file |
63
+ | `prettier` | `boolean` | `false` | Format the output with Prettier (must be installed) |
64
+ | `excludeModels` | `LucidModel[]` | `[]` | Models to exclude from the generated schema |
65
+ | `columnTypes` | `Record<string, Record<string, string>>` | `{}` | Override the inferred Zero type for specific columns |
66
+
67
+ ---
68
+
69
+ ## CLI options
70
+
71
+ ```
72
+ lucid-zero generate [options]
73
+
74
+ Options:
75
+ -c, --config <path> Path to config file (default: lucid-zero.config.ts)
76
+ -t, --tsconfig <path> Path to tsconfig.json (default: tsconfig.json next to config)
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Column type inference
82
+
83
+ `@antislop/zero-lucid-generator` maps TypeScript types to Zero types automatically:
84
+
85
+ | TypeScript type | Zero type |
86
+ |---|---|
87
+ | `string` | `string()` |
88
+ | `number` | `number()` |
89
+ | `boolean` | `boolean()` |
90
+ | `DateTime` / `Date` | `number()` (Unix ms) |
91
+ | `object` / `unknown` / `any` | `json()` |
92
+ | `T \| null` or `T \| undefined` | `.optional()` |
93
+
94
+ When inference isn't accurate — for example a JSON column with a known shape — use `columnTypes`:
95
+
96
+ ```ts
97
+ import type { Config } from '@antislop/zero-lucid-generator'
98
+ import Session from "#models/session";
99
+
100
+ const config: Config = {
101
+ modelsSourcePath: './app/models',
102
+ columnTypes: {
103
+ Issue: {
104
+ // metadata is typed as `unknown` but has a known shape
105
+ metadata: 'json<{ priority: number; labels: string[] }>()',
106
+ },
107
+ User: {
108
+ // role is stored as a string enum
109
+ role: 'enumeration<"admin" | "member" | "viewer">()',
110
+ },
111
+ },
112
+ excludeModels: [Session],
113
+ }
114
+
115
+ export default config
116
+ ```
117
+
118
+ Valid Zero base types: `string`, `number`, `boolean`, `json`, `enumeration`.
119
+
120
+ ---
121
+
122
+ ## Example output
123
+
124
+ ```ts
125
+ // zero-schema.gen.ts (auto-generated — do not edit)
126
+
127
+ import {
128
+ createBuilder,
129
+ createSchema,
130
+ number,
131
+ relationships,
132
+ string,
133
+ table,
134
+ } from "@rocicorp/zero";
135
+
136
+ export const users = table("users")
137
+ .columns({
138
+ id: number(),
139
+ name: string(),
140
+ email: string(),
141
+ })
142
+ .primaryKey("id");
143
+
144
+ export const posts = table("posts")
145
+ .columns({
146
+ id: number(),
147
+ userId: number().from('user_id'),
148
+ title: string(),
149
+ body: string().optional(),
150
+ })
151
+ .primaryKey("id");
152
+
153
+ export const usersRelationships = relationships(users, ({ many }) => ({
154
+ posts: many({
155
+ sourceField: ["id"],
156
+ destField: ["userId"],
157
+ destSchema: posts,
158
+ }),
159
+ }));
160
+
161
+ export const schema = createSchema({
162
+ tables: [users, posts],
163
+ relationships: [usersRelationships],
164
+ });
165
+
166
+ export type Schema = typeof schema;
167
+
168
+ export const zql = createBuilder(schema);
169
+ ```
170
+
171
+ ## Supported relation types
172
+
173
+ | Lucid relation | Zero mapping |
174
+ |---|---|
175
+ | `hasOne` | `one(...)` |
176
+ | `hasMany` | `many(...)` |
177
+ | `belongsTo` | `one(...)` |
178
+ | `manyToMany` | `many([...chain])` via pivot table |
179
+ | `hasManyThrough` | `many([...chain])` |
180
+
181
+ Relations pointing to a model not found in `modelsSourcePath` are skipped with a warning.
182
+
183
+ ---
184
+
185
+ ## Automating regeneration
186
+
187
+ Add a script to `package.json` to regenerate whenever models change:
188
+
189
+ ```json
190
+ {
191
+ "scripts": {
192
+ "zero:generate": "lucid-zero generate",
193
+ "postinstall": "npm run zero:generate"
194
+ }
195
+ }
196
+ ```
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ type ConfigInput = {
3
+ modelsSourcePath: string;
4
+ /**
5
+ * Models to exclude from the generated schema.
6
+ * Typed as a constructor array rather than `LucidModel[]` so that model
7
+ * classes from the user's own `@adonisjs/lucid` installation are accepted
8
+ * without a structural mismatch from duplicate copies of the package.
9
+ */
10
+ excludeModels?: (abstract new (...args: any[]) => any)[];
11
+ output?: string;
12
+ prettier?: boolean;
13
+ columnTypes?: Record<string, Record<string, string>>;
14
+ };
15
+
16
+ export type { ConfigInput as Config };
package/dist/index.js ADDED
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/config.ts
7
+ import fs2 from "fs/promises";
8
+ import path from "path";
9
+
10
+ // src/utils.ts
11
+ import url from "url";
12
+ import fs from "fs/promises";
13
+ function toCamelCase(name) {
14
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
15
+ }
16
+ async function getDefaultExportFromModulePath(modulePath) {
17
+ try {
18
+ await fs.access(modulePath);
19
+ } catch {
20
+ throw new Error(`lucid-zero: Module not found at ${modulePath}`);
21
+ }
22
+ const { tsImport } = await import("tsx/esm/api");
23
+ const moduleUrl = url.pathToFileURL(modulePath).href;
24
+ const module = await tsImport(moduleUrl, { parentURL: import.meta.url });
25
+ const defaultExport = module.default;
26
+ if (!defaultExport) {
27
+ throw new Error(`lucid-zero: Module at ${modulePath} does not have a default export`);
28
+ }
29
+ return defaultExport;
30
+ }
31
+
32
+ // src/config.ts
33
+ var DEFAULT_CONFIG_FILE_PATH = "lucid-zero.config.ts";
34
+ var DEFAULT_OUTPUT_FILE_PATH = "zero-schema.gen.ts";
35
+ var Config = class {
36
+ modelsSourcePath;
37
+ excludeModels;
38
+ outputFilePath;
39
+ formatOutputFile;
40
+ columnTypes;
41
+ tsconfigPath;
42
+ /** Directory containing the config file — used to resolve relative paths */
43
+ configDir;
44
+ /** Populated by this.loadModels() */
45
+ models = [];
46
+ constructor(configInput, configDir = process.cwd(), tsconfigPath) {
47
+ this.configDir = configDir;
48
+ this.modelsSourcePath = configInput.modelsSourcePath;
49
+ this.excludeModels = configInput.excludeModels ?? [];
50
+ this.outputFilePath = path.resolve(configDir, configInput.output ?? DEFAULT_OUTPUT_FILE_PATH);
51
+ this.tsconfigPath = tsconfigPath ?? path.resolve(configDir, "tsconfig.json");
52
+ this.formatOutputFile = configInput.prettier ?? false;
53
+ this.columnTypes = configInput.columnTypes ?? {};
54
+ }
55
+ async verify() {
56
+ if (!this.modelsSourcePath) {
57
+ throw new Error("lucid-zero: modelsSourcePath is required");
58
+ }
59
+ await this.loadModels();
60
+ if (this.models.length === 0) {
61
+ throw new Error(
62
+ `lucid-zero: No Lucid models found in modelsSourcePath. Each model file must have a default export that extends BaseModel.`
63
+ );
64
+ }
65
+ this.verifyColumnTypes();
66
+ }
67
+ async loadModels() {
68
+ const modelsSourceAbsPath = path.resolve(this.configDir, this.modelsSourcePath);
69
+ let fileNames;
70
+ try {
71
+ fileNames = await fs2.readdir(modelsSourceAbsPath);
72
+ } catch (e) {
73
+ throw new Error(
74
+ `lucid-zero: Could not read modelsSourcePath at ${modelsSourceAbsPath}. Does the directory exist?Error: ${e}`
75
+ );
76
+ }
77
+ const tsFileNames = fileNames.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"));
78
+ const excludedModelNames = new Set((this.excludeModels ?? []).map((m) => m.name));
79
+ const results = await Promise.all(
80
+ tsFileNames.map(async (fileName) => {
81
+ let defaultExport;
82
+ try {
83
+ defaultExport = await getDefaultExportFromModulePath(
84
+ path.join(modelsSourceAbsPath, fileName)
85
+ );
86
+ } catch (err) {
87
+ console.warn(`lucid-zero: Failed to import ${fileName}, skipping: ${String(err)}`);
88
+ return null;
89
+ }
90
+ if (!this.isLucidModel(defaultExport)) {
91
+ console.info(`${fileName} does not export a Lucid model. Skipping...`);
92
+ return null;
93
+ }
94
+ if (excludedModelNames.has(defaultExport.name)) {
95
+ console.info(`${defaultExport.name} is excluded. Skipping...`);
96
+ return null;
97
+ }
98
+ return defaultExport;
99
+ })
100
+ );
101
+ this.models = results.filter((m) => m !== null);
102
+ }
103
+ verifyColumnTypes() {
104
+ const validZeroTypes = ["string", "number", "boolean", "json", "enumeration"];
105
+ const modelNames = new Set(this.models.map((m) => m.name));
106
+ for (const [modelName, columnOverrides] of Object.entries(this.columnTypes ?? {})) {
107
+ if (!modelNames.has(modelName)) {
108
+ throw new Error(
109
+ `lucid-zero: columnTypes has unknown model "${modelName}". Known models: ${[...modelNames].join(", ")}`
110
+ );
111
+ }
112
+ for (const [attributeName, typeString] of Object.entries(columnOverrides)) {
113
+ const baseTypeName = typeString.match(/^([a-z]+)/)?.[1];
114
+ if (!baseTypeName || !validZeroTypes.includes(baseTypeName)) {
115
+ throw new Error(
116
+ `lucid-zero: columnTypes override for ${modelName}.${attributeName} has unrecognised type "${typeString}". Expected one of: ${validZeroTypes.join(", ")}`
117
+ );
118
+ }
119
+ }
120
+ }
121
+ }
122
+ isLucidModel(value) {
123
+ return (
124
+ // The class itself is a constructor function
125
+ typeof value === "function" && "$columnsDefinitions" in value && value.$columnsDefinitions instanceof Map
126
+ );
127
+ }
128
+ };
129
+ var ConfigLoader = class {
130
+ static async load(opts) {
131
+ const absoluteConfigPath = path.resolve(
132
+ process.cwd(),
133
+ opts.configFilePath ?? DEFAULT_CONFIG_FILE_PATH
134
+ );
135
+ let defaultExport;
136
+ try {
137
+ defaultExport = await getDefaultExportFromModulePath(absoluteConfigPath);
138
+ } catch (err) {
139
+ throw new Error(
140
+ `lucid-zero: Failed to import config at ${absoluteConfigPath}
141
+ Error: ${String(err)}`
142
+ );
143
+ }
144
+ const config = new Config(defaultExport, path.dirname(absoluteConfigPath), opts.tsconfigPath);
145
+ return config;
146
+ }
147
+ };
148
+
149
+ // src/schema-transformer.ts
150
+ import { Project } from "ts-morph";
151
+ var typesToZeroTypes = {
152
+ string: "string()",
153
+ number: "number()",
154
+ boolean: "boolean()",
155
+ // Lucid date columns are typed as Luxon DateTime
156
+ DateTime: "number()",
157
+ // Plain JS Date falls back to number (epoch ms)
158
+ Date: "number()",
159
+ // Catch-all for object/unknown shapes
160
+ object: "json()",
161
+ unknown: "json()",
162
+ any: "json()"
163
+ };
164
+ var SchemaTransformer = class {
165
+ config;
166
+ project;
167
+ constructor(config, tsconfigPath) {
168
+ this.config = config;
169
+ this.project = new Project({ tsConfigFilePath: tsconfigPath ?? "tsconfig.json" });
170
+ }
171
+ transform() {
172
+ return {
173
+ models: this.config.models.map((model) => {
174
+ const columnNameAndTypePairs = this.extractColumnNameAndType(model);
175
+ const columnOverrides = this.config.columnTypes?.[model.name];
176
+ return {
177
+ tableName: model.table,
178
+ columns: this.mapColumns(columnNameAndTypePairs, columnOverrides, model),
179
+ relationships: this.mapRelations(model, this.config.models),
180
+ primaryKey: [model.primaryKey]
181
+ };
182
+ })
183
+ };
184
+ }
185
+ // Lucid Model does not offer the type of the columns, so we need to extract it from the source code
186
+ extractColumnNameAndType(model) {
187
+ const columnNameAndType = /* @__PURE__ */ new Map();
188
+ const modelSourceFile = this.project.getSourceFiles().find((sourceFile) => {
189
+ return sourceFile.getClass(model.name)?.getName() === model.name;
190
+ });
191
+ if (!modelSourceFile) {
192
+ throw new Error(
193
+ `lucid-zero: Could not find source file for model ${model.name} in ${this.project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath()).join(", ")}`
194
+ );
195
+ }
196
+ const classDeclaration = modelSourceFile.getClass(model.name);
197
+ if (!classDeclaration) {
198
+ throw new Error(`lucid-zero: Could not find class declaration for model ${model.name}`);
199
+ }
200
+ let currentClass = classDeclaration;
201
+ while (currentClass) {
202
+ for (const property of currentClass.getProperties()) {
203
+ const attributeName = property.getName();
204
+ if (!model.$columnsDefinitions.has(attributeName)) continue;
205
+ if (columnNameAndType.has(attributeName)) continue;
206
+ const explicitPropertyType = property.getTypeNode();
207
+ if (explicitPropertyType) {
208
+ columnNameAndType.set(attributeName, explicitPropertyType.getText());
209
+ } else {
210
+ columnNameAndType.set(attributeName, property.getType().getText());
211
+ }
212
+ }
213
+ currentClass = currentClass.getBaseClass();
214
+ }
215
+ return columnNameAndType;
216
+ }
217
+ mapColumns(typescriptColumnTypes, columnTypeOverrides, model) {
218
+ return Object.fromEntries(
219
+ [...model.$columnsDefinitions].map(([attributeName, columnOptions]) => {
220
+ const tsType = typescriptColumnTypes.get(attributeName);
221
+ const override = columnTypeOverrides?.[attributeName];
222
+ let zeroType;
223
+ if (override) {
224
+ zeroType = override;
225
+ } else if (tsType) {
226
+ zeroType = tsTypeToZeroType(tsType);
227
+ } else {
228
+ console.warn(
229
+ `lucid-zero: Could not infer Zero type for ${model.name}.${attributeName}. Falling back to json(). Add a columnTypes override if this is wrong.`
230
+ );
231
+ zeroType = "json()";
232
+ }
233
+ const isOptional = !columnOptions.isPrimary && isTsTypeOptional(tsType);
234
+ return [columnOptions.columnName, { type: zeroType, isOptional }];
235
+ })
236
+ );
237
+ }
238
+ /** Translates a Lucid attribute name to its DB column name via $columnsDefinitions. */
239
+ resolveColumnName(lucidModel, attributeName) {
240
+ const entry = [...lucidModel.$columnsDefinitions].find(([attr]) => attr === attributeName);
241
+ return entry ? entry[1].columnName : attributeName;
242
+ }
243
+ mapRelations(model, allModels) {
244
+ const relationships = {};
245
+ for (const [relationName, relation] of model.$relationsDefinitions) {
246
+ try {
247
+ relation.boot();
248
+ } catch (err) {
249
+ throw new Error(
250
+ `lucid-zero: Failed to boot relation "${relationName}" on ${model.name}: ${String(err)}`
251
+ );
252
+ }
253
+ const relatedModel = relation.relatedModel();
254
+ if (!allModels.some((m) => m.name === relatedModel.name)) {
255
+ console.info(
256
+ `lucid-zero: Skipping relation "${relationName}" on ${model.name} \u2014 ${relatedModel.name} is not included.`
257
+ );
258
+ continue;
259
+ }
260
+ switch (relation.type) {
261
+ case "hasOne":
262
+ relationships[relationName] = {
263
+ type: "one",
264
+ sourceField: [this.resolveColumnName(model, relation.localKey)],
265
+ destinationField: [this.resolveColumnName(relatedModel, relation.foreignKey)],
266
+ destinationTable: relatedModel.table
267
+ };
268
+ break;
269
+ case "belongsTo":
270
+ relationships[relationName] = {
271
+ type: "one",
272
+ sourceField: [this.resolveColumnName(model, relation.foreignKey)],
273
+ destinationField: [this.resolveColumnName(relatedModel, relation.localKey)],
274
+ destinationTable: relatedModel.table
275
+ };
276
+ break;
277
+ case "hasMany":
278
+ relationships[relationName] = {
279
+ type: "many",
280
+ sourceField: [this.resolveColumnName(model, relation.localKey)],
281
+ destinationField: [this.resolveColumnName(relatedModel, relation.foreignKey)],
282
+ destinationTable: relatedModel.table
283
+ };
284
+ break;
285
+ case "manyToMany": {
286
+ if (!relation.pivotTable || !relation.pivotForeignKey || !relation.pivotRelatedForeignKey) {
287
+ throw new Error(
288
+ `lucid-zero: manyToMany relation "${relationName}" on ${model.name} \u2014 pivot table fields could not be resolved after boot().`
289
+ );
290
+ }
291
+ relationships[relationName] = {
292
+ type: "many",
293
+ chain: [
294
+ {
295
+ sourceField: [this.resolveColumnName(model, relation.localKey)],
296
+ // pivotForeignKey and pivotRelatedForeignKey are already DB column names
297
+ destinationField: [relation.pivotForeignKey],
298
+ destinationTable: relation.pivotTable
299
+ },
300
+ {
301
+ sourceField: [relation.pivotRelatedForeignKey],
302
+ destinationField: [this.resolveColumnName(relatedModel, relation.relatedKey ?? relatedModel.primaryKey)],
303
+ destinationTable: relatedModel.table
304
+ }
305
+ ]
306
+ };
307
+ break;
308
+ }
309
+ case "hasManyThrough": {
310
+ const throughRel = relation;
311
+ if (!throughRel.throughModel) {
312
+ throw new Error(
313
+ `lucid-zero: hasManyThrough relation "${relationName}" on ${model.name} \u2014 throughModel could not be resolved after boot().`
314
+ );
315
+ }
316
+ const throughModel = throughRel.throughModel();
317
+ relationships[relationName] = {
318
+ type: "many",
319
+ chain: [
320
+ {
321
+ sourceField: [this.resolveColumnName(model, relation.localKey)],
322
+ destinationField: [this.resolveColumnName(throughModel, relation.foreignKey)],
323
+ destinationTable: throughModel.table
324
+ },
325
+ {
326
+ sourceField: [this.resolveColumnName(throughModel, throughRel.throughLocalKey ?? throughModel.primaryKey)],
327
+ destinationField: [this.resolveColumnName(relatedModel, throughRel.throughForeignKey ?? relatedModel.primaryKey)],
328
+ destinationTable: relatedModel.table
329
+ }
330
+ ]
331
+ };
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ return relationships;
337
+ }
338
+ };
339
+ function isTsTypeOptional(type) {
340
+ if (!type) {
341
+ return false;
342
+ }
343
+ return type.includes("null") || type.includes("undefined");
344
+ }
345
+ function tsTypeToZeroType(type) {
346
+ const base = type.split("|").map((t) => t.trim()).find((t) => t !== "null" && t !== "undefined");
347
+ if (!base) return "json()";
348
+ return typesToZeroTypes[base] ?? "json()";
349
+ }
350
+
351
+ // src/code-generator.ts
352
+ import fs3 from "fs";
353
+
354
+ // src/formatter.ts
355
+ var Formatter = class _Formatter {
356
+ static async loadPrettier() {
357
+ let prettier;
358
+ try {
359
+ const { createRequire } = await import("module");
360
+ const req = createRequire(process.cwd() + "/package.json");
361
+ const prettierPath = req.resolve("prettier");
362
+ const { pathToFileURL } = await import("url");
363
+ prettier = await import(pathToFileURL(prettierPath).href);
364
+ return prettier;
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+ static async format(code) {
370
+ const prettier = await _Formatter.loadPrettier();
371
+ if (!prettier) {
372
+ console.warn("lucid-zero: prettier not found \u2014 skipping formatting");
373
+ return code;
374
+ }
375
+ try {
376
+ const options = await prettier.resolveConfig(process.cwd());
377
+ return await prettier.format(code, { ...options, parser: "typescript" });
378
+ } catch {
379
+ console.warn("lucid-zero: prettier formatting failed \u2014 skipping");
380
+ return code;
381
+ }
382
+ }
383
+ };
384
+
385
+ // src/code-generator.ts
386
+ var HEADER = `// This file was automatically generated by lucid-zero.
387
+ // Do NOT edit this file manually \u2014 it will be overwritten on the next run.`;
388
+ var CodeGenerator = class {
389
+ config;
390
+ schema;
391
+ constructor(config, schema) {
392
+ this.config = config;
393
+ this.schema = schema;
394
+ }
395
+ async generateToOutputFile() {
396
+ let code = this.generate();
397
+ if (this.config.formatOutputFile) {
398
+ code = await Formatter.format(code);
399
+ }
400
+ fs3.writeFileSync(this.config.outputFilePath, code);
401
+ }
402
+ generate() {
403
+ let out = HEADER + "\n\n";
404
+ out += this.generateImports();
405
+ for (const model of this.schema.models) {
406
+ out += this.generateTableDefinitions(model) + "\n";
407
+ }
408
+ for (const model of this.schema.models) {
409
+ const rel = this.generateRelationshipsDefinitions(model);
410
+ if (rel) out += rel + "\n";
411
+ }
412
+ out += this.generateExports();
413
+ return out;
414
+ }
415
+ generateImports() {
416
+ const used = /* @__PURE__ */ new Set(["table", "createSchema", "createBuilder"]);
417
+ for (const model of this.schema.models) {
418
+ for (const col of Object.values(model.columns)) {
419
+ const baseTypeName = col.type.match(/^([a-z]+)/)?.[1];
420
+ if (baseTypeName) used.add(baseTypeName);
421
+ }
422
+ if (Object.keys(model.relationships).length > 0) {
423
+ used.add("relationships");
424
+ }
425
+ }
426
+ const sorted = [...used].sort();
427
+ return `import {
428
+ ${sorted.join(",\n ")},
429
+ } from "@rocicorp/zero";
430
+
431
+ `;
432
+ }
433
+ generateColumnDefinitions(dbColName, col) {
434
+ const zeroColName = this.toZeroName(dbColName);
435
+ let expr = col.type;
436
+ if (zeroColName !== dbColName) {
437
+ expr += `.from('${dbColName}')`;
438
+ }
439
+ if (col.isOptional) {
440
+ expr += `.optional()`;
441
+ }
442
+ return ` ${zeroColName}: ${expr}`;
443
+ }
444
+ toZeroName(name) {
445
+ return toCamelCase(name);
446
+ }
447
+ relationshipVariableName(model) {
448
+ return toCamelCase(model.tableName) + "Relationships";
449
+ }
450
+ generateTableDefinitions(model) {
451
+ const zeroName = this.toZeroName(model.tableName);
452
+ let out = `export const ${zeroName} = table("${zeroName}")`;
453
+ if (zeroName !== model.tableName) {
454
+ out += `
455
+ .from("${model.tableName}")`;
456
+ }
457
+ out += "\n .columns({\n";
458
+ for (const [dbColName, col] of Object.entries(model.columns)) {
459
+ out += this.generateColumnDefinitions(dbColName, col) + ",\n";
460
+ }
461
+ out += " })";
462
+ out += `
463
+ .primaryKey(${model.primaryKey.map((k) => `"${this.toZeroName(k)}"`).join(", ")});
464
+ `;
465
+ return out;
466
+ }
467
+ generateRelationshipsDefinitions(model) {
468
+ const relationEntries = Object.entries(model.relationships);
469
+ if (relationEntries.length === 0) return "";
470
+ const hasOne = relationEntries.some(([, rel]) => rel.type === "one");
471
+ const hasMany = relationEntries.some(([, rel]) => rel.type === "many");
472
+ const destructured = [hasOne && "one", hasMany && "many"].filter(Boolean).join(", ");
473
+ const zeroName = this.toZeroName(model.tableName);
474
+ const body = relationEntries.map(([name, rel]) => ` ${this.toZeroName(name)}: ${rel.type}(${this.generateRelationshipConfig(rel)})`).join(",\n");
475
+ return `export const ${this.relationshipVariableName(model)} = relationships(${zeroName}, ({ ${destructured} }) => ({
476
+ ${body}
477
+ }));
478
+ `;
479
+ }
480
+ zeroFields(fields) {
481
+ return JSON.stringify(fields.map((f) => this.toZeroName(f)));
482
+ }
483
+ generateRelationshipConfig(rel) {
484
+ if ("chain" in rel) {
485
+ return rel.chain.map((link) => [
486
+ `{`,
487
+ ` sourceField: ${this.zeroFields(link.sourceField)},`,
488
+ ` destField: ${this.zeroFields(link.destinationField)},`,
489
+ ` destSchema: ${this.toZeroName(link.destinationTable)},`,
490
+ ` }`
491
+ ].join("\n")).join(", ");
492
+ }
493
+ return [
494
+ `{`,
495
+ ` sourceField: ${this.zeroFields(rel.sourceField)},`,
496
+ ` destField: ${this.zeroFields(rel.destinationField)},`,
497
+ ` destSchema: ${this.toZeroName(rel.destinationTable)},`,
498
+ ` }`
499
+ ].join("\n");
500
+ }
501
+ generateExports() {
502
+ const hasRelationships = this.schema.models.some(
503
+ (model) => Object.keys(model.relationships).length > 0
504
+ );
505
+ let out = `
506
+ export const schema = createSchema({
507
+ `;
508
+ out += ` tables: [
509
+ `;
510
+ for (const model of this.schema.models) {
511
+ out += ` ${this.toZeroName(model.tableName)},
512
+ `;
513
+ }
514
+ out += ` ],
515
+ `;
516
+ if (hasRelationships) {
517
+ out += ` relationships: [
518
+ `;
519
+ for (const model of this.schema.models) {
520
+ if (Object.keys(model.relationships).length > 0) {
521
+ out += ` ${this.relationshipVariableName(model)},
522
+ `;
523
+ }
524
+ }
525
+ out += ` ],
526
+ `;
527
+ }
528
+ out += `});
529
+
530
+ `;
531
+ out += `export type Schema = typeof schema;
532
+ `;
533
+ out += `
534
+ export const zql = createBuilder(schema);
535
+ `;
536
+ return out;
537
+ }
538
+ };
539
+
540
+ // src/index.ts
541
+ var program = new Command();
542
+ program.name("lucid-zero").description("Generate Zero schemas from Lucid ORM model definitions");
543
+ program.command("generate").description("Generate a Zero schema from your Lucid models").option("-c, --config <path>", `Path to config file (default: ${DEFAULT_CONFIG_FILE_PATH})`).option("-t, --tsconfig <path>", `Path to tsconfig file (default: tsconfig.json)`).action(async (opts) => {
544
+ await run({ configFilePath: opts.config, tsconfigPath: opts.tsconfig });
545
+ });
546
+ program.parse();
547
+ async function run(options) {
548
+ try {
549
+ const config = await ConfigLoader.load(options);
550
+ await config.verify();
551
+ const schemaTransformer = new SchemaTransformer(config, options.tsconfigPath);
552
+ const transformedSchema = schemaTransformer.transform();
553
+ const codeGenerator = new CodeGenerator(config, transformedSchema);
554
+ await codeGenerator.generateToOutputFile();
555
+ } catch (e) {
556
+ const message = e instanceof Error ? e.message : String(e);
557
+ console.error(`lucid-zero: ${message}`);
558
+ process.exit(1);
559
+ }
560
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@antislop/zero-lucid-generator",
3
+ "version": "1.0.0",
4
+ "description": "Generate Zero schemas from Lucid ORM models",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "rm -rf dist && npx tsx build.ts && chmod +x dist/index.js",
18
+ "test": "vitest run --typecheck --coverage",
19
+ "check-types": "tsc --noEmit",
20
+ "lint": "eslint src tests",
21
+ "lint:fix": "eslint src tests --fix",
22
+ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
23
+ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\""
24
+ },
25
+ "bin": {
26
+ "lucid-zero": "./dist/index.js"
27
+ },
28
+ "keywords": ["zero", "lucid", "adonisjs", "schema", "generator", "rocicorp"],
29
+ "author": "antislop",
30
+ "license": "ISC",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "packageManager": "npm@11.12.1",
35
+ "type": "module",
36
+ "peerDependencies": {
37
+ "@adonisjs/lucid": ">=21.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@adonisjs/lucid": "^22.4.2",
41
+ "@types/node": "^25.9.2",
42
+ "@typescript-eslint/eslint-plugin": "^8.61.0",
43
+ "@typescript-eslint/parser": "^8.61.0",
44
+ "@vitest/coverage-v8": "^4.1.8",
45
+ "eslint": "^10.4.1",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "prettier": "^3.8.4",
48
+ "tsup": "^8.5.1",
49
+ "tsx": "^4.22.4",
50
+ "typescript": "^6.0.3",
51
+ "vitest": "^4.1.8"
52
+ },
53
+ "dependencies": {
54
+ "commander": "^15.0.0",
55
+ "ts-morph": "^28.0.0",
56
+ "tsx": "^4.22.4"
57
+ }
58
+ }