@metaobjectsdev/codegen-ts 0.16.0-rc.1 → 0.17.0-rc.1

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 (66) hide show
  1. package/dist/column-mapper.d.ts +10 -0
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +21 -2
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/naming.d.ts +4 -0
  6. package/dist/naming.d.ts.map +1 -1
  7. package/dist/naming.js +6 -0
  8. package/dist/naming.js.map +1 -1
  9. package/dist/projection/build-projection-views.d.ts +5 -1
  10. package/dist/projection/build-projection-views.d.ts.map +1 -1
  11. package/dist/projection/build-projection-views.js +165 -47
  12. package/dist/projection/build-projection-views.js.map +1 -1
  13. package/dist/projection/extract-view-spec.d.ts +8 -1
  14. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  15. package/dist/projection/extract-view-spec.js +430 -29
  16. package/dist/projection/extract-view-spec.js.map +1 -1
  17. package/dist/projection/view-ddl-emit.d.ts.map +1 -1
  18. package/dist/projection/view-ddl-emit.js +142 -25
  19. package/dist/projection/view-ddl-emit.js.map +1 -1
  20. package/dist/projection/view-spec.d.ts +101 -3
  21. package/dist/projection/view-spec.d.ts.map +1 -1
  22. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  23. package/dist/templates/drizzle-schema.js +9 -0
  24. package/dist/templates/drizzle-schema.js.map +1 -1
  25. package/dist/templates/entity-file.d.ts.map +1 -1
  26. package/dist/templates/entity-file.js +39 -3
  27. package/dist/templates/entity-file.js.map +1 -1
  28. package/dist/templates/inferred-types.d.ts +1 -1
  29. package/dist/templates/inferred-types.d.ts.map +1 -1
  30. package/dist/templates/inferred-types.js +13 -1
  31. package/dist/templates/inferred-types.js.map +1 -1
  32. package/dist/templates/projection-decl.d.ts.map +1 -1
  33. package/dist/templates/projection-decl.js +9 -70
  34. package/dist/templates/projection-decl.js.map +1 -1
  35. package/dist/templates/queries-file.d.ts.map +1 -1
  36. package/dist/templates/queries-file.js +117 -43
  37. package/dist/templates/queries-file.js.map +1 -1
  38. package/dist/templates/queries.d.ts +21 -4
  39. package/dist/templates/queries.d.ts.map +1 -1
  40. package/dist/templates/queries.js +48 -12
  41. package/dist/templates/queries.js.map +1 -1
  42. package/dist/templates/view-decl.d.ts +28 -0
  43. package/dist/templates/view-decl.d.ts.map +1 -0
  44. package/dist/templates/view-decl.js +107 -0
  45. package/dist/templates/view-decl.js.map +1 -0
  46. package/dist/templates/zod-validators.d.ts +6 -0
  47. package/dist/templates/zod-validators.d.ts.map +1 -1
  48. package/dist/templates/zod-validators.js +53 -7
  49. package/dist/templates/zod-validators.js.map +1 -1
  50. package/package.json +6 -6
  51. package/src/column-mapper.ts +26 -1
  52. package/src/naming.ts +7 -0
  53. package/src/projection/build-projection-views.ts +211 -50
  54. package/src/projection/extract-view-spec.ts +468 -29
  55. package/src/projection/view-ddl-emit.ts +158 -24
  56. package/src/projection/view-spec.ts +104 -3
  57. package/src/reference/entity.ts +11 -1
  58. package/src/reference/queries.ts +4 -1
  59. package/src/templates/drizzle-schema.ts +7 -0
  60. package/src/templates/entity-file.ts +46 -3
  61. package/src/templates/inferred-types.ts +18 -1
  62. package/src/templates/projection-decl.ts +8 -74
  63. package/src/templates/queries-file.ts +133 -48
  64. package/src/templates/queries.ts +50 -11
  65. package/src/templates/view-decl.ts +128 -0
  66. package/src/templates/zod-validators.ts +54 -9
@@ -10,18 +10,17 @@
10
10
  import { code, imp, joinCode, type Code } from "ts-poet";
11
11
  import {
12
12
  MetaField, MetaObject, type MetaRoot,
13
- FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage,
14
13
  } from "@metaobjectsdev/metadata";
15
14
  import { projectionViewName } from "../projection/extract-view-spec.js";
16
15
  import { columnNameFromField, toSnakeCase, pluralize } from "../naming.js";
17
16
  import { GENERATED_HEADER } from "../constants.js";
18
17
  import type { ColumnNamingStrategy } from "../metaobjects-config.js";
19
18
  import type { RenderContext } from "../render-context.js";
20
- import { mapColumnType } from "../column-mapper.js";
21
19
  import { valueObjectModuleSpecifier } from "../import-path.js";
22
20
  import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
23
21
  import { renderFilterType } from "./filter-type.js";
24
- import { inferViewKind, zodTypeFor, currencyMetaFor, labelFor } from "./field-meta.js";
22
+ import { inferViewKind, currencyMetaFor, labelFor } from "./field-meta.js";
23
+ import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js";
25
24
 
26
25
  // ---------------------------------------------------------------------------
27
26
  // Public interface
@@ -100,16 +99,7 @@ export function renderProjectionDecl(
100
99
  ctx
101
100
  ? valueObjectModuleSpecifier(refBase, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle)
102
101
  : `./${refBase}.js`;
103
- const objectRefOf = (f: MetaField): string | undefined => {
104
- if (f.subType !== FIELD_SUBTYPE_OBJECT) return undefined;
105
- const ref = f.attr(FIELD_ATTR_OBJECT_REF);
106
- return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined;
107
- };
108
102
 
109
- const viewFn = dialect === "postgres" ? "pgView" : "sqliteView";
110
- const viewModule =
111
- dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
112
- const viewSym = imp(`${viewFn}@${viewModule}`);
113
103
  const z = imp("z@zod");
114
104
 
115
105
  // Read-model generation needs only the view name — NOT the join/DDL
@@ -136,57 +126,6 @@ export function renderProjectionDecl(
136
126
  // inherited fields are not duplicated.
137
127
  for (const f of projection.ownFields()) allFields.push(f);
138
128
 
139
- // A field.object passthrough carries the value-object's Zod schema (so the
140
- // view's read schema + inferred type expose the VO shape, not z.unknown()).
141
- const zodLines: Code[] = allFields.map((f) => {
142
- // The read schema's nullability MUST mirror the view column's: a column that
143
- // is not `.notNull()` infers `T | null` in Drizzle's SELECT type, so the Zod
144
- // read type (and thus the generated query's return type) must be `.nullable()`
145
- // too — otherwise `db.select().from(view)` yields `T | null` into a non-null
146
- // `<Name>` field and the generated query fails to compile under strict TS.
147
- const nullable =
148
- mapColumnType(f, dialect, columnNamingStrategy, timestampMode)
149
- .modifiers.includes(".notNull()")
150
- ? ""
151
- : ".nullable()";
152
- const refBase = objectRefOf(f);
153
- if (refBase) {
154
- const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`);
155
- const base = f.resolvedIsArray()
156
- ? code`${z}.array(${schemaSym})`
157
- : code`${schemaSym}`;
158
- return code` ${f.name}: ${base}${nullable}`;
159
- }
160
- return code` ${f.name}: ${z}.${zodTypeFor(f).replace(/^z\./, "")}${nullable}`;
161
- });
162
-
163
- // Typed view column map for the Drizzle `.existing()` declaration — keyed by
164
- // projection field name, valued by the column builder for the (renamed)
165
- // physical view column, so `db.select().from(<view>)` is typed. Honors
166
- // `@dbColumnType` (e.g. a passthrough of a jsonb column). `.existing()` views
167
- // carry type + physical name only — no PK/default/notNull modifiers.
168
- const viewColumnLines: Code[] = allFields.map((f) => {
169
- const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
170
- const colSym = imp(`${spec.fnName}@${spec.importModule}`);
171
- const optsArg =
172
- spec.fnOptions && Object.keys(spec.fnOptions).length > 0
173
- ? `, ${JSON.stringify(spec.fnOptions)}`
174
- : "";
175
- // Of the table modifiers, only `.notNull()` carries to an existing view (it
176
- // shapes the SELECT type); `.primaryKey()`/`.default()`/`.references()` are
177
- // table-DDL concerns and invalid on a `.existing()` view declaration.
178
- const notNull = spec.modifiers.includes(".notNull()") ? ".notNull()" : "";
179
- // Narrow a jsonb passthrough to its value-object type — `.$type<VO>()` —
180
- // mirroring the entity column, so the read row is typed (not `unknown`).
181
- let dollarType: Code | string = "";
182
- const dtr = spec.dollarTypeRef;
183
- if (dtr?.kind === "objectRef") {
184
- const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`);
185
- dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`;
186
- }
187
- return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${notNull}`;
188
- });
189
-
190
129
  const constFieldLines: string[] = allFields.map((f) => {
191
130
  const dbCol = columnNameFromField(f.name, columnNamingStrategy);
192
131
  const view = inferViewKind(f);
@@ -205,19 +144,14 @@ export function renderProjectionDecl(
205
144
 
206
145
  const sections: Code[] = [
207
146
  ...(includeViewDecl
208
- ? [code`
209
- // View declaration — Drizzle uses this for typed SELECT queries.
210
- // The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
211
- // not to attempt DDL for this declaration.
212
- export const ${camelName}View = ${viewSym}(${JSON.stringify(viewName)}, {
213
- ${joinCode(viewColumnLines, { on: ",\n" })}
214
- }).existing();
215
- `]
147
+ ? [renderExistingViewDecl(allFields, viewName, `${camelName}View`, {
148
+ dialect, columnNamingStrategy, timestampMode, voModule,
149
+ })]
216
150
  : []),
217
151
  code`
218
- export const ${projName}Schema = ${z}.object({
219
- ${joinCode(zodLines, { on: ",\n" })}
220
- });
152
+ export const ${projName}Schema = ${renderViewReadZodObject(allFields, {
153
+ dialect, columnNamingStrategy, timestampMode, voModule,
154
+ })};
221
155
  `,
222
156
  code`
223
157
  export type ${projName} = ${z}.infer<typeof ${projName}Schema>;
@@ -13,16 +13,40 @@ import {
13
13
  renderFindByIdFn,
14
14
  renderListFn,
15
15
  renderCreateFn,
16
+ renderInsertPreservingFn,
16
17
  renderUpdateFn,
17
18
  renderDeleteByIdFn,
18
19
  renderReverseFinderFns,
19
20
  reverseFksFor,
20
21
  getPkInfo,
22
+ getPkFields,
21
23
  } from "./queries.js";
22
- import { pluralize, findByIdFnName, listFnName } from "../naming.js";
24
+ import { pluralize, findByIdFnName, listFnName, createFnName, insertPreservingFnName, updateFnName } from "../naming.js";
23
25
  import { GENERATED_HEADER } from "../constants.js";
24
26
  import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator.js";
25
- import { isProjection } from "../projection/projection-detector.js";
27
+ import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
28
+ import { hasAutoSetFields } from "./zod-validators.js";
29
+
30
+ /**
31
+ * The dialect-correct Drizzle `Db` type alias + its import — parameter-passed into every
32
+ * generated CRUD helper (ADR-0008), so the signatures `find…(db: Db, …)` typecheck without
33
+ * the consumer importing anything to construct one. Shared by every queries-file renderer.
34
+ * - Postgres: `PgDatabase<…>` is the base every PG driver extends (node-postgres,
35
+ * postgres.js, Neon, Vercel, pglite) — accepts whichever driver the consumer chose.
36
+ * - SQLite: `BaseSQLiteDatabase<"sync" | "async", …>` accepts BOTH sync (better-sqlite3)
37
+ * and async (libsql/Turso/D1) drivers; the generated queries `await` results, valid on either.
38
+ */
39
+ function dbTypeBlock(dialect: "postgres" | "sqlite"): { import: string; alias: string } {
40
+ return dialect === "postgres"
41
+ ? {
42
+ import: `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`,
43
+ alias: `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`,
44
+ }
45
+ : {
46
+ import: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`,
47
+ alias: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`,
48
+ };
49
+ }
26
50
 
27
51
  export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
28
52
  // FR-017 Tier 2 — a TPH discriminator base gets a polymorphic queries file:
@@ -42,6 +66,13 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
42
66
  return renderProjectionQueriesFile(obj, ctx);
43
67
  }
44
68
 
69
+ // #214 — a write-through entity read-view (FR-024 §7): READS route to the replica
70
+ // view (returning the derived fields), WRITES target the table. A hybrid of the
71
+ // projection read path + the vanilla write path.
72
+ if (isWriteThrough(obj)) {
73
+ return renderWriteThroughQueriesFile(obj, ctx);
74
+ }
75
+
45
76
  const entityName = obj.name;
46
77
  // Import the entity's own file. Same target → relative "./Entity"; cross
47
78
  // target → importBase-qualified package path.
@@ -58,21 +89,12 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
58
89
  // helper (ADR-0008). Emit the dialect-correct Drizzle type alias so the
59
90
  // signatures `findXxx(db: Db, ...)` typecheck without the consumer importing
60
91
  // anything to construct one. Consumers pass any compatible Drizzle instance.
61
- const dbTypeImport =
62
- ctx.dialect === "postgres"
63
- ? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
64
- : `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
65
- const dbTypeAlias =
66
- ctx.dialect === "postgres"
67
- // Postgres: the base class every PG driver extends (node-postgres,
68
- // postgres.js, Neon, Vercel, pglite) — not just node-postgres, so any
69
- // PG driver the consumer chose is accepted.
70
- ? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
71
- // SQLite: accept BOTH sync (better-sqlite3) and async (libsql/Turso/D1)
72
- // drivers. Generated queries `await` their results, which is valid on
73
- // either result-kind; pinning `<"async">` wrongly rejected better-sqlite3
74
- // (the most common SQLite driver) with "is not assignable".
75
- : `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
92
+ const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
93
+
94
+ // #203 an @autoSet entity additionally imports its preserving-shape schema
95
+ // and emits the `insertPreserving<Entity>` escape hatch after `create<Entity>`.
96
+ const autoSet = hasAutoSetFields(obj);
97
+ const preservingImport = autoSet ? `, ${entityName}InsertPreservingSchema` : "";
76
98
 
77
99
  // Literal imports (Db type + entity types) live in a code block so they sort
78
100
  // alongside ts-poet's hoisted imp() imports at the top of the body.
@@ -80,7 +102,7 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
80
102
  ${dbTypeImport}
81
103
  ${dbTypeAlias}
82
104
 
83
- import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
105
+ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema${preservingImport}, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
84
106
  `;
85
107
 
86
108
  const sections: Code[] = [
@@ -88,6 +110,7 @@ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}I
88
110
  renderFindByIdFn(obj, ctx),
89
111
  renderListFn(obj, ctx),
90
112
  renderCreateFn(obj, ctx),
113
+ ...(autoSet ? [renderInsertPreservingFn(obj, ctx)] : []),
91
114
  renderUpdateFn(obj, ctx),
92
115
  renderDeleteByIdFn(obj, ctx),
93
116
  ];
@@ -130,21 +153,7 @@ function renderProjectionQueriesFile(obj: MetaObject, ctx: RenderContext): strin
130
153
  const { fieldName: pkField, tsType: pkType } = getPkInfo(obj, ctx);
131
154
  const eqSym = imp("eq@drizzle-orm");
132
155
 
133
- const dbTypeImport =
134
- ctx.dialect === "postgres"
135
- ? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
136
- : `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
137
- const dbTypeAlias =
138
- ctx.dialect === "postgres"
139
- // Postgres: the base class every PG driver extends (node-postgres,
140
- // postgres.js, Neon, Vercel, pglite) — not just node-postgres, so any
141
- // PG driver the consumer chose is accepted.
142
- ? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
143
- // SQLite: accept BOTH sync (better-sqlite3) and async (libsql/Turso/D1)
144
- // drivers. Generated queries `await` their results, which is valid on
145
- // either result-kind; pinning `<"async">` wrongly rejected better-sqlite3
146
- // (the most common SQLite driver) with "is not assignable".
147
- : `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
156
+ const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
148
157
 
149
158
  const literalImports = code`
150
159
  ${dbTypeImport}
@@ -175,6 +184,96 @@ export async function ${listFnName(entityName)}(db: Db, opts?: { limit?: number;
175
184
  return header + body;
176
185
  }
177
186
 
187
+ /**
188
+ * #214 — the queries file for a write-through entity read-view (FR-024 §7).
189
+ *
190
+ * Reads (`find<Name>ById`, `list<Plural>`, reverse finders) SELECT from the replica
191
+ * `<camel>View` so they return the derived fields; writes (`create`/`update`/`delete`)
192
+ * target the write TABLE (derived-free, #213). A create/update re-reads the row THROUGH
193
+ * the view by PK — a derived field is only computable via the view's join, so the
194
+ * returned `<Entity>` (view shape) is read-your-writes correct.
195
+ */
196
+ function renderWriteThroughQueriesFile(obj: MetaObject, ctx: RenderContext): string {
197
+ const entityName = obj.name;
198
+ const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
199
+ const viewVar = `${camelName}View`;
200
+ const tableVar = ctx.collectionName(entityName);
201
+ const singularVar = camelName;
202
+ const entityFileName = entityModuleSpecifier(
203
+ ctx.selfTarget, ctx.entityModuleTarget, obj.package, entityName, ctx.extStyle,
204
+ );
205
+ const { fieldName: pkField, tsType: pkType } = getPkInfo(obj, ctx);
206
+ const pkFields = getPkFields(obj);
207
+ const eqSym = imp("eq@drizzle-orm");
208
+ const andSym = imp("and@drizzle-orm");
209
+ const autoSet = hasAutoSetFields(obj);
210
+
211
+ const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
212
+
213
+ const preservingImport = autoSet ? `, ${entityName}InsertPreservingSchema` : "";
214
+ const literalImports = code`
215
+ ${dbTypeImport}
216
+ ${dbTypeAlias}
217
+
218
+ import { ${viewVar}, ${tableVar}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema${preservingImport}, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
219
+ `;
220
+
221
+ // The view re-read predicate keyed on ALL primary-key columns of `source` (the
222
+ // insert's returning() row), so a composite PK re-reads the EXACT written row rather
223
+ // than any view row sharing the first key component. `andSym`/`pkFields[i]` are only
224
+ // referenced when the PK is composite, so a single-PK entity emits a bare `eq(...)`.
225
+ const viewByAllPk = (source: string): Code =>
226
+ pkFields.length > 1
227
+ ? code`${andSym}(${joinCode(pkFields.map((f) => code`${eqSym}(${viewVar}.${f}, ${source}.${f})`), { on: ", " })})`
228
+ : code`${eqSym}(${viewVar}.${pkField}, ${source}.${pkField})`;
229
+
230
+ // A create/insertPreserving writes the table, then reads the persisted row back
231
+ // through the view so the returned <Entity> carries the derived fields.
232
+ const insertReturningView = (fnName: string, schemaName: string): Code => code`
233
+ export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
234
+ const validated = ${schemaName}.parse(data);
235
+ const [${singularVar}] = await db.insert(${tableVar}).values(validated).returning();
236
+ const [row] = await db.select().from(${viewVar}).where(${viewByAllPk(`${singularVar}!`)}).limit(1);
237
+ return row!;
238
+ }
239
+ `;
240
+
241
+ const updateFn = code`
242
+ export async function ${updateFnName(entityName)}(db: Db, ${pkField}: ${pkType}, patch: ${entityName}Patch): Promise<${entityName} | null> {
243
+ const validated = ${entityName}UpdateSchema.parse(patch);
244
+ // PATCH-5: an empty patch is a no-op — return the current (view) row.
245
+ if (Object.keys(validated).length === 0) return ${findByIdFnName(entityName)}(db, ${pkField});
246
+ const updated = await db.update(${tableVar}).set(validated).where(${eqSym}(${tableVar}.${pkField}, ${pkField})).returning();
247
+ if (updated.length === 0) return null;
248
+ const [row] = await db.select().from(${viewVar}).where(${eqSym}(${viewVar}.${pkField}, ${pkField})).limit(1);
249
+ return row ?? null;
250
+ }
251
+ `;
252
+
253
+ const sections: Code[] = [
254
+ literalImports,
255
+ // Reads route to the replica view.
256
+ renderFindByIdFn(obj, ctx, viewVar),
257
+ renderListFn(obj, ctx, viewVar),
258
+ // Writes target the table (create/update re-read through the view; delete is boolean).
259
+ insertReturningView(createFnName(entityName), `${entityName}InsertSchema`),
260
+ ...(autoSet ? [insertReturningView(insertPreservingFnName(entityName), `${entityName}InsertPreservingSchema`)] : []),
261
+ updateFn,
262
+ renderDeleteByIdFn(obj, ctx),
263
+ ];
264
+ // Reverse finders are reads → route to the view.
265
+ for (const fk of reverseFksFor(obj)) {
266
+ sections.push(renderReverseFinderFns(obj, fk, ctx, viewVar));
267
+ }
268
+
269
+ const body = joinCode(sections, { on: "\n" }).toString();
270
+ const header =
271
+ `// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
272
+ `// Source metadata: ${entityName} (${obj.fqn()}) — write-through entity read-view (reads → view, writes → table)\n` +
273
+ `// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
274
+ return header + body;
275
+ }
276
+
178
277
  /**
179
278
  * FR-017 Tier 2 — the polymorphic + per-subtype queries file for a TPH base.
180
279
  *
@@ -203,21 +302,7 @@ function renderTphQueriesFile(base: MetaObject, ctx: RenderContext): string {
203
302
  const eqSym = imp("eq@drizzle-orm");
204
303
  const andSym = imp("and@drizzle-orm");
205
304
 
206
- const dbTypeImport =
207
- ctx.dialect === "postgres"
208
- ? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
209
- : `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
210
- const dbTypeAlias =
211
- ctx.dialect === "postgres"
212
- // Postgres: the base class every PG driver extends (node-postgres,
213
- // postgres.js, Neon, Vercel, pglite) — not just node-postgres, so any
214
- // PG driver the consumer chose is accepted.
215
- ? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
216
- // SQLite: accept BOTH sync (better-sqlite3) and async (libsql/Turso/D1)
217
- // drivers. Generated queries `await` their results, which is valid on
218
- // either result-kind; pinning `<"async">` wrongly rejected better-sqlite3
219
- // (the most common SQLite driver) with "is not assignable".
220
- : `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
305
+ const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
221
306
 
222
307
  // --- Polymorphic base reads ---
223
308
  const polymorphic = code`
@@ -9,6 +9,7 @@ import {
9
9
  findByIdFnName,
10
10
  listFnName,
11
11
  createFnName,
12
+ insertPreservingFnName,
12
13
  updateFnName,
13
14
  deleteByIdFnName,
14
15
  reverseFinderFnName,
@@ -27,17 +28,28 @@ function subTypeToTsType(subType: string): "number" | "boolean" | "string" {
27
28
  /** Get the PK field name and its TS type for a given entity. */
28
29
  export function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: string; tsType: string } {
29
30
  // Use primaryIdentity() to find the primary identity (may be inherited from extends:/super:).
30
- const primary = entity.primaryIdentity();
31
- const rawFields = primary?.attr(IDENTITY_ATTR_FIELDS);
32
- const fields = Array.isArray(rawFields) ? rawFields : (typeof rawFields === "string" ? [rawFields] : undefined);
33
- const pkFieldName = fields?.[0] ?? "id";
31
+ const pkFieldName = getPkFields(entity)[0] ?? "id";
34
32
  const pkInfo = ctx.pkMap.get(entity.name);
35
33
  const subType = pkInfo?.fieldSubType ?? "long";
36
34
  return { fieldName: pkFieldName, tsType: subTypeToTsType(subType) };
37
35
  }
38
36
 
39
- export function renderFindByIdFn(entity: MetaObject, ctx: RenderContext): Code {
40
- const varName = ctx.collectionName(entity.name);
37
+ /** All primary-key field names, in order. `getPkInfo` exposes only the first (the
38
+ * query layer's single-PK finder/update signatures key on it); #214's write-through
39
+ * create re-read keys on ALL of them so a composite PK re-reads the exact written row
40
+ * (via the insert's returning() values), not any row sharing the first key component. */
41
+ export function getPkFields(entity: MetaObject): string[] {
42
+ const rawFields = entity.primaryIdentity()?.attr(IDENTITY_ATTR_FIELDS);
43
+ if (Array.isArray(rawFields)) return rawFields.filter((f): f is string => typeof f === "string");
44
+ return typeof rawFields === "string" ? [rawFields] : [];
45
+ }
46
+
47
+ // #214 — a write-through entity read-view routes READS to its replica view; the read
48
+ // renderers below accept an optional `readVar` (the collection to SELECT from). Absent,
49
+ // they read the entity's own table (the vanilla path, byte-identical). Writes always
50
+ // target the table, so create/update/delete take no such override.
51
+ export function renderFindByIdFn(entity: MetaObject, ctx: RenderContext, readVar?: string): Code {
52
+ const varName = readVar ?? ctx.collectionName(entity.name);
41
53
  const entityName = entity.name;
42
54
  const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
43
55
  const { fieldName: pkField, tsType: pkType } = getPkInfo(entity, ctx);
@@ -52,8 +64,8 @@ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise<${entity
52
64
  `;
53
65
  }
54
66
 
55
- export function renderListFn(entity: MetaObject, ctx: RenderContext): Code {
56
- const varName = ctx.collectionName(entity.name);
67
+ export function renderListFn(entity: MetaObject, ctx: RenderContext, readVar?: string): Code {
68
+ const varName = readVar ?? ctx.collectionName(entity.name);
57
69
  const entityName = entity.name;
58
70
  // Pluralize the PascalCase entity name, preserving capitalization
59
71
  // (e.g., "Category" -> "Categories", not "Categorys").
@@ -85,6 +97,31 @@ export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
85
97
  `;
86
98
  }
87
99
 
100
+ /**
101
+ * #203 — the `insertPreserving<Entity>` escape hatch. Cross-port with the Java /
102
+ * Kotlin / C# / Python ports: emitted ONLY for an entity that declares @autoSet
103
+ * fields, it persists the row WITHOUT the create-time now() stamp — the @autoSet
104
+ * columns are written verbatim from the caller's data — for import / restore /
105
+ * replication paths that must keep the original timestamps. It validates through
106
+ * `<Entity>InsertPreservingSchema` (the preserving-shape schema whose @autoSet
107
+ * columns carry no transform); the normal `create<Entity>` always stamps now().
108
+ */
109
+ export function renderInsertPreservingFn(entity: MetaObject, ctx: RenderContext): Code {
110
+ const varName = ctx.collectionName(entity.name);
111
+ const entityName = entity.name;
112
+ const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
113
+ const fnName = insertPreservingFnName(entityName);
114
+ const schemaName = `${entityName}InsertPreservingSchema`;
115
+
116
+ return code`
117
+ export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
118
+ const validated = ${schemaName}.parse(data);
119
+ const [${singularVar}] = await db.insert(${varName}).values(validated).returning();
120
+ return ${singularVar}!;
121
+ }
122
+ `;
123
+ }
124
+
88
125
  export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
89
126
  const varName = ctx.collectionName(entity.name);
90
127
  const entityName = entity.name;
@@ -157,9 +194,11 @@ export function reverseFksFor(entity: MetaObject): ReverseFk[] {
157
194
  return out;
158
195
  }
159
196
 
160
- /** Render the single + batched reverse finders for one FK on `entity`. */
161
- export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: RenderContext): Code {
162
- const varName = ctx.collectionName(entity.name);
197
+ /** Render the single + batched reverse finders for one FK on `entity`. `readVar`
198
+ * (#214) routes the SELECT to a write-through entity's replica view; absent, it
199
+ * reads the entity's own table (vanilla, byte-identical). */
200
+ export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: RenderContext, readVar?: string): Code {
201
+ const varName = readVar ?? ctx.collectionName(entity.name);
163
202
  const entityName = entity.name;
164
203
  // The Drizzle table object is keyed by the LOGICAL field name (the DB column
165
204
  // name is the argument to integer()/text()), so column access uses fk.fkField.
@@ -0,0 +1,128 @@
1
+ // Shared Drizzle `.existing()` view declaration builder.
2
+ //
3
+ // A view-backed read model — a projection (read-only, view-only) OR the replica
4
+ // view of a write-through entity (FR-024 §7, #214) — declares its physical SQL
5
+ // view to Drizzle via `<viewVar> = pgView/sqliteView(<name>, { …cols }).existing()`
6
+ // so `db.select().from(<viewVar>)` is typed and `.existing()` tells Drizzle not to
7
+ // attempt DDL (the SQL view is created/owned by migrate-ts). Both hosts emit the
8
+ // SAME declaration shape, so it lives here once (extracted from projection-decl).
9
+
10
+ import { code, imp, joinCode, type Code } from "ts-poet";
11
+ import {
12
+ type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage,
13
+ } from "@metaobjectsdev/metadata";
14
+ import type { ColumnNamingStrategy } from "../metaobjects-config.js";
15
+ import { mapColumnType } from "../column-mapper.js";
16
+ import { zodTypeFor } from "./field-meta.js";
17
+
18
+ export interface ViewDeclOpts {
19
+ readonly dialect: "postgres" | "sqlite";
20
+ readonly columnNamingStrategy: ColumnNamingStrategy;
21
+ /** Drives the timestamp column TS type (Date vs string) in the view declaration. */
22
+ readonly timestampMode: "date" | "string";
23
+ /** Resolve a value-object short name → its import module specifier. */
24
+ readonly voModule: (refBase: string) => string;
25
+ }
26
+
27
+ /**
28
+ * The typed view column map for a Drizzle `.existing()` declaration — keyed by
29
+ * field name, valued by the column builder for the (renamed) physical view column,
30
+ * so `db.select().from(<view>)` is typed. Honors `@dbColumnType`; `.existing()`
31
+ * views carry type + physical name only (no PK/default/notNull DDL modifiers).
32
+ */
33
+ function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code {
34
+ const { dialect, columnNamingStrategy, timestampMode, voModule } = opts;
35
+ const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
36
+ const colSym = imp(`${spec.fnName}@${spec.importModule}`);
37
+ const optsArg =
38
+ spec.fnOptions && Object.keys(spec.fnOptions).length > 0
39
+ ? `, ${JSON.stringify(spec.fnOptions)}`
40
+ : "";
41
+ // #204 — of the table modifiers, only `.array()` (postgres native array element
42
+ // typing) and `.notNull()` (shapes the SELECT type) carry to an existing view;
43
+ // `.primaryKey()`/`.default()`/`.references()`/`.unique()` are table-DDL concerns
44
+ // and invalid on a `.existing()` view declaration. Preserve the canonical order.
45
+ const viewModifiers = spec.modifiers
46
+ .filter((m) => m === ".array()" || m === ".notNull()")
47
+ .join("");
48
+ // Narrow the column to its resolved element/value type — `.$type<…>()` — mirroring
49
+ // the entity column so the read row is typed (not `unknown`) and an array column
50
+ // reads as `T[]`, not `T`.
51
+ let dollarType: Code | string = "";
52
+ const dtr = spec.dollarTypeRef;
53
+ if (dtr?.kind === "scalar") {
54
+ dollarType = `.$type<${dtr.tsType}${dtr.array ? "[]" : ""}>()`;
55
+ } else if (dtr?.kind === "objectRef") {
56
+ const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`);
57
+ dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`;
58
+ } else if (dtr?.kind === "map") {
59
+ dollarType = "scalar" in dtr.value
60
+ ? `.$type<Record<string, ${dtr.value.scalar}>>()`
61
+ : code`.$type<Record<string, ${imp(`${dtr.value.objectRef}@${voModule(dtr.value.objectRef)}`)}>>()`;
62
+ }
63
+ return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${viewModifiers}`;
64
+ }
65
+
66
+ /**
67
+ * Emit `export const <viewVar> = <viewFn>(<viewName>, { …cols }).existing();` for a
68
+ * view-backed read model (projection or write-through replica view). `fields` are the
69
+ * view's exposed columns (already resolved to effective fields by the caller).
70
+ */
71
+ export function renderExistingViewDecl(
72
+ fields: readonly MetaField[],
73
+ viewName: string,
74
+ viewVar: string,
75
+ opts: ViewDeclOpts,
76
+ ): Code {
77
+ const viewFn = opts.dialect === "postgres" ? "pgView" : "sqliteView";
78
+ const viewModule = opts.dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
79
+ const viewSym = imp(`${viewFn}@${viewModule}`);
80
+ const viewColumnLines = fields.map((f) => viewColumnLine(f, opts));
81
+ return code`
82
+ // View declaration — Drizzle uses this for typed SELECT queries.
83
+ // The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
84
+ // not to attempt DDL for this declaration.
85
+ export const ${viewVar} = ${viewSym}(${JSON.stringify(viewName)}, {
86
+ ${joinCode(viewColumnLines, { on: ",\n" })}
87
+ }).existing();
88
+ `;
89
+ }
90
+
91
+ /**
92
+ * The Zod object body for a view-backed read model — one `field: z.<type>[.nullable()]`
93
+ * line per column, the read counterpart of {@link renderExistingViewDecl}. A column that
94
+ * is not `.notNull()` in the view is `.nullable()` (Drizzle's SELECT infers `T | null`),
95
+ * so `z.infer<>` of the object equals the view row type. This is the dialect-agnostic read
96
+ * type (a Drizzle view exposes `$inferSelect` on Postgres but not SQLite). A `field.object`
97
+ * passthrough carries the value-object's schema so the read type exposes the VO shape.
98
+ * `voModule` resolves a value-object short name → its import module.
99
+ */
100
+ export function renderViewReadZodObject(fields: readonly MetaField[], opts: ViewDeclOpts): Code {
101
+ const { dialect, columnNamingStrategy, timestampMode, voModule } = opts;
102
+ const z = imp("z@zod");
103
+ const lines: Code[] = fields.map((f) => {
104
+ const nullable =
105
+ mapColumnType(f, dialect, columnNamingStrategy, timestampMode).modifiers.includes(".notNull()")
106
+ ? ""
107
+ : ".nullable()";
108
+ const refBase =
109
+ f.subType === FIELD_SUBTYPE_OBJECT
110
+ ? (() => {
111
+ const ref = f.attr(FIELD_ATTR_OBJECT_REF);
112
+ return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined;
113
+ })()
114
+ : undefined;
115
+ if (refBase) {
116
+ const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`);
117
+ const base = f.resolvedIsArray() ? code`${z}.array(${schemaSym})` : code`${schemaSym}`;
118
+ return code` ${f.name}: ${base}${nullable}`;
119
+ }
120
+ // #204 — an array passthrough reads as `T[]`; zodTypeFor returns the ELEMENT type.
121
+ const inner = code`${z}.${zodTypeFor(f).replace(/^z\./, "")}`;
122
+ const zbase = f.resolvedIsArray() ? code`${z}.array(${inner})` : inner;
123
+ return code` ${f.name}: ${zbase}${nullable}`;
124
+ });
125
+ return code`${z}.object({
126
+ ${joinCode(lines, { on: ",\n" })}
127
+ })`;
128
+ }