@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.
Files changed (164) hide show
  1. package/dist/cli.js +3 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/config.js.map +1 -1
  7. package/dist/errors.d.ts +14 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +2 -0
  10. package/dist/errors.js.map +1 -1
  11. package/dist/generate.d.ts +5 -9
  12. package/dist/generate.d.ts.map +1 -1
  13. package/dist/generate.js +27 -29
  14. package/dist/generate.js.map +1 -1
  15. package/dist/index.d.ts +19 -12
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +25 -13
  18. package/dist/index.js.map +1 -1
  19. package/dist/init.d.ts.map +1 -1
  20. package/dist/init.js +39 -9
  21. package/dist/init.js.map +1 -1
  22. package/dist/ir/extensions/queries.d.ts +264 -0
  23. package/dist/ir/extensions/queries.d.ts.map +1 -0
  24. package/dist/ir/extensions/queries.js +153 -0
  25. package/dist/ir/extensions/queries.js.map +1 -0
  26. package/dist/ir/extensions/schema-builder.d.ts +61 -0
  27. package/dist/ir/extensions/schema-builder.d.ts.map +1 -0
  28. package/dist/ir/extensions/schema-builder.js +5 -0
  29. package/dist/ir/extensions/schema-builder.js.map +1 -0
  30. package/dist/lib/conjure.d.ts +66 -0
  31. package/dist/lib/conjure.d.ts.map +1 -1
  32. package/dist/lib/conjure.js +127 -29
  33. package/dist/lib/conjure.js.map +1 -1
  34. package/dist/lib/hex.d.ts +10 -3
  35. package/dist/lib/hex.d.ts.map +1 -1
  36. package/dist/lib/hex.js +18 -8
  37. package/dist/lib/hex.js.map +1 -1
  38. package/dist/plugins/arktype.d.ts +27 -14
  39. package/dist/plugins/arktype.d.ts.map +1 -1
  40. package/dist/plugins/arktype.js +166 -130
  41. package/dist/plugins/arktype.js.map +1 -1
  42. package/dist/plugins/effect.d.ts +53 -0
  43. package/dist/plugins/effect.d.ts.map +1 -0
  44. package/dist/plugins/effect.js +1074 -0
  45. package/dist/plugins/effect.js.map +1 -0
  46. package/dist/plugins/http-elysia.d.ts +32 -0
  47. package/dist/plugins/http-elysia.d.ts.map +1 -0
  48. package/dist/plugins/http-elysia.js +613 -0
  49. package/dist/plugins/http-elysia.js.map +1 -0
  50. package/dist/plugins/http-express.d.ts +36 -0
  51. package/dist/plugins/http-express.d.ts.map +1 -0
  52. package/dist/plugins/http-express.js +388 -0
  53. package/dist/plugins/http-express.js.map +1 -0
  54. package/dist/plugins/http-hono.d.ts +36 -0
  55. package/dist/plugins/http-hono.d.ts.map +1 -0
  56. package/dist/plugins/http-hono.js +453 -0
  57. package/dist/plugins/http-hono.js.map +1 -0
  58. package/dist/plugins/http-orpc.d.ts +55 -0
  59. package/dist/plugins/http-orpc.d.ts.map +1 -0
  60. package/dist/plugins/http-orpc.js +370 -0
  61. package/dist/plugins/http-orpc.js.map +1 -0
  62. package/dist/plugins/http-trpc.d.ts +59 -0
  63. package/dist/plugins/http-trpc.d.ts.map +1 -0
  64. package/dist/plugins/http-trpc.js +392 -0
  65. package/dist/plugins/http-trpc.js.map +1 -0
  66. package/dist/plugins/kysely/queries.d.ts +92 -0
  67. package/dist/plugins/kysely/queries.d.ts.map +1 -0
  68. package/dist/plugins/kysely/queries.js +1169 -0
  69. package/dist/plugins/kysely/queries.js.map +1 -0
  70. package/dist/plugins/kysely/shared.d.ts +59 -0
  71. package/dist/plugins/kysely/shared.d.ts.map +1 -0
  72. package/dist/plugins/kysely/shared.js +247 -0
  73. package/dist/plugins/kysely/shared.js.map +1 -0
  74. package/dist/plugins/kysely/types.d.ts +22 -0
  75. package/dist/plugins/kysely/types.d.ts.map +1 -0
  76. package/dist/plugins/kysely/types.js +428 -0
  77. package/dist/plugins/kysely/types.js.map +1 -0
  78. package/dist/plugins/kysely.d.ts +72 -0
  79. package/dist/plugins/kysely.d.ts.map +1 -0
  80. package/dist/plugins/kysely.js +906 -0
  81. package/dist/plugins/kysely.js.map +1 -0
  82. package/dist/plugins/sql-queries.d.ts +55 -11
  83. package/dist/plugins/sql-queries.d.ts.map +1 -1
  84. package/dist/plugins/sql-queries.js +467 -218
  85. package/dist/plugins/sql-queries.js.map +1 -1
  86. package/dist/plugins/types.d.ts +20 -14
  87. package/dist/plugins/types.d.ts.map +1 -1
  88. package/dist/plugins/types.js +90 -112
  89. package/dist/plugins/types.js.map +1 -1
  90. package/dist/plugins/valibot.d.ts +45 -0
  91. package/dist/plugins/valibot.d.ts.map +1 -0
  92. package/dist/plugins/valibot.js +422 -0
  93. package/dist/plugins/valibot.js.map +1 -0
  94. package/dist/plugins/zod.d.ts +27 -14
  95. package/dist/plugins/zod.d.ts.map +1 -1
  96. package/dist/plugins/zod.js +231 -166
  97. package/dist/plugins/zod.js.map +1 -1
  98. package/dist/services/artifact-store.d.ts +11 -1
  99. package/dist/services/artifact-store.d.ts.map +1 -1
  100. package/dist/services/artifact-store.js +9 -0
  101. package/dist/services/artifact-store.js.map +1 -1
  102. package/dist/services/core-providers.d.ts +15 -0
  103. package/dist/services/core-providers.d.ts.map +1 -0
  104. package/dist/services/core-providers.js +23 -0
  105. package/dist/services/core-providers.js.map +1 -0
  106. package/dist/services/emissions.d.ts +14 -0
  107. package/dist/services/emissions.d.ts.map +1 -1
  108. package/dist/services/emissions.js +86 -47
  109. package/dist/services/emissions.js.map +1 -1
  110. package/dist/services/execution.d.ts +35 -0
  111. package/dist/services/execution.d.ts.map +1 -0
  112. package/dist/services/execution.js +86 -0
  113. package/dist/services/execution.js.map +1 -0
  114. package/dist/services/file-builder.d.ts +4 -0
  115. package/dist/services/file-builder.d.ts.map +1 -1
  116. package/dist/services/file-builder.js.map +1 -1
  117. package/dist/services/inflection.d.ts +2 -2
  118. package/dist/services/inflection.d.ts.map +1 -1
  119. package/dist/services/inflection.js +4 -4
  120. package/dist/services/inflection.js.map +1 -1
  121. package/dist/services/ir-builder.d.ts.map +1 -1
  122. package/dist/services/ir-builder.js +10 -3
  123. package/dist/services/ir-builder.js.map +1 -1
  124. package/dist/services/pg-types.d.ts +31 -0
  125. package/dist/services/pg-types.d.ts.map +1 -1
  126. package/dist/services/pg-types.js +24 -0
  127. package/dist/services/pg-types.js.map +1 -1
  128. package/dist/services/plugin-runner.d.ts +27 -37
  129. package/dist/services/plugin-runner.d.ts.map +1 -1
  130. package/dist/services/plugin-runner.js +73 -171
  131. package/dist/services/plugin-runner.js.map +1 -1
  132. package/dist/services/plugin.d.ts +349 -217
  133. package/dist/services/plugin.d.ts.map +1 -1
  134. package/dist/services/plugin.js +182 -130
  135. package/dist/services/plugin.js.map +1 -1
  136. package/dist/services/resolution.d.ts +38 -0
  137. package/dist/services/resolution.d.ts.map +1 -0
  138. package/dist/services/resolution.js +242 -0
  139. package/dist/services/resolution.js.map +1 -0
  140. package/dist/services/service-registry.d.ts +74 -0
  141. package/dist/services/service-registry.d.ts.map +1 -0
  142. package/dist/services/service-registry.js +61 -0
  143. package/dist/services/service-registry.js.map +1 -0
  144. package/dist/services/symbols.d.ts +59 -0
  145. package/dist/services/symbols.d.ts.map +1 -1
  146. package/dist/services/symbols.js +16 -0
  147. package/dist/services/symbols.js.map +1 -1
  148. package/dist/testing.d.ts +4 -25
  149. package/dist/testing.d.ts.map +1 -1
  150. package/dist/testing.js +2 -23
  151. package/dist/testing.js.map +1 -1
  152. package/package.json +1 -1
  153. package/dist/plugins/effect-model.d.ts +0 -17
  154. package/dist/plugins/effect-model.d.ts.map +0 -1
  155. package/dist/plugins/effect-model.js +0 -409
  156. package/dist/plugins/effect-model.js.map +0 -1
  157. package/dist/plugins/kysely-queries.d.ts +0 -66
  158. package/dist/plugins/kysely-queries.d.ts.map +0 -1
  159. package/dist/plugins/kysely-queries.js +0 -951
  160. package/dist/plugins/kysely-queries.js.map +0 -1
  161. package/dist/plugins/kysely-types.d.ts +0 -35
  162. package/dist/plugins/kysely-types.d.ts.map +0 -1
  163. package/dist/plugins/kysely-types.js +0 -601
  164. 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