@danielfgray/pg-sourcerer 0.2.1 → 0.3.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/dist/cli.js +3 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +14 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/generate.d.ts +5 -9
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +27 -29
- package/dist/generate.js.map +1 -1
- package/dist/index.d.ts +19 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -13
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +39 -9
- package/dist/init.js.map +1 -1
- package/dist/ir/extensions/queries.d.ts +264 -0
- package/dist/ir/extensions/queries.d.ts.map +1 -0
- package/dist/ir/extensions/queries.js +153 -0
- package/dist/ir/extensions/queries.js.map +1 -0
- package/dist/ir/extensions/schema-builder.d.ts +61 -0
- package/dist/ir/extensions/schema-builder.d.ts.map +1 -0
- package/dist/ir/extensions/schema-builder.js +5 -0
- package/dist/ir/extensions/schema-builder.js.map +1 -0
- package/dist/lib/conjure.d.ts +66 -0
- package/dist/lib/conjure.d.ts.map +1 -1
- package/dist/lib/conjure.js +127 -29
- package/dist/lib/conjure.js.map +1 -1
- package/dist/lib/hex.d.ts +10 -3
- package/dist/lib/hex.d.ts.map +1 -1
- package/dist/lib/hex.js +18 -8
- package/dist/lib/hex.js.map +1 -1
- package/dist/plugins/arktype.d.ts +27 -14
- package/dist/plugins/arktype.d.ts.map +1 -1
- package/dist/plugins/arktype.js +166 -130
- package/dist/plugins/arktype.js.map +1 -1
- package/dist/plugins/effect.d.ts +53 -0
- package/dist/plugins/effect.d.ts.map +1 -0
- package/dist/plugins/effect.js +1074 -0
- package/dist/plugins/effect.js.map +1 -0
- package/dist/plugins/http-elysia.d.ts +32 -0
- package/dist/plugins/http-elysia.d.ts.map +1 -0
- package/dist/plugins/http-elysia.js +613 -0
- package/dist/plugins/http-elysia.js.map +1 -0
- package/dist/plugins/http-express.d.ts +36 -0
- package/dist/plugins/http-express.d.ts.map +1 -0
- package/dist/plugins/http-express.js +388 -0
- package/dist/plugins/http-express.js.map +1 -0
- package/dist/plugins/http-hono.d.ts +36 -0
- package/dist/plugins/http-hono.d.ts.map +1 -0
- package/dist/plugins/http-hono.js +453 -0
- package/dist/plugins/http-hono.js.map +1 -0
- package/dist/plugins/http-orpc.d.ts +55 -0
- package/dist/plugins/http-orpc.d.ts.map +1 -0
- package/dist/plugins/http-orpc.js +370 -0
- package/dist/plugins/http-orpc.js.map +1 -0
- package/dist/plugins/http-trpc.d.ts +59 -0
- package/dist/plugins/http-trpc.d.ts.map +1 -0
- package/dist/plugins/http-trpc.js +392 -0
- package/dist/plugins/http-trpc.js.map +1 -0
- package/dist/plugins/kysely/queries.d.ts +92 -0
- package/dist/plugins/kysely/queries.d.ts.map +1 -0
- package/dist/plugins/kysely/queries.js +1169 -0
- package/dist/plugins/kysely/queries.js.map +1 -0
- package/dist/plugins/kysely/shared.d.ts +59 -0
- package/dist/plugins/kysely/shared.d.ts.map +1 -0
- package/dist/plugins/kysely/shared.js +247 -0
- package/dist/plugins/kysely/shared.js.map +1 -0
- package/dist/plugins/kysely/types.d.ts +22 -0
- package/dist/plugins/kysely/types.d.ts.map +1 -0
- package/dist/plugins/kysely/types.js +428 -0
- package/dist/plugins/kysely/types.js.map +1 -0
- package/dist/plugins/kysely.d.ts +72 -0
- package/dist/plugins/kysely.d.ts.map +1 -0
- package/dist/plugins/kysely.js +906 -0
- package/dist/plugins/kysely.js.map +1 -0
- package/dist/plugins/sql-queries.d.ts +55 -11
- package/dist/plugins/sql-queries.d.ts.map +1 -1
- package/dist/plugins/sql-queries.js +467 -218
- package/dist/plugins/sql-queries.js.map +1 -1
- package/dist/plugins/types.d.ts +20 -14
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/plugins/types.js +90 -112
- package/dist/plugins/types.js.map +1 -1
- package/dist/plugins/valibot.d.ts +45 -0
- package/dist/plugins/valibot.d.ts.map +1 -0
- package/dist/plugins/valibot.js +422 -0
- package/dist/plugins/valibot.js.map +1 -0
- package/dist/plugins/zod.d.ts +27 -14
- package/dist/plugins/zod.d.ts.map +1 -1
- package/dist/plugins/zod.js +231 -166
- package/dist/plugins/zod.js.map +1 -1
- package/dist/services/artifact-store.d.ts +11 -1
- package/dist/services/artifact-store.d.ts.map +1 -1
- package/dist/services/artifact-store.js +9 -0
- package/dist/services/artifact-store.js.map +1 -1
- package/dist/services/core-providers.d.ts +15 -0
- package/dist/services/core-providers.d.ts.map +1 -0
- package/dist/services/core-providers.js +23 -0
- package/dist/services/core-providers.js.map +1 -0
- package/dist/services/emissions.d.ts +14 -0
- package/dist/services/emissions.d.ts.map +1 -1
- package/dist/services/emissions.js +86 -47
- package/dist/services/emissions.js.map +1 -1
- package/dist/services/execution.d.ts +35 -0
- package/dist/services/execution.d.ts.map +1 -0
- package/dist/services/execution.js +86 -0
- package/dist/services/execution.js.map +1 -0
- package/dist/services/file-builder.d.ts +4 -0
- package/dist/services/file-builder.d.ts.map +1 -1
- package/dist/services/file-builder.js.map +1 -1
- package/dist/services/inflection.d.ts +2 -2
- package/dist/services/inflection.d.ts.map +1 -1
- package/dist/services/inflection.js +4 -4
- package/dist/services/inflection.js.map +1 -1
- package/dist/services/ir-builder.d.ts.map +1 -1
- package/dist/services/ir-builder.js +10 -3
- package/dist/services/ir-builder.js.map +1 -1
- package/dist/services/pg-types.d.ts +31 -0
- package/dist/services/pg-types.d.ts.map +1 -1
- package/dist/services/pg-types.js +24 -0
- package/dist/services/pg-types.js.map +1 -1
- package/dist/services/plugin-runner.d.ts +27 -37
- package/dist/services/plugin-runner.d.ts.map +1 -1
- package/dist/services/plugin-runner.js +73 -171
- package/dist/services/plugin-runner.js.map +1 -1
- package/dist/services/plugin.d.ts +349 -217
- package/dist/services/plugin.d.ts.map +1 -1
- package/dist/services/plugin.js +182 -130
- package/dist/services/plugin.js.map +1 -1
- package/dist/services/resolution.d.ts +38 -0
- package/dist/services/resolution.d.ts.map +1 -0
- package/dist/services/resolution.js +242 -0
- package/dist/services/resolution.js.map +1 -0
- package/dist/services/service-registry.d.ts +74 -0
- package/dist/services/service-registry.d.ts.map +1 -0
- package/dist/services/service-registry.js +61 -0
- package/dist/services/service-registry.js.map +1 -0
- package/dist/services/symbols.d.ts +59 -0
- package/dist/services/symbols.d.ts.map +1 -1
- package/dist/services/symbols.js +16 -0
- package/dist/services/symbols.js.map +1 -1
- package/dist/testing.d.ts +4 -25
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -23
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
- package/dist/plugins/effect-model.d.ts +0 -17
- package/dist/plugins/effect-model.d.ts.map +0 -1
- package/dist/plugins/effect-model.js +0 -409
- package/dist/plugins/effect-model.js.map +0 -1
- package/dist/plugins/kysely-queries.d.ts +0 -66
- package/dist/plugins/kysely-queries.d.ts.map +0 -1
- package/dist/plugins/kysely-queries.js +0 -951
- package/dist/plugins/kysely-queries.js.map +0 -1
- package/dist/plugins/kysely-types.d.ts +0 -35
- package/dist/plugins/kysely-types.d.ts.map +0 -1
- package/dist/plugins/kysely-types.js +0 -601
- package/dist/plugins/kysely-types.js.map +0 -1
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Plugin - Unified @effect/sql + @effect/platform code generation
|
|
3
|
+
*
|
|
4
|
+
* Generates:
|
|
5
|
+
* - Model classes (@effect/sql Model.Class with variants)
|
|
6
|
+
* - Repositories (Model.makeRepository or SqlSchema/SqlResolver functions)
|
|
7
|
+
* - HTTP API (@effect/platform HttpApi with full handlers)
|
|
8
|
+
*
|
|
9
|
+
* This plugin merges and replaces the deprecated effect-model plugin.
|
|
10
|
+
*/
|
|
11
|
+
import { Array as Arr, Option, pipe, Schema as S } from "effect";
|
|
12
|
+
import { definePlugin } from "../services/plugin.js";
|
|
13
|
+
import { inflect } from "../services/inflection.js";
|
|
14
|
+
import { findEnumByPgName, TsType } from "../services/pg-types.js";
|
|
15
|
+
import { getEnumEntities, getTableEntities, getCompositeEntities, } from "../ir/semantic-ir.js";
|
|
16
|
+
import { conjure } from "../lib/conjure.js";
|
|
17
|
+
import { isUuidType, isDateType, isBigIntType, isEnumType, getPgTypeName, resolveFieldType, } from "../lib/field-utils.js";
|
|
18
|
+
const { ts, exp, obj } = conjure;
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Configuration
|
|
21
|
+
// ============================================================================
|
|
22
|
+
/**
|
|
23
|
+
* HTTP API configuration
|
|
24
|
+
*/
|
|
25
|
+
const HttpConfigSchema = S.Struct({
|
|
26
|
+
/** Enable HTTP API generation */
|
|
27
|
+
enabled: S.optionalWith(S.Boolean, { default: () => true }),
|
|
28
|
+
/** Output subdirectory for API files. Default: "api" */
|
|
29
|
+
outputDir: S.optionalWith(S.String, { default: () => "api" }),
|
|
30
|
+
/** Base path for all routes. Default: "/api" */
|
|
31
|
+
basePath: S.optionalWith(S.String, { default: () => "/api" }),
|
|
32
|
+
/** Enable Swagger/OpenAPI generation */
|
|
33
|
+
swagger: S.optionalWith(S.Boolean, { default: () => true }),
|
|
34
|
+
});
|
|
35
|
+
const EffectConfigSchema = S.Struct({
|
|
36
|
+
outputDir: S.optionalWith(S.String, { default: () => "effect" }),
|
|
37
|
+
schemaDir: S.optional(S.String),
|
|
38
|
+
enumStyle: S.optionalWith(S.Union(S.Literal("strings"), S.Literal("enum")), { default: () => "strings" }),
|
|
39
|
+
typeReferences: S.optionalWith(S.Union(S.Literal("inline"), S.Literal("separate")), { default: () => "separate" }),
|
|
40
|
+
exportTypes: S.optionalWith(S.Boolean, { default: () => true }),
|
|
41
|
+
queryMode: S.optionalWith(S.Union(S.Literal("repository"), S.Literal("resolvers")), { default: () => "repository" }),
|
|
42
|
+
http: S.optionalWith(S.Union(S.Literal(false), HttpConfigSchema), { default: () => ({ enabled: true, outputDir: "api", basePath: "/api", swagger: true }) }),
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Normalize outputDir: "." becomes "" for path joining
|
|
46
|
+
*/
|
|
47
|
+
const normalizeOutputDir = (dir) => dir === "." ? "" : dir;
|
|
48
|
+
/**
|
|
49
|
+
* Build a file path, handling empty segments gracefully
|
|
50
|
+
*/
|
|
51
|
+
const buildPath = (...segments) => segments.filter((s) => s !== undefined && s !== "").join("/") + ".ts";
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Smart Tags
|
|
54
|
+
// ============================================================================
|
|
55
|
+
const EffectTagsSchema = S.Struct({
|
|
56
|
+
/** Override insert optionality: "optional" or "required" */
|
|
57
|
+
insert: S.optional(S.Union(S.Literal("optional"), S.Literal("required"))),
|
|
58
|
+
/** Mark field as sensitive - excluded from json variants */
|
|
59
|
+
sensitive: S.optional(S.Boolean),
|
|
60
|
+
/** Exclude entity from HTTP API generation */
|
|
61
|
+
http: S.optional(S.Union(S.Literal(false), S.Struct({
|
|
62
|
+
operations: S.optional(S.Array(S.Union(S.Literal("list"), S.Literal("read"), S.Literal("create"), S.Literal("update"), S.Literal("delete")))),
|
|
63
|
+
path: S.optional(S.String),
|
|
64
|
+
}))),
|
|
65
|
+
/** Skip repository generation for this entity */
|
|
66
|
+
repo: S.optional(S.Literal(false)),
|
|
67
|
+
});
|
|
68
|
+
const getEffectTags = (field) => {
|
|
69
|
+
const pluginTags = field.tags["effect"];
|
|
70
|
+
if (!pluginTags)
|
|
71
|
+
return {};
|
|
72
|
+
try {
|
|
73
|
+
return S.decodeUnknownSync(EffectTagsSchema)(pluginTags);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const getEntityEffectTags = (entity) => {
|
|
80
|
+
const pluginTags = entity.tags["effect"];
|
|
81
|
+
if (!pluginTags)
|
|
82
|
+
return {};
|
|
83
|
+
try {
|
|
84
|
+
return S.decodeUnknownSync(EffectTagsSchema)(pluginTags);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// Also support legacy effect:model tags for backward compatibility
|
|
91
|
+
const getEffectModelTags = (field) => {
|
|
92
|
+
const pluginTags = field.tags["effect:model"];
|
|
93
|
+
if (!pluginTags)
|
|
94
|
+
return {};
|
|
95
|
+
try {
|
|
96
|
+
return S.decodeUnknownSync(EffectTagsSchema)(pluginTags);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const isSensitive = (field) => getEffectTags(field).sensitive === true || getEffectModelTags(field).sensitive === true;
|
|
103
|
+
const getInsertOverride = (field) => getEffectTags(field).insert ?? getEffectModelTags(field).insert;
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Schema Builders (pure functions)
|
|
106
|
+
// ============================================================================
|
|
107
|
+
/** Cast n.Expression to ExpressionKind for recast compatibility */
|
|
108
|
+
const toExprKind = (expr) => expr;
|
|
109
|
+
/** Map TypeScript type to Effect Schema property access */
|
|
110
|
+
const tsTypeToEffectSchema = (tsType) => {
|
|
111
|
+
const prop = tsType === TsType.String
|
|
112
|
+
? "String"
|
|
113
|
+
: tsType === TsType.Number
|
|
114
|
+
? "Number"
|
|
115
|
+
: tsType === TsType.Boolean
|
|
116
|
+
? "Boolean"
|
|
117
|
+
: tsType === TsType.BigInt
|
|
118
|
+
? "BigInt"
|
|
119
|
+
: tsType === TsType.Date
|
|
120
|
+
? "Date"
|
|
121
|
+
: "Unknown";
|
|
122
|
+
return conjure.id("S").prop(prop).build();
|
|
123
|
+
};
|
|
124
|
+
/** Build S.Union(S.Literal(...), ...) for an enum */
|
|
125
|
+
const buildEnumSchema = (enumResult) => conjure
|
|
126
|
+
.id("S")
|
|
127
|
+
.method("Union", enumResult.values.map((v) => conjure.id("S").method("Literal", [conjure.str(v)]).build()))
|
|
128
|
+
.build();
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Schema Wrappers
|
|
131
|
+
// ============================================================================
|
|
132
|
+
const wrapIf = (schema, condition, wrapper) => (condition ? wrapper(schema) : schema);
|
|
133
|
+
const wrapNullable = (schema) => conjure.id("S").method("NullOr", [schema]).build();
|
|
134
|
+
const wrapArray = (schema) => conjure.id("S").method("Array", [schema]).build();
|
|
135
|
+
const wrapGenerated = (schema) => conjure.id("Model").method("Generated", [schema]).build();
|
|
136
|
+
const wrapSensitive = (schema) => conjure.id("Model").method("Sensitive", [schema]).build();
|
|
137
|
+
const wrapFieldOption = (schema) => conjure.id("Model").method("FieldOption", [schema]).build();
|
|
138
|
+
/**
|
|
139
|
+
* Determine if a field should be treated as DB-generated.
|
|
140
|
+
* Includes GENERATED ALWAYS, IDENTITY, and PK fields with defaults.
|
|
141
|
+
*/
|
|
142
|
+
const isDbGenerated = (field, entity) => field.isGenerated ||
|
|
143
|
+
field.isIdentity ||
|
|
144
|
+
(field.hasDefault && entity.primaryKey?.columns.includes(field.columnName) === true);
|
|
145
|
+
/**
|
|
146
|
+
* Check for auto-timestamp patterns (created_at, updated_at)
|
|
147
|
+
*/
|
|
148
|
+
const getAutoTimestamp = (field) => {
|
|
149
|
+
if (!field.hasDefault)
|
|
150
|
+
return undefined;
|
|
151
|
+
const name = field.columnName.toLowerCase();
|
|
152
|
+
if (name === "created_at" || name === "createdat")
|
|
153
|
+
return "insert";
|
|
154
|
+
if (name === "updated_at" || name === "updatedat")
|
|
155
|
+
return "update";
|
|
156
|
+
return undefined;
|
|
157
|
+
};
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Field → Schema Expression
|
|
160
|
+
// ============================================================================
|
|
161
|
+
/**
|
|
162
|
+
* Build the base Effect Schema type for a field
|
|
163
|
+
*/
|
|
164
|
+
const buildBaseSchemaType = (field, ctx) => {
|
|
165
|
+
const resolved = resolveFieldType(field, ctx.enums, ctx.extensions);
|
|
166
|
+
if (resolved.enumDef) {
|
|
167
|
+
if (ctx.typeReferences === "separate") {
|
|
168
|
+
// Reference by name - the enum schema is imported
|
|
169
|
+
return conjure.id(resolved.enumDef.name).build();
|
|
170
|
+
}
|
|
171
|
+
else if (ctx.enumStyle === "enum") {
|
|
172
|
+
// Inline native enum: S.Enums(EnumName)
|
|
173
|
+
return conjure
|
|
174
|
+
.id("S")
|
|
175
|
+
.method("Enums", [conjure.id(resolved.enumDef.name).build()])
|
|
176
|
+
.build();
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Inline strings: S.Union(S.Literal(...), ...)
|
|
180
|
+
return buildEnumSchema(resolved.enumDef);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (isUuidType(field))
|
|
184
|
+
return conjure.id("S").prop("UUID").build();
|
|
185
|
+
if (isDateType(field))
|
|
186
|
+
return conjure.id("S").prop("Date").build();
|
|
187
|
+
if (isBigIntType(field))
|
|
188
|
+
return conjure.id("S").prop("BigInt").build();
|
|
189
|
+
return tsTypeToEffectSchema(resolved.tsType);
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Build the complete field schema expression with all wrappers applied
|
|
193
|
+
*/
|
|
194
|
+
const buildFieldSchema = (field, ctx) => {
|
|
195
|
+
// Check for auto-timestamp patterns first (returns early with special type)
|
|
196
|
+
const autoTs = getAutoTimestamp(field);
|
|
197
|
+
if (autoTs === "insert")
|
|
198
|
+
return conjure.id("Model").prop("DateTimeInsertFromDate").build();
|
|
199
|
+
if (autoTs === "update")
|
|
200
|
+
return conjure.id("Model").prop("DateTimeUpdateFromDate").build();
|
|
201
|
+
// Build base type with array/nullable wrappers
|
|
202
|
+
let schema = buildBaseSchemaType(field, ctx);
|
|
203
|
+
schema = wrapIf(schema, field.isArray, wrapArray);
|
|
204
|
+
schema = wrapIf(schema, field.nullable, wrapNullable);
|
|
205
|
+
schema = wrapIf(schema, isSensitive(field), wrapSensitive);
|
|
206
|
+
// Determine generated/optional status
|
|
207
|
+
const insertOverride = getInsertOverride(field);
|
|
208
|
+
const shouldBeGenerated = insertOverride === "required"
|
|
209
|
+
? field.isGenerated || field.isIdentity
|
|
210
|
+
: isDbGenerated(field, ctx.entity);
|
|
211
|
+
if (shouldBeGenerated) {
|
|
212
|
+
schema = wrapGenerated(schema);
|
|
213
|
+
}
|
|
214
|
+
else if (insertOverride === "optional") {
|
|
215
|
+
schema = wrapFieldOption(schema);
|
|
216
|
+
}
|
|
217
|
+
return schema;
|
|
218
|
+
};
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Entity → Model Class
|
|
221
|
+
// ============================================================================
|
|
222
|
+
/**
|
|
223
|
+
* Build Model.Class<ClassName>("table_name")({ ...fields })
|
|
224
|
+
*/
|
|
225
|
+
const buildModelClass = (entity, className, ctx) => {
|
|
226
|
+
// Build fields object from row shape
|
|
227
|
+
const fieldsObj = entity.shapes.row.fields.reduce((builder, field) => builder.prop(field.name, buildFieldSchema(field, ctx)), obj());
|
|
228
|
+
// Build: Model.Class<ClassName>("table_name")
|
|
229
|
+
const modelClassRef = conjure.b.memberExpression(conjure.b.identifier("Model"), conjure.b.identifier("Class"));
|
|
230
|
+
const modelClassWithType = conjure.b.callExpression(modelClassRef, [
|
|
231
|
+
conjure.str(entity.pgName),
|
|
232
|
+
]);
|
|
233
|
+
// Add type parameters: Model.Class<ClassName>
|
|
234
|
+
modelClassWithType.typeParameters =
|
|
235
|
+
conjure.b.tsTypeParameterInstantiation([
|
|
236
|
+
conjure.b.tsTypeReference(conjure.b.identifier(className)),
|
|
237
|
+
]);
|
|
238
|
+
// Call with fields: Model.Class<ClassName>("table_name")({ ... })
|
|
239
|
+
return conjure.b.callExpression(modelClassWithType, [fieldsObj.build()]);
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Generate: export class ClassName extends Model.Class<ClassName>("table")({ ... }) {}
|
|
243
|
+
*/
|
|
244
|
+
const generateModelStatement = (entity, className, ctx) => {
|
|
245
|
+
const modelExpr = buildModelClass(entity, className, ctx);
|
|
246
|
+
const classDecl = conjure.b.classDeclaration(conjure.b.identifier(className), conjure.b.classBody([]), toExprKind(modelExpr));
|
|
247
|
+
return {
|
|
248
|
+
_tag: "SymbolStatement",
|
|
249
|
+
node: conjure.b.exportNamedDeclaration(classDecl, []),
|
|
250
|
+
symbol: {
|
|
251
|
+
name: className,
|
|
252
|
+
capability: "models",
|
|
253
|
+
entity: entity.name,
|
|
254
|
+
isType: false,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Generate enum schema: export const EnumName = S.Union(S.Literal(...), ...)
|
|
260
|
+
*/
|
|
261
|
+
const generateEnumStatement = (enumEntity) => exp.const(enumEntity.name, { capability: "models", entity: enumEntity.name }, buildEnumSchema({
|
|
262
|
+
name: enumEntity.name,
|
|
263
|
+
pgName: enumEntity.pgName,
|
|
264
|
+
values: enumEntity.values,
|
|
265
|
+
}));
|
|
266
|
+
/**
|
|
267
|
+
* Build the base Effect Schema type for a composite field (no Model wrappers)
|
|
268
|
+
*/
|
|
269
|
+
const buildCompositeFieldSchema = (field, ctx) => {
|
|
270
|
+
const resolved = resolveFieldType(field, ctx.enums, ctx.extensions);
|
|
271
|
+
if (resolved.enumDef) {
|
|
272
|
+
if (ctx.typeReferences === "separate") {
|
|
273
|
+
return conjure.id(resolved.enumDef.name).build();
|
|
274
|
+
}
|
|
275
|
+
else if (ctx.enumStyle === "enum") {
|
|
276
|
+
return conjure
|
|
277
|
+
.id("S")
|
|
278
|
+
.method("Enums", [conjure.id(resolved.enumDef.name).build()])
|
|
279
|
+
.build();
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
return buildEnumSchema(resolved.enumDef);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (isUuidType(field))
|
|
286
|
+
return conjure.id("S").prop("UUID").build();
|
|
287
|
+
if (isDateType(field))
|
|
288
|
+
return conjure.id("S").prop("Date").build();
|
|
289
|
+
if (isBigIntType(field))
|
|
290
|
+
return conjure.id("S").prop("BigInt").build();
|
|
291
|
+
return tsTypeToEffectSchema(resolved.tsType);
|
|
292
|
+
};
|
|
293
|
+
/**
|
|
294
|
+
* Generate: export const CompositeName = S.Struct({ ... })
|
|
295
|
+
* Optionally: export type CompositeName = S.Schema.Type<typeof CompositeName>
|
|
296
|
+
*/
|
|
297
|
+
const generateCompositeStatements = (composite, ctx, exportTypes) => {
|
|
298
|
+
// Build S.Struct({ ... }) for composite fields
|
|
299
|
+
const fieldsObj = composite.fields.reduce((builder, field) => {
|
|
300
|
+
let schema = buildCompositeFieldSchema(field, ctx);
|
|
301
|
+
schema = wrapIf(schema, field.isArray, wrapArray);
|
|
302
|
+
schema = wrapIf(schema, field.nullable, wrapNullable);
|
|
303
|
+
return builder.prop(field.name, schema);
|
|
304
|
+
}, obj());
|
|
305
|
+
const structExpr = conjure
|
|
306
|
+
.id("S")
|
|
307
|
+
.method("Struct", [fieldsObj.build()])
|
|
308
|
+
.build();
|
|
309
|
+
const modelSymbolCtx = { capability: "models", entity: composite.name };
|
|
310
|
+
const schemaStatement = exp.const(composite.name, modelSymbolCtx, structExpr);
|
|
311
|
+
if (!exportTypes) {
|
|
312
|
+
return [schemaStatement];
|
|
313
|
+
}
|
|
314
|
+
// Generate: export type CompositeName = S.Schema.Type<typeof CompositeName>
|
|
315
|
+
const typeSymbolCtx = { capability: "types", entity: composite.name };
|
|
316
|
+
const inferType = ts.qualifiedRefWithParams(["S", "Schema", "Type"], [ts.typeof(composite.name)]);
|
|
317
|
+
const typeStatement = exp.type(composite.name, typeSymbolCtx, inferType);
|
|
318
|
+
return [schemaStatement, typeStatement];
|
|
319
|
+
};
|
|
320
|
+
// ============================================================================
|
|
321
|
+
// Enum Helpers
|
|
322
|
+
// ============================================================================
|
|
323
|
+
/** Collect enum names used by fields */
|
|
324
|
+
const collectUsedEnums = (fields, enums) => {
|
|
325
|
+
const enumNames = fields.filter(isEnumType).flatMap((field) => {
|
|
326
|
+
const pgTypeName = getPgTypeName(field);
|
|
327
|
+
if (!pgTypeName)
|
|
328
|
+
return [];
|
|
329
|
+
return pipe(findEnumByPgName(enums, pgTypeName), Option.map((e) => e.name), Option.toArray);
|
|
330
|
+
});
|
|
331
|
+
return new Set(enumNames);
|
|
332
|
+
};
|
|
333
|
+
/** Build import refs for used enums */
|
|
334
|
+
const buildEnumImports = (usedEnums) => Arr.fromIterable(usedEnums).map((enumName) => ({
|
|
335
|
+
kind: "symbol",
|
|
336
|
+
ref: { capability: "models", entity: enumName },
|
|
337
|
+
}));
|
|
338
|
+
/**
|
|
339
|
+
* Generate enum schema for native enum style.
|
|
340
|
+
* Generates: export enum EnumName { A = 'a', ... } + export const EnumNameSchema = S.Enums(EnumName)
|
|
341
|
+
*/
|
|
342
|
+
const generateNativeEnumStatements = (enumEntity) => {
|
|
343
|
+
const symbolCtx = { capability: "models", entity: enumEntity.name };
|
|
344
|
+
// Generate: export enum EnumName { A = 'a', B = 'b', ... }
|
|
345
|
+
const enumStatement = exp.tsEnum(enumEntity.name, symbolCtx, enumEntity.values);
|
|
346
|
+
const schemaName = `${enumEntity.name}Schema`;
|
|
347
|
+
const schemaExpr = conjure
|
|
348
|
+
.id("S")
|
|
349
|
+
.method("Enums", [conjure.id(enumEntity.name).build()])
|
|
350
|
+
.build();
|
|
351
|
+
const schemaStatement = exp.const(schemaName, symbolCtx, schemaExpr);
|
|
352
|
+
return [enumStatement, schemaStatement];
|
|
353
|
+
};
|
|
354
|
+
// ============================================================================
|
|
355
|
+
// Model Generation
|
|
356
|
+
// ============================================================================
|
|
357
|
+
/**
|
|
358
|
+
* Generate all Model class files
|
|
359
|
+
*/
|
|
360
|
+
const generateModels = (ctx, config) => {
|
|
361
|
+
const { ir, inflection } = ctx;
|
|
362
|
+
const enumEntities = getEnumEntities(ir);
|
|
363
|
+
const { enumStyle, typeReferences, schemaDir, queryMode } = config;
|
|
364
|
+
const outputDir = normalizeOutputDir(config.outputDir);
|
|
365
|
+
// Helper to build file path for models (in schemaDir if set, otherwise directly in outputDir)
|
|
366
|
+
const buildModelPath = (entityName) => buildPath(outputDir, schemaDir, entityName);
|
|
367
|
+
// Generate separate enum files if configured
|
|
368
|
+
if (typeReferences === "separate") {
|
|
369
|
+
enumEntities
|
|
370
|
+
.filter((e) => e.tags.omit !== true)
|
|
371
|
+
.forEach((enumEntity) => {
|
|
372
|
+
const filePath = buildModelPath(enumEntity.name);
|
|
373
|
+
const statements = enumStyle === "enum"
|
|
374
|
+
? generateNativeEnumStatements(enumEntity)
|
|
375
|
+
: [generateEnumStatement(enumEntity)];
|
|
376
|
+
ctx
|
|
377
|
+
.file(filePath)
|
|
378
|
+
.import({ kind: "package", names: ["Schema as S"], from: "effect" })
|
|
379
|
+
.ast(conjure.symbolProgram(...statements))
|
|
380
|
+
.emit();
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// Generate table/view entity model files
|
|
384
|
+
getTableEntities(ir)
|
|
385
|
+
.filter((entity) => entity.tags.omit !== true)
|
|
386
|
+
.forEach((entity) => {
|
|
387
|
+
const className = inflection.entityName(entity.pgClass, entity.tags);
|
|
388
|
+
const fieldCtx = {
|
|
389
|
+
entity,
|
|
390
|
+
enums: enumEntities,
|
|
391
|
+
extensions: ir.extensions,
|
|
392
|
+
enumStyle,
|
|
393
|
+
typeReferences,
|
|
394
|
+
};
|
|
395
|
+
const filePath = buildModelPath(className);
|
|
396
|
+
// Collect enum usage for imports
|
|
397
|
+
const usedEnums = typeReferences === "separate"
|
|
398
|
+
? collectUsedEnums(entity.shapes.row.fields, enumEntities)
|
|
399
|
+
: new Set();
|
|
400
|
+
const fileBuilder = ctx
|
|
401
|
+
.file(filePath)
|
|
402
|
+
.import({ kind: "package", names: ["Model"], from: "@effect/sql" })
|
|
403
|
+
.import({ kind: "package", names: ["Schema as S"], from: "effect" });
|
|
404
|
+
buildEnumImports(usedEnums).forEach((ref) => fileBuilder.import(ref));
|
|
405
|
+
// Build statements: Model class + optional Repo
|
|
406
|
+
const statements = [
|
|
407
|
+
generateModelStatement(entity, className, fieldCtx),
|
|
408
|
+
];
|
|
409
|
+
// Add repo if: queryMode is 'repository', entity is a table with single-column PK, not skipped
|
|
410
|
+
if (queryMode === "repository" &&
|
|
411
|
+
entity.kind === "table" &&
|
|
412
|
+
hasSingleColumnPrimaryKey(entity) &&
|
|
413
|
+
!shouldSkipRepo(entity)) {
|
|
414
|
+
const idColumn = getPrimaryKeyColumn(entity);
|
|
415
|
+
if (idColumn) {
|
|
416
|
+
statements.push(generateRepoStatement(entity, className, idColumn));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
fileBuilder
|
|
420
|
+
.ast(conjure.symbolProgram(...statements))
|
|
421
|
+
.emit();
|
|
422
|
+
});
|
|
423
|
+
// Generate composite type schema files
|
|
424
|
+
const compositeFieldCtx = {
|
|
425
|
+
enums: enumEntities,
|
|
426
|
+
extensions: ir.extensions,
|
|
427
|
+
enumStyle,
|
|
428
|
+
typeReferences,
|
|
429
|
+
};
|
|
430
|
+
getCompositeEntities(ir)
|
|
431
|
+
.filter((composite) => composite.tags.omit !== true)
|
|
432
|
+
.forEach((composite) => {
|
|
433
|
+
const filePath = buildModelPath(composite.name);
|
|
434
|
+
// Collect enum usage for imports
|
|
435
|
+
const usedEnums = typeReferences === "separate"
|
|
436
|
+
? collectUsedEnums(composite.fields, enumEntities)
|
|
437
|
+
: new Set();
|
|
438
|
+
const fileBuilder = ctx
|
|
439
|
+
.file(filePath)
|
|
440
|
+
.import({ kind: "package", names: ["Schema as S"], from: "effect" });
|
|
441
|
+
buildEnumImports(usedEnums).forEach((ref) => fileBuilder.import(ref));
|
|
442
|
+
fileBuilder
|
|
443
|
+
.ast(conjure.symbolProgram(...generateCompositeStatements(composite, compositeFieldCtx, config.exportTypes)))
|
|
444
|
+
.emit();
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
// ============================================================================
|
|
448
|
+
// Repository Generation (Phase 2A)
|
|
449
|
+
// ============================================================================
|
|
450
|
+
/**
|
|
451
|
+
* Get the qualified table name (schema.table)
|
|
452
|
+
*/
|
|
453
|
+
const getQualifiedTableName = (entity) => `${entity.schemaName}.${entity.pgName}`;
|
|
454
|
+
/**
|
|
455
|
+
* Check if entity has a single-column primary key suitable for makeRepository
|
|
456
|
+
*/
|
|
457
|
+
const hasSingleColumnPrimaryKey = (entity) => entity.primaryKey !== undefined && entity.primaryKey.columns.length === 1;
|
|
458
|
+
/**
|
|
459
|
+
* Get the primary key column name for an entity
|
|
460
|
+
*/
|
|
461
|
+
const getPrimaryKeyColumn = (entity) => entity.primaryKey?.columns[0];
|
|
462
|
+
/**
|
|
463
|
+
* Check if entity should skip repository generation based on tags
|
|
464
|
+
*/
|
|
465
|
+
const shouldSkipRepo = (entity) => {
|
|
466
|
+
const pluginTags = entity.tags["effect"];
|
|
467
|
+
if (!pluginTags)
|
|
468
|
+
return false;
|
|
469
|
+
try {
|
|
470
|
+
const parsed = S.decodeUnknownSync(EffectTagsSchema)(pluginTags);
|
|
471
|
+
return parsed.repo === false;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
/**
|
|
478
|
+
* Generate repository statement:
|
|
479
|
+
* export const {Entity}Repo = Model.makeRepository({Entity}, { tableName, spanPrefix, idColumn })
|
|
480
|
+
*/
|
|
481
|
+
const generateRepoStatement = (entity, className, idColumn) => {
|
|
482
|
+
const repoName = `${className}Repo`;
|
|
483
|
+
const qualifiedTableName = getQualifiedTableName(entity);
|
|
484
|
+
// Build: Model.makeRepository(Entity, { tableName: "...", spanPrefix: "...", idColumn: "..." })
|
|
485
|
+
const makeRepoCall = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Model"), conjure.b.identifier("makeRepository")), [
|
|
486
|
+
conjure.b.identifier(className),
|
|
487
|
+
obj()
|
|
488
|
+
.prop("tableName", conjure.str(qualifiedTableName))
|
|
489
|
+
.prop("spanPrefix", conjure.str(repoName))
|
|
490
|
+
.prop("idColumn", conjure.str(idColumn))
|
|
491
|
+
.build(),
|
|
492
|
+
]);
|
|
493
|
+
return exp.const(repoName, { capability: "queries", entity: entity.name }, makeRepoCall);
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* Generate repository files for entities with single-column primary keys
|
|
497
|
+
* NOTE: Repos are now generated inline with models in generateModels()
|
|
498
|
+
* This function is kept for potential future use (e.g., separate file mode)
|
|
499
|
+
*/
|
|
500
|
+
const generateRepositories = (_ctx, _config) => {
|
|
501
|
+
// Repos are now generated inline with models
|
|
502
|
+
};
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// HTTP API Generation (Phase 3A)
|
|
505
|
+
// ============================================================================
|
|
506
|
+
/**
|
|
507
|
+
* Get parsed HTTP config from entity tags
|
|
508
|
+
*/
|
|
509
|
+
const getEntityHttpConfig = (entity) => {
|
|
510
|
+
const pluginTags = entity.tags["effect"];
|
|
511
|
+
if (!pluginTags)
|
|
512
|
+
return { skip: false };
|
|
513
|
+
try {
|
|
514
|
+
const parsed = S.decodeUnknownSync(EffectTagsSchema)(pluginTags);
|
|
515
|
+
if (parsed.http === false)
|
|
516
|
+
return { skip: true };
|
|
517
|
+
if (typeof parsed.http === "object") {
|
|
518
|
+
return {
|
|
519
|
+
skip: false,
|
|
520
|
+
operations: parsed.http.operations ? [...parsed.http.operations] : undefined,
|
|
521
|
+
path: parsed.http.path,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return { skip: false };
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return { skip: false };
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
/**
|
|
531
|
+
* Get the schema type for the primary key (for path params)
|
|
532
|
+
*/
|
|
533
|
+
const getPrimaryKeySchemaType = (entity) => {
|
|
534
|
+
const pkColumn = entity.primaryKey?.columns[0];
|
|
535
|
+
if (!pkColumn)
|
|
536
|
+
return "S.String";
|
|
537
|
+
const pkField = entity.shapes.row.fields.find((f) => f.columnName === pkColumn);
|
|
538
|
+
if (!pkField)
|
|
539
|
+
return "S.String";
|
|
540
|
+
// Check the underlying type
|
|
541
|
+
const pgType = pkField.pgAttribute.getType();
|
|
542
|
+
if (!pgType)
|
|
543
|
+
return "S.String";
|
|
544
|
+
// Map common PK types
|
|
545
|
+
switch (pgType.typname) {
|
|
546
|
+
case "int2":
|
|
547
|
+
case "int4":
|
|
548
|
+
case "int8":
|
|
549
|
+
case "serial":
|
|
550
|
+
case "bigserial":
|
|
551
|
+
return "S.NumberFromString";
|
|
552
|
+
case "uuid":
|
|
553
|
+
return "S.UUID";
|
|
554
|
+
default:
|
|
555
|
+
return "S.String";
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
/**
|
|
559
|
+
* Generate NotFound error class:
|
|
560
|
+
* export class {Entity}NotFound extends S.TaggedError<{Entity}NotFound>()("{Entity}NotFound", { id: Schema }) {}
|
|
561
|
+
*/
|
|
562
|
+
const generateNotFoundError = (className, idSchemaType) => {
|
|
563
|
+
const errorName = `${className}NotFound`;
|
|
564
|
+
// Build: S.TaggedError<ErrorName>()("ErrorName", { id: Schema })
|
|
565
|
+
// This is complex AST - use a simpler approach with raw template
|
|
566
|
+
const taggedErrorCall = conjure.b.callExpression(conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("S"), conjure.b.identifier("TaggedError")), []), [
|
|
567
|
+
conjure.str(errorName),
|
|
568
|
+
obj().prop("id", conjure.b.identifier(idSchemaType)).build(),
|
|
569
|
+
]);
|
|
570
|
+
// Add type parameter to the first call: S.TaggedError<ErrorName>
|
|
571
|
+
const innerCall = taggedErrorCall.callee;
|
|
572
|
+
innerCall.typeParameters =
|
|
573
|
+
conjure.b.tsTypeParameterInstantiation([
|
|
574
|
+
conjure.b.tsTypeReference(conjure.b.identifier(errorName)),
|
|
575
|
+
]);
|
|
576
|
+
// Build class: class ErrorName extends S.TaggedError<ErrorName>()(...) {}
|
|
577
|
+
const classDecl = conjure.b.classDeclaration(conjure.b.identifier(errorName), conjure.b.classBody([]), taggedErrorCall);
|
|
578
|
+
return {
|
|
579
|
+
_tag: "SymbolStatement",
|
|
580
|
+
node: conjure.b.exportNamedDeclaration(classDecl, []),
|
|
581
|
+
symbol: {
|
|
582
|
+
name: errorName,
|
|
583
|
+
capability: "http",
|
|
584
|
+
entity: className,
|
|
585
|
+
isType: false,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
/**
|
|
590
|
+
* Generate HttpApiGroup for an entity with CRUD endpoints
|
|
591
|
+
*/
|
|
592
|
+
const generateApiGroup = (entity, className, basePath, idSchemaType) => {
|
|
593
|
+
const groupName = `${className}Api`;
|
|
594
|
+
const errorName = `${className}NotFound`;
|
|
595
|
+
// Convert snake_case to kebab-case: user_emails -> user-emails
|
|
596
|
+
const kebabName = entity.pgName.replace(/_/g, "-");
|
|
597
|
+
const pluralPath = inflect.pluralize(kebabName);
|
|
598
|
+
const fullPath = `${basePath}/${pluralPath}`;
|
|
599
|
+
// Build the id path param: HttpApiSchema.param("id", Schema)
|
|
600
|
+
const idParam = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiSchema"), conjure.b.identifier("param")), [conjure.str("id"), conjure.b.identifier(idSchemaType)]);
|
|
601
|
+
// GET / - list endpoint
|
|
602
|
+
const listEndpoint = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiEndpoint"), conjure.b.identifier("get")), [conjure.str("list"), conjure.str("/")]), conjure.b.identifier("addSuccess")), [
|
|
603
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("S"), conjure.b.identifier("Array")), [conjure.b.memberExpression(conjure.b.identifier(className), conjure.b.identifier("json"))]),
|
|
604
|
+
]);
|
|
605
|
+
// GET /:id - get endpoint (with template literal for path param)
|
|
606
|
+
// HttpApiEndpoint.get("get")`/${idParam}`.addSuccess(Entity.json).addError(NotFound, { status: 404 })
|
|
607
|
+
const getEndpointBase = conjure.b.taggedTemplateExpression(conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiEndpoint"), conjure.b.identifier("get")), [conjure.str("get")]), conjure.b.templateLiteral([
|
|
608
|
+
conjure.b.templateElement({ raw: "/", cooked: "/" }, false),
|
|
609
|
+
conjure.b.templateElement({ raw: "", cooked: "" }, true),
|
|
610
|
+
], [idParam]));
|
|
611
|
+
const getEndpointWithSuccess = conjure.b.callExpression(conjure.b.memberExpression(getEndpointBase, conjure.b.identifier("addSuccess")), [conjure.b.memberExpression(conjure.b.identifier(className), conjure.b.identifier("json"))]);
|
|
612
|
+
const getEndpoint = conjure.b.callExpression(conjure.b.memberExpression(getEndpointWithSuccess, conjure.b.identifier("addError")), [conjure.b.identifier(errorName), obj().prop("status", conjure.b.literal(404)).build()]);
|
|
613
|
+
// POST / - create endpoint
|
|
614
|
+
const createEndpointBase = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiEndpoint"), conjure.b.identifier("post")), [conjure.str("create"), conjure.str("/")]);
|
|
615
|
+
const createEndpointWithPayload = conjure.b.callExpression(conjure.b.memberExpression(createEndpointBase, conjure.b.identifier("setPayload")), [conjure.b.memberExpression(conjure.b.identifier(className), conjure.b.identifier("jsonCreate"))]);
|
|
616
|
+
const createEndpoint = conjure.b.callExpression(conjure.b.memberExpression(createEndpointWithPayload, conjure.b.identifier("addSuccess")), [conjure.b.memberExpression(conjure.b.identifier(className), conjure.b.identifier("json"))]);
|
|
617
|
+
// PUT /:id - update endpoint
|
|
618
|
+
const updateEndpointBase = conjure.b.taggedTemplateExpression(conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiEndpoint"), conjure.b.identifier("put")), [conjure.str("update")]), conjure.b.templateLiteral([
|
|
619
|
+
conjure.b.templateElement({ raw: "/", cooked: "/" }, false),
|
|
620
|
+
conjure.b.templateElement({ raw: "", cooked: "" }, true),
|
|
621
|
+
], [idParam]));
|
|
622
|
+
const updateEndpointWithPayload = conjure.b.callExpression(conjure.b.memberExpression(updateEndpointBase, conjure.b.identifier("setPayload")), [conjure.b.memberExpression(conjure.b.identifier(className), conjure.b.identifier("jsonUpdate"))]);
|
|
623
|
+
const updateEndpointWithSuccess = conjure.b.callExpression(conjure.b.memberExpression(updateEndpointWithPayload, conjure.b.identifier("addSuccess")), [conjure.b.memberExpression(conjure.b.identifier(className), conjure.b.identifier("json"))]);
|
|
624
|
+
const updateEndpoint = conjure.b.callExpression(conjure.b.memberExpression(updateEndpointWithSuccess, conjure.b.identifier("addError")), [conjure.b.identifier(errorName), obj().prop("status", conjure.b.literal(404)).build()]);
|
|
625
|
+
// DELETE /:id - delete endpoint
|
|
626
|
+
const deleteEndpointBase = conjure.b.taggedTemplateExpression(conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiEndpoint"), conjure.b.identifier("del")), [conjure.str("delete")]), conjure.b.templateLiteral([
|
|
627
|
+
conjure.b.templateElement({ raw: "/", cooked: "/" }, false),
|
|
628
|
+
conjure.b.templateElement({ raw: "", cooked: "" }, true),
|
|
629
|
+
], [idParam]));
|
|
630
|
+
const deleteEndpoint = conjure.b.callExpression(conjure.b.memberExpression(deleteEndpointBase, conjure.b.identifier("addError")), [conjure.b.identifier(errorName), obj().prop("status", conjure.b.literal(404)).build()]);
|
|
631
|
+
// Build: HttpApiGroup.make("name").prefix("/path").add(...).add(...)
|
|
632
|
+
let groupExpr = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiGroup"), conjure.b.identifier("make")), [conjure.str(pluralPath)]);
|
|
633
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("prefix")), [conjure.str(fullPath)]);
|
|
634
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("add")), [listEndpoint]);
|
|
635
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("add")), [getEndpoint]);
|
|
636
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("add")), [createEndpoint]);
|
|
637
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("add")), [updateEndpoint]);
|
|
638
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("add")), [deleteEndpoint]);
|
|
639
|
+
// Add InternalServerError for SQL errors
|
|
640
|
+
groupExpr = conjure.b.callExpression(conjure.b.memberExpression(groupExpr, conjure.b.identifier("addError")), [
|
|
641
|
+
conjure.b.memberExpression(conjure.b.identifier("HttpApiError"), conjure.b.identifier("InternalServerError")),
|
|
642
|
+
]);
|
|
643
|
+
return exp.const(groupName, { capability: "http", entity: entity.name }, groupExpr);
|
|
644
|
+
};
|
|
645
|
+
/**
|
|
646
|
+
* Generate HTTP API files for each entity
|
|
647
|
+
* Each file contains: NotFoundError, ApiGroup, and Handlers
|
|
648
|
+
*/
|
|
649
|
+
const generateHttpApi = (ctx, config, entities) => {
|
|
650
|
+
const httpConfig = config.http;
|
|
651
|
+
if (httpConfig === false || !httpConfig.enabled)
|
|
652
|
+
return;
|
|
653
|
+
if (entities.length === 0)
|
|
654
|
+
return;
|
|
655
|
+
const basePath = httpConfig.basePath;
|
|
656
|
+
const outputDir = normalizeOutputDir(config.outputDir);
|
|
657
|
+
const apiDir = httpConfig.outputDir;
|
|
658
|
+
const buildApiPath = (entityName) => buildPath(outputDir, apiDir, entityName);
|
|
659
|
+
for (const info of entities) {
|
|
660
|
+
const idSchemaType = getPrimaryKeySchemaType(info.entity);
|
|
661
|
+
const filePath = buildApiPath(info.className);
|
|
662
|
+
const statements = [
|
|
663
|
+
generateNotFoundError(info.className, idSchemaType),
|
|
664
|
+
generateApiGroup(info.entity, info.className, basePath, idSchemaType),
|
|
665
|
+
generateEntityHandlers(info),
|
|
666
|
+
];
|
|
667
|
+
ctx
|
|
668
|
+
.file(filePath)
|
|
669
|
+
// API group imports
|
|
670
|
+
.import({
|
|
671
|
+
kind: "package",
|
|
672
|
+
names: ["HttpApiBuilder", "HttpApiEndpoint", "HttpApiError", "HttpApiGroup", "HttpApiSchema"],
|
|
673
|
+
from: "@effect/platform",
|
|
674
|
+
})
|
|
675
|
+
// Handler imports
|
|
676
|
+
.import({ kind: "package", names: ["Model", "SqlClient"], from: "@effect/sql" })
|
|
677
|
+
.import({ kind: "package", names: ["DateTime", "Effect", "Option", "Schema", "Schema as S"], from: "effect" })
|
|
678
|
+
// Model and Repo
|
|
679
|
+
.import({
|
|
680
|
+
kind: "symbol",
|
|
681
|
+
ref: { capability: "models", entity: info.entity.name },
|
|
682
|
+
})
|
|
683
|
+
.import({
|
|
684
|
+
kind: "symbol",
|
|
685
|
+
ref: { capability: "queries", entity: info.entity.name },
|
|
686
|
+
})
|
|
687
|
+
// Combined Api class
|
|
688
|
+
.import({
|
|
689
|
+
kind: "relative",
|
|
690
|
+
names: ["Api"],
|
|
691
|
+
from: "./Api.js",
|
|
692
|
+
})
|
|
693
|
+
.ast(conjure.symbolProgram(...statements))
|
|
694
|
+
.emit();
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
// ============================================================================
|
|
698
|
+
// HTTP API Index Generation (Phase 3B)
|
|
699
|
+
// ============================================================================
|
|
700
|
+
/**
|
|
701
|
+
* Create a shorthand property for object patterns: { id } instead of { id: id }
|
|
702
|
+
*/
|
|
703
|
+
const shorthandProp = (name) => {
|
|
704
|
+
const prop = conjure.b.property("init", conjure.b.identifier(name), conjure.b.identifier(name));
|
|
705
|
+
prop.shorthand = true;
|
|
706
|
+
return prop;
|
|
707
|
+
};
|
|
708
|
+
/**
|
|
709
|
+
* Collect entities eligible for HTTP API generation
|
|
710
|
+
*/
|
|
711
|
+
const collectApiEntities = (ir, inflection) => getTableEntities(ir)
|
|
712
|
+
.filter((entity) => entity.tags.omit !== true)
|
|
713
|
+
.filter((entity) => entity.kind === "table")
|
|
714
|
+
.filter((entity) => hasSingleColumnPrimaryKey(entity))
|
|
715
|
+
.filter((entity) => !getEntityHttpConfig(entity).skip)
|
|
716
|
+
.map((entity) => {
|
|
717
|
+
const className = inflection.entityName(entity.pgClass, entity.tags);
|
|
718
|
+
const kebabName = entity.pgName.replace(/_/g, "-");
|
|
719
|
+
const pluralName = inflect.pluralize(kebabName);
|
|
720
|
+
const idColumn = getPrimaryKeyColumn(entity);
|
|
721
|
+
// Find timestamp fields that need Model.Override(DateTime.unsafeNow())
|
|
722
|
+
const insertTimestampFields = [];
|
|
723
|
+
const updateTimestampFields = [];
|
|
724
|
+
for (const field of entity.shapes.row.fields) {
|
|
725
|
+
const autoTs = getAutoTimestamp(field);
|
|
726
|
+
if (autoTs === "insert") {
|
|
727
|
+
// created_at: needs timestamp on insert only
|
|
728
|
+
insertTimestampFields.push(field.columnName);
|
|
729
|
+
}
|
|
730
|
+
else if (autoTs === "update") {
|
|
731
|
+
// updated_at: needs timestamp on both insert and update
|
|
732
|
+
insertTimestampFields.push(field.columnName);
|
|
733
|
+
updateTimestampFields.push(field.columnName);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
entity,
|
|
738
|
+
className,
|
|
739
|
+
groupName: `${className}Api`,
|
|
740
|
+
errorName: `${className}NotFound`,
|
|
741
|
+
repoName: `${className}Repo`,
|
|
742
|
+
pluralName,
|
|
743
|
+
idColumn,
|
|
744
|
+
insertTimestampFields,
|
|
745
|
+
updateTimestampFields,
|
|
746
|
+
};
|
|
747
|
+
});
|
|
748
|
+
/**
|
|
749
|
+
* Generate: export class Api extends HttpApi.make("api").add(Group1).add(Group2)... {}
|
|
750
|
+
*/
|
|
751
|
+
const generateCombinedApiClass = (entities) => {
|
|
752
|
+
// Build: HttpApi.make("api").add(Group1).add(Group2)...
|
|
753
|
+
let apiExpr = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApi"), conjure.b.identifier("make")), [conjure.str("api")]);
|
|
754
|
+
for (const info of entities) {
|
|
755
|
+
apiExpr = conjure.b.callExpression(conjure.b.memberExpression(apiExpr, conjure.b.identifier("add")), [conjure.b.identifier(info.groupName)]);
|
|
756
|
+
}
|
|
757
|
+
const classDecl = conjure.b.classDeclaration(conjure.b.identifier("Api"), conjure.b.classBody([]), apiExpr);
|
|
758
|
+
return {
|
|
759
|
+
_tag: "SymbolStatement",
|
|
760
|
+
node: conjure.b.exportNamedDeclaration(classDecl, []),
|
|
761
|
+
symbol: {
|
|
762
|
+
name: "Api",
|
|
763
|
+
capability: "http",
|
|
764
|
+
entity: "Api",
|
|
765
|
+
isType: false,
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
};
|
|
769
|
+
/**
|
|
770
|
+
* Generate api/Api.ts with the combined Api class
|
|
771
|
+
*/
|
|
772
|
+
const generateHttpApiClass = (ctx, config, entities) => {
|
|
773
|
+
if (entities.length === 0)
|
|
774
|
+
return;
|
|
775
|
+
const httpConfig = config.http;
|
|
776
|
+
if (httpConfig === false || !httpConfig.enabled)
|
|
777
|
+
return;
|
|
778
|
+
const outputDir = normalizeOutputDir(config.outputDir);
|
|
779
|
+
const apiDir = httpConfig.outputDir;
|
|
780
|
+
const filePath = buildPath(outputDir, apiDir, "Api");
|
|
781
|
+
const statements = [generateCombinedApiClass(entities)];
|
|
782
|
+
const fileBuilder = ctx
|
|
783
|
+
.file(filePath)
|
|
784
|
+
.import({ kind: "package", names: ["HttpApi"], from: "@effect/platform" });
|
|
785
|
+
// Import each entity's API group
|
|
786
|
+
for (const info of entities) {
|
|
787
|
+
fileBuilder.import({
|
|
788
|
+
kind: "relative",
|
|
789
|
+
names: [info.groupName],
|
|
790
|
+
from: `./${info.className}.js`,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
fileBuilder.ast(conjure.symbolProgram(...statements)).emit();
|
|
794
|
+
};
|
|
795
|
+
/**
|
|
796
|
+
* Build: Effect.catchTag("SqlError", () => Effect.fail(new HttpApiError.InternalServerError()))
|
|
797
|
+
*/
|
|
798
|
+
const buildSqlErrorCatch = () => conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("catchTag")), [
|
|
799
|
+
conjure.str("SqlError"),
|
|
800
|
+
conjure.b.arrowFunctionExpression([], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("fail")), [
|
|
801
|
+
conjure.b.newExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiError"), conjure.b.identifier("InternalServerError")), []),
|
|
802
|
+
])),
|
|
803
|
+
]);
|
|
804
|
+
/**
|
|
805
|
+
* Build: expr.pipe(arg1, arg2, ...)
|
|
806
|
+
*/
|
|
807
|
+
const buildPipe = (expr, ...args) => conjure.b.callExpression(conjure.b.memberExpression(expr, conjure.b.identifier("pipe")), args);
|
|
808
|
+
/**
|
|
809
|
+
* Generate handler for a single entity:
|
|
810
|
+
* export const {Entity}Handlers = HttpApiBuilder.group(Api, "pluralName", (handlers) =>
|
|
811
|
+
* Effect.gen(function* () {
|
|
812
|
+
* const sql = yield* SqlClient;
|
|
813
|
+
* const repo = yield* {Entity}Repo;
|
|
814
|
+
* return handlers
|
|
815
|
+
* .handle("list", () => sql`SELECT * FROM table`)
|
|
816
|
+
* .handle("get", ({ path: { id } }) => ...)
|
|
817
|
+
* ...
|
|
818
|
+
* })
|
|
819
|
+
* );
|
|
820
|
+
*/
|
|
821
|
+
const generateEntityHandlers = (info) => {
|
|
822
|
+
const handlersName = `${info.className}Handlers`;
|
|
823
|
+
const qualifiedTable = `${info.entity.schemaName}.${info.entity.pgName}`;
|
|
824
|
+
// Build Effect.gen body
|
|
825
|
+
// const sql = yield* SqlClient;
|
|
826
|
+
const sqlDecl = conjure.b.variableDeclaration("const", [
|
|
827
|
+
conjure.b.variableDeclarator(conjure.b.identifier("sql"), conjure.b.yieldExpression(conjure.b.memberExpression(conjure.b.identifier("SqlClient"), conjure.b.identifier("SqlClient")), true)),
|
|
828
|
+
]);
|
|
829
|
+
// const repo = yield* {Entity}Repo;
|
|
830
|
+
const repoDecl = conjure.b.variableDeclaration("const", [
|
|
831
|
+
conjure.b.variableDeclarator(conjure.b.identifier("repo"), conjure.b.yieldExpression(conjure.b.identifier(info.repoName), true // delegate (yield*)
|
|
832
|
+
)),
|
|
833
|
+
]);
|
|
834
|
+
// Build handlers chain: handlers.handle("list", ...).handle("get", ...)...
|
|
835
|
+
// Start with handlers reference
|
|
836
|
+
let handlersChain = conjure.b.identifier("handlers");
|
|
837
|
+
// .handle("list", () => sql`SELECT * FROM table`.pipe(
|
|
838
|
+
// Effect.flatMap(Schema.decodeUnknown(Schema.Array(Model))),
|
|
839
|
+
// Effect.catchAll(() => Effect.fail(new HttpApiError.InternalServerError()))
|
|
840
|
+
// ))
|
|
841
|
+
const listQuery = conjure.b.taggedTemplateExpression(conjure.b.identifier("sql"), conjure.b.templateLiteral([conjure.b.templateElement({ raw: `SELECT * FROM ${qualifiedTable}`, cooked: `SELECT * FROM ${qualifiedTable}` }, true)], []));
|
|
842
|
+
// Schema.decodeUnknown(Schema.Array(Model))
|
|
843
|
+
const decodeArray = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Schema"), conjure.b.identifier("decodeUnknown")), [
|
|
844
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Schema"), conjure.b.identifier("Array")), [conjure.b.identifier(info.className)]),
|
|
845
|
+
]);
|
|
846
|
+
// Effect.flatMap(decodeArray)
|
|
847
|
+
const flatMapDecode = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("flatMap")), [decodeArray]);
|
|
848
|
+
// Effect.catchAll(() => Effect.fail(new HttpApiError.InternalServerError()))
|
|
849
|
+
const catchAll = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("catchAll")), [
|
|
850
|
+
conjure.b.arrowFunctionExpression([], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("fail")), [
|
|
851
|
+
conjure.b.newExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiError"), conjure.b.identifier("InternalServerError")), []),
|
|
852
|
+
])),
|
|
853
|
+
]);
|
|
854
|
+
const listQueryWithErrorHandling = buildPipe(listQuery, flatMapDecode, catchAll);
|
|
855
|
+
handlersChain = conjure.b.callExpression(conjure.b.memberExpression(handlersChain, conjure.b.identifier("handle")), [
|
|
856
|
+
conjure.str("list"),
|
|
857
|
+
conjure.b.arrowFunctionExpression([], listQueryWithErrorHandling),
|
|
858
|
+
]);
|
|
859
|
+
// .handle("get", ({ path: { id } }) => repo.findById(id).pipe(Effect.flatMap(Option.match(...))))
|
|
860
|
+
const getHandler = conjure.b.arrowFunctionExpression([
|
|
861
|
+
conjure.b.objectPattern([
|
|
862
|
+
conjure.b.property("init", conjure.b.identifier("path"), conjure.b.objectPattern([shorthandProp("id")])),
|
|
863
|
+
]),
|
|
864
|
+
],
|
|
865
|
+
// repo.findById(id).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.fail(new Error({ id })), onSome: Effect.succeed })))
|
|
866
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("repo"), conjure.b.identifier("findById")), [conjure.b.identifier("id")]), conjure.b.identifier("pipe")), [
|
|
867
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("flatMap")), [
|
|
868
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Option"), conjure.b.identifier("match")), [
|
|
869
|
+
obj()
|
|
870
|
+
.prop("onNone", conjure.b.arrowFunctionExpression([], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("fail")), [
|
|
871
|
+
conjure.b.newExpression(conjure.b.identifier(info.errorName), [
|
|
872
|
+
obj().prop("id", conjure.b.identifier("id")).build(),
|
|
873
|
+
]),
|
|
874
|
+
])))
|
|
875
|
+
.prop("onSome", conjure.b.identifier("Effect.succeed"))
|
|
876
|
+
.build(),
|
|
877
|
+
]),
|
|
878
|
+
]),
|
|
879
|
+
]));
|
|
880
|
+
handlersChain = conjure.b.callExpression(conjure.b.memberExpression(handlersChain, conjure.b.identifier("handle")), [conjure.str("get"), getHandler]);
|
|
881
|
+
// .handle("create", ({ payload }) => repo.insert({ ...payload, created_at: Model.Override(DateTime.unsafeNow()), ... }))
|
|
882
|
+
// Build timestamp properties for insert: Model.Override(DateTime.unsafeNow())
|
|
883
|
+
const timestampNow = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Model"), conjure.b.identifier("Override")), [
|
|
884
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("DateTime"), conjure.b.identifier("unsafeNow")), []),
|
|
885
|
+
]);
|
|
886
|
+
const insertTimestampProps = info.insertTimestampFields.map((fieldName) => conjure.b.property("init", conjure.b.identifier(fieldName), timestampNow));
|
|
887
|
+
// Build insert argument: { ...payload, created_at: ..., updated_at: ... } or just payload if no timestamps
|
|
888
|
+
const insertArg = insertTimestampProps.length > 0
|
|
889
|
+
? conjure.b.objectExpression([
|
|
890
|
+
conjure.b.spreadProperty(conjure.b.identifier("payload")),
|
|
891
|
+
...insertTimestampProps,
|
|
892
|
+
])
|
|
893
|
+
: conjure.b.identifier("payload");
|
|
894
|
+
const createHandler = conjure.b.arrowFunctionExpression([
|
|
895
|
+
conjure.b.objectPattern([shorthandProp("payload")]),
|
|
896
|
+
], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("repo"), conjure.b.identifier("insert")), [insertArg]));
|
|
897
|
+
handlersChain = conjure.b.callExpression(conjure.b.memberExpression(handlersChain, conjure.b.identifier("handle")), [conjure.str("create"), createHandler]);
|
|
898
|
+
// .handle("update", ({ path: { id }, payload }) =>
|
|
899
|
+
// sql`UPDATE table SET ${sql.update({...payload, updated_at}, ["id"])} WHERE id = ${id} RETURNING *`.pipe(
|
|
900
|
+
// Effect.flatMap(Effect.head),
|
|
901
|
+
// Effect.flatMap(Option.match({ onNone: () => Effect.fail(NotFound), onSome: Schema.decodeUnknown(Model) })),
|
|
902
|
+
// Effect.catchTags({ ParseError: () => InternalServerError, SqlError: () => InternalServerError })
|
|
903
|
+
// )
|
|
904
|
+
// )
|
|
905
|
+
// Build update payload object: { ...payload, updated_at: DateTime.unsafeNow() }
|
|
906
|
+
const updateTimestampProps = info.updateTimestampFields.map((fieldName) => conjure.b.property("init", conjure.b.identifier(fieldName), conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("DateTime"), conjure.b.identifier("unsafeNow")), [])));
|
|
907
|
+
const updatePayloadObj = conjure.b.objectExpression([
|
|
908
|
+
conjure.b.spreadProperty(conjure.b.identifier("payload")),
|
|
909
|
+
...updateTimestampProps,
|
|
910
|
+
]);
|
|
911
|
+
// sql.update(payload) - builds SET clause from payload object
|
|
912
|
+
const sqlUpdateCall = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("sql"), conjure.b.identifier("update")), [updatePayloadObj]);
|
|
913
|
+
// Build template: sql`UPDATE table SET ${sql.update(...)} WHERE id = ${id} RETURNING *`
|
|
914
|
+
const updateQuery = conjure.b.taggedTemplateExpression(conjure.b.identifier("sql"), conjure.b.templateLiteral([
|
|
915
|
+
conjure.b.templateElement({ raw: `UPDATE ${qualifiedTable} SET `, cooked: `UPDATE ${qualifiedTable} SET ` }, false),
|
|
916
|
+
conjure.b.templateElement({ raw: ` WHERE ${info.idColumn} = `, cooked: ` WHERE ${info.idColumn} = ` }, false),
|
|
917
|
+
conjure.b.templateElement({ raw: " RETURNING *", cooked: " RETURNING *" }, true),
|
|
918
|
+
], [sqlUpdateCall, conjure.b.identifier("id")]));
|
|
919
|
+
// Effect.head
|
|
920
|
+
const effectHead = conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("head"));
|
|
921
|
+
// Effect.flatMap(Schema.decodeUnknown(Model))
|
|
922
|
+
const updateFlatMapDecode = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("flatMap")), [
|
|
923
|
+
conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Schema"), conjure.b.identifier("decodeUnknown")), [conjure.b.identifier(info.className)]),
|
|
924
|
+
]);
|
|
925
|
+
// Effect.catchTag("NoSuchElementException", () => Effect.fail(new NotFound({ id })))
|
|
926
|
+
const catchNotFound = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("catchTag")), [
|
|
927
|
+
conjure.str("NoSuchElementException"),
|
|
928
|
+
conjure.b.arrowFunctionExpression([], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("fail")), [
|
|
929
|
+
conjure.b.newExpression(conjure.b.identifier(info.errorName), [
|
|
930
|
+
obj().prop("id", conjure.b.identifier("id")).build(),
|
|
931
|
+
]),
|
|
932
|
+
])),
|
|
933
|
+
]);
|
|
934
|
+
// Effect.catchTags({ ParseError: () => ..., SqlError: () => ... })
|
|
935
|
+
const internalServerErrorFn = conjure.b.arrowFunctionExpression([], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("fail")), [
|
|
936
|
+
conjure.b.newExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiError"), conjure.b.identifier("InternalServerError")), []),
|
|
937
|
+
]));
|
|
938
|
+
const catchTags = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("catchTags")), [
|
|
939
|
+
obj()
|
|
940
|
+
.prop("ParseError", internalServerErrorFn)
|
|
941
|
+
.prop("SqlError", internalServerErrorFn)
|
|
942
|
+
.build(),
|
|
943
|
+
]);
|
|
944
|
+
// Full update pipeline
|
|
945
|
+
const updateQueryWithPipe = buildPipe(updateQuery, effectHead, updateFlatMapDecode, catchNotFound, catchTags);
|
|
946
|
+
const updateHandler = conjure.b.arrowFunctionExpression([
|
|
947
|
+
conjure.b.objectPattern([
|
|
948
|
+
conjure.b.property("init", conjure.b.identifier("path"), conjure.b.objectPattern([shorthandProp("id")])),
|
|
949
|
+
shorthandProp("payload"),
|
|
950
|
+
]),
|
|
951
|
+
], updateQueryWithPipe);
|
|
952
|
+
handlersChain = conjure.b.callExpression(conjure.b.memberExpression(handlersChain, conjure.b.identifier("handle")), [conjure.str("update"), updateHandler]);
|
|
953
|
+
// .handle("delete", ({ path: { id } }) => repo.delete(id))
|
|
954
|
+
// delete returns void directly, no Option matching needed
|
|
955
|
+
const deleteHandler = conjure.b.arrowFunctionExpression([
|
|
956
|
+
conjure.b.objectPattern([
|
|
957
|
+
conjure.b.property("init", conjure.b.identifier("path"), conjure.b.objectPattern([shorthandProp("id")])),
|
|
958
|
+
]),
|
|
959
|
+
], conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("repo"), conjure.b.identifier("delete")), [conjure.b.identifier("id")]));
|
|
960
|
+
handlersChain = conjure.b.callExpression(conjure.b.memberExpression(handlersChain, conjure.b.identifier("handle")), [conjure.str("delete"), deleteHandler]);
|
|
961
|
+
// return handlers chain
|
|
962
|
+
const returnStmt = conjure.b.returnStatement(handlersChain);
|
|
963
|
+
// Build the generator function body
|
|
964
|
+
const genBody = conjure.b.blockStatement([sqlDecl, repoDecl, returnStmt]);
|
|
965
|
+
// Build: function* () { ... }
|
|
966
|
+
const genFunc = conjure.b.functionExpression(null, [], genBody);
|
|
967
|
+
genFunc.generator = true;
|
|
968
|
+
// Build: Effect.gen(function* () { ... })
|
|
969
|
+
const effectGen = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Effect"), conjure.b.identifier("gen")), [genFunc]);
|
|
970
|
+
// Build: (handlers) => Effect.gen(...)
|
|
971
|
+
const handlersCallback = conjure.b.arrowFunctionExpression([conjure.b.identifier("handlers")], effectGen);
|
|
972
|
+
// Build: HttpApiBuilder.group(Api, "pluralName", callback)
|
|
973
|
+
const groupCall = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiBuilder"), conjure.b.identifier("group")), [conjure.b.identifier("Api"), conjure.str(info.pluralName), handlersCallback]);
|
|
974
|
+
return exp.const(handlersName, { capability: "http", entity: info.entity.name }, groupCall);
|
|
975
|
+
};
|
|
976
|
+
/**
|
|
977
|
+
* Generate: export const ApiLive = HttpApiBuilder.api(Api).pipe(Layer.provide(...), ...)
|
|
978
|
+
*/
|
|
979
|
+
const generateApiLive = (entities) => {
|
|
980
|
+
// Build: HttpApiBuilder.api(Api)
|
|
981
|
+
let apiExpr = conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("HttpApiBuilder"), conjure.b.identifier("api")), [conjure.b.identifier("Api")]);
|
|
982
|
+
// Chain .pipe(Layer.provide(Handler1), Layer.provide(Handler2), ...)
|
|
983
|
+
if (entities.length > 0) {
|
|
984
|
+
const pipeArgs = entities.map((info) => conjure.b.callExpression(conjure.b.memberExpression(conjure.b.identifier("Layer"), conjure.b.identifier("provide")), [conjure.b.identifier(`${info.className}Handlers`)]));
|
|
985
|
+
apiExpr = conjure.b.callExpression(conjure.b.memberExpression(apiExpr, conjure.b.identifier("pipe")), pipeArgs);
|
|
986
|
+
}
|
|
987
|
+
return exp.const("ApiLive", { capability: "http", entity: "Api" }, apiExpr);
|
|
988
|
+
};
|
|
989
|
+
/**
|
|
990
|
+
* Generate api/index.ts with just ApiLive composition
|
|
991
|
+
*/
|
|
992
|
+
const generateHttpApiIndex = (ctx, config, entities) => {
|
|
993
|
+
if (entities.length === 0)
|
|
994
|
+
return;
|
|
995
|
+
const httpConfig = config.http;
|
|
996
|
+
if (httpConfig === false || !httpConfig.enabled)
|
|
997
|
+
return;
|
|
998
|
+
const outputDir = normalizeOutputDir(config.outputDir);
|
|
999
|
+
const apiDir = httpConfig.outputDir;
|
|
1000
|
+
const filePath = buildPath(outputDir, apiDir, "index");
|
|
1001
|
+
const statements = [generateApiLive(entities)];
|
|
1002
|
+
const fileBuilder = ctx
|
|
1003
|
+
.file(filePath)
|
|
1004
|
+
.import({ kind: "package", names: ["HttpApiBuilder"], from: "@effect/platform" })
|
|
1005
|
+
.import({ kind: "package", names: ["Layer"], from: "effect" })
|
|
1006
|
+
.import({ kind: "relative", names: ["Api"], from: "./Api.js" });
|
|
1007
|
+
// Import each entity's handlers
|
|
1008
|
+
for (const info of entities) {
|
|
1009
|
+
fileBuilder.import({
|
|
1010
|
+
kind: "relative",
|
|
1011
|
+
names: [`${info.className}Handlers`],
|
|
1012
|
+
from: `./${info.className}.js`,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
fileBuilder.ast(conjure.symbolProgram(...statements)).emit();
|
|
1016
|
+
};
|
|
1017
|
+
// ============================================================================
|
|
1018
|
+
// Plugin Definition
|
|
1019
|
+
// ============================================================================
|
|
1020
|
+
/**
|
|
1021
|
+
* Effect Plugin
|
|
1022
|
+
*
|
|
1023
|
+
* Generates @effect/sql Model classes, repositories, and HTTP APIs.
|
|
1024
|
+
*
|
|
1025
|
+
* @example
|
|
1026
|
+
* ```typescript
|
|
1027
|
+
* import { effect } from "pg-sourcerer"
|
|
1028
|
+
*
|
|
1029
|
+
* export default defineConfig({
|
|
1030
|
+
* plugins: [
|
|
1031
|
+
* effect(),
|
|
1032
|
+
* effect({
|
|
1033
|
+
* outputDir: "generated/effect",
|
|
1034
|
+
* queryMode: "resolvers",
|
|
1035
|
+
* http: { basePath: "/api/v1" },
|
|
1036
|
+
* }),
|
|
1037
|
+
* ],
|
|
1038
|
+
* })
|
|
1039
|
+
* ```
|
|
1040
|
+
*/
|
|
1041
|
+
export function effect(config = {}) {
|
|
1042
|
+
const parsed = S.decodeUnknownSync(EffectConfigSchema)(config);
|
|
1043
|
+
return definePlugin({
|
|
1044
|
+
name: "effect",
|
|
1045
|
+
kind: "effect",
|
|
1046
|
+
singleton: true,
|
|
1047
|
+
canProvide: () => true,
|
|
1048
|
+
provide: (_params, _deps, ctx) => {
|
|
1049
|
+
// Phase 1: Generate Model classes
|
|
1050
|
+
generateModels(ctx, parsed);
|
|
1051
|
+
// Phase 2: Generate Repositories (if not disabled)
|
|
1052
|
+
generateRepositories(ctx, parsed);
|
|
1053
|
+
// Phase 3: Generate HTTP API (if enabled)
|
|
1054
|
+
const httpConfig = parsed.http;
|
|
1055
|
+
if (httpConfig !== false && httpConfig.enabled) {
|
|
1056
|
+
const entities = collectApiEntities(ctx.ir, ctx.inflection);
|
|
1057
|
+
if (entities.length > 0) {
|
|
1058
|
+
// Phase 3A: Generate api/Api.ts with combined Api class
|
|
1059
|
+
generateHttpApiClass(ctx, parsed, entities);
|
|
1060
|
+
// Phase 3B: Generate per-entity API groups + handlers
|
|
1061
|
+
generateHttpApi(ctx, parsed, entities);
|
|
1062
|
+
// Phase 3C: Generate api/index.ts with ApiLive
|
|
1063
|
+
generateHttpApiIndex(ctx, parsed, entities);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return undefined;
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* @deprecated Use `effect()` instead. This alias is provided for backward compatibility.
|
|
1072
|
+
*/
|
|
1073
|
+
export const effectPlugin = effect;
|
|
1074
|
+
//# sourceMappingURL=effect.js.map
|