@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 +196 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +560 -0
- package/package.json +58 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|