appflare 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Documentation.md CHANGED
@@ -45,6 +45,33 @@ Schema is defined with `schema`, `table`, and `v` from Appflare.
45
45
 
46
46
  Example source: `packages/backend/schema.ts`.
47
47
 
48
+ ### 2.3 Relation helpers (`v.one`, `v.many`, `v.manyToMany`)
49
+
50
+ - `v.one("target")` creates a single-reference relation and infers a local FK field.
51
+ - `v.many("target")` is inverse one-to-many and infers an FK on the target table.
52
+ - `v.manyToMany("target")` creates a many-to-many relation by synthesizing a junction table.
53
+
54
+ Many-to-many example:
55
+
56
+ ```ts
57
+ export const schemas = schema({
58
+ pets: table({
59
+ id: v.uuid(),
60
+ trips: v.manyToMany("trips"),
61
+ }),
62
+ trips: table({
63
+ id: v.uuid(),
64
+ pets: v.manyToMany("pets"),
65
+ }),
66
+ });
67
+ ```
68
+
69
+ Default behavior for `v.manyToMany`:
70
+
71
+ - Generates one deterministic junction table per pair.
72
+ - Junction rows are pure links (two FK columns, no payload columns).
73
+ - Reciprocal declarations must agree on options (`junctionTable`, field names, FK actions), otherwise generation throws a conflict error.
74
+
48
75
  ---
49
76
 
50
77
  ## 3) How to create handlers
@@ -5,6 +5,7 @@ import type {
5
5
  ColumnDefinition,
6
6
  ColumnType,
7
7
  ManyRelationDefinition,
8
+ ManyToManyRelationDefinition,
8
9
  OneRelationDefinition,
9
10
  SchemaDefinition,
10
11
  TableDefinition,
@@ -121,6 +122,8 @@ function ensureInferredReferenceColumn(
121
122
  fkType?: ColumnType;
122
123
  sqlName?: string;
123
124
  notNull?: boolean;
125
+ onDelete?: OneRelationDefinition["onDelete"];
126
+ onUpdate?: OneRelationDefinition["onUpdate"];
124
127
  },
125
128
  inferredType: ColumnType,
126
129
  ): void {
@@ -138,7 +141,17 @@ function ensureInferredReferenceColumn(
138
141
 
139
142
  table.columns[columnName] = {
140
143
  ...existing,
141
- references: { table: referenceTable, column: referenceField },
144
+ notNull:
145
+ existing.notNull ??
146
+ (existing.nullable === true ? false : options.notNull),
147
+ nullable:
148
+ existing.nullable ?? (options.notNull === false ? true : undefined),
149
+ references: {
150
+ table: referenceTable,
151
+ column: referenceField,
152
+ onDelete: existing.references?.onDelete ?? options.onDelete,
153
+ onUpdate: existing.references?.onUpdate ?? options.onUpdate,
154
+ },
142
155
  };
143
156
  return;
144
157
  }
@@ -148,16 +161,360 @@ function ensureInferredReferenceColumn(
148
161
  type: options.fkType ?? inferredType,
149
162
  sqlName: options.sqlName,
150
163
  notNull: options.notNull ?? true,
164
+ nullable: options.notNull === false ? true : undefined,
151
165
  references: {
152
166
  table: referenceTable,
153
167
  column: referenceField,
168
+ onDelete: options.onDelete,
169
+ onUpdate: options.onUpdate,
154
170
  },
155
171
  };
156
172
  }
157
173
 
174
+ function resolveNotNullFromNullableFlags(
175
+ fieldPath: string,
176
+ flags: { notNull?: boolean; nullable?: boolean },
177
+ defaultNotNull: boolean,
178
+ ): boolean {
179
+ if (flags.notNull === true && flags.nullable === true) {
180
+ throw new Error(
181
+ `Invalid nullable configuration on '${fieldPath}': cannot set both notNull and nullable to true.`,
182
+ );
183
+ }
184
+
185
+ if (flags.notNull === true) {
186
+ return true;
187
+ }
188
+
189
+ if (flags.nullable === true) {
190
+ return false;
191
+ }
192
+
193
+ if (flags.notNull === false) {
194
+ return false;
195
+ }
196
+
197
+ return defaultNotNull;
198
+ }
199
+
200
+ type ManyToManyPair = {
201
+ leftTable: string;
202
+ leftReferenceField: string;
203
+ rightTable: string;
204
+ rightReferenceField: string;
205
+ junctionTable: string;
206
+ leftField: string;
207
+ rightField: string;
208
+ leftSqlName?: string;
209
+ rightSqlName?: string;
210
+ onDelete?: OneRelationDefinition["onDelete"];
211
+ onUpdate?: OneRelationDefinition["onUpdate"];
212
+ };
213
+
214
+ function endpointKey(tableName: string, referenceField: string): string {
215
+ return `${tableName}:${referenceField}`;
216
+ }
217
+
218
+ function normalizeManyToManyPairKey(
219
+ sourceTable: string,
220
+ sourceReferenceField: string,
221
+ targetTable: string,
222
+ targetReferenceField: string,
223
+ ): {
224
+ key: string;
225
+ leftTable: string;
226
+ leftReferenceField: string;
227
+ rightTable: string;
228
+ rightReferenceField: string;
229
+ sourceIsLeft: boolean;
230
+ } {
231
+ const source = endpointKey(sourceTable, sourceReferenceField);
232
+ const target = endpointKey(targetTable, targetReferenceField);
233
+ const sourceIsLeft = source <= target;
234
+
235
+ if (sourceIsLeft) {
236
+ return {
237
+ key: `${source}|${target}`,
238
+ leftTable: sourceTable,
239
+ leftReferenceField: sourceReferenceField,
240
+ rightTable: targetTable,
241
+ rightReferenceField: targetReferenceField,
242
+ sourceIsLeft: true,
243
+ };
244
+ }
245
+
246
+ return {
247
+ key: `${target}|${source}`,
248
+ leftTable: targetTable,
249
+ leftReferenceField: targetReferenceField,
250
+ rightTable: sourceTable,
251
+ rightReferenceField: sourceReferenceField,
252
+ sourceIsLeft: false,
253
+ };
254
+ }
255
+
256
+ function defaultManyToManyJunctionTable(
257
+ leftTable: string,
258
+ rightTable: string,
259
+ ): string {
260
+ return `${leftTable}${toPascalCase(rightTable)}Links`;
261
+ }
262
+
263
+ function defaultManyToManyFieldName(
264
+ ownerTable: string,
265
+ otherTable: string,
266
+ suffix: "source" | "target",
267
+ ): string {
268
+ const ownerSingular = singularize(ownerTable);
269
+ const otherSingular = singularize(otherTable);
270
+
271
+ if (ownerSingular === otherSingular) {
272
+ return `${suffix}${toPascalCase(ownerSingular)}Id`;
273
+ }
274
+
275
+ return `${ownerSingular}Id`;
276
+ }
277
+
278
+ function mergeManyToManyPairConfig(
279
+ existing: ManyToManyPair,
280
+ incoming: ManyToManyPair,
281
+ pairKey: string,
282
+ ): ManyToManyPair {
283
+ if (existing.junctionTable !== incoming.junctionTable) {
284
+ throw new Error(
285
+ `manyToMany pair '${pairKey}' has conflicting junctionTable values ('${existing.junctionTable}' vs '${incoming.junctionTable}').`,
286
+ );
287
+ }
288
+
289
+ if (existing.leftField !== incoming.leftField) {
290
+ throw new Error(
291
+ `manyToMany pair '${pairKey}' has conflicting left field values ('${existing.leftField}' vs '${incoming.leftField}').`,
292
+ );
293
+ }
294
+
295
+ if (existing.rightField !== incoming.rightField) {
296
+ throw new Error(
297
+ `manyToMany pair '${pairKey}' has conflicting right field values ('${existing.rightField}' vs '${incoming.rightField}').`,
298
+ );
299
+ }
300
+
301
+ if (existing.leftSqlName !== incoming.leftSqlName) {
302
+ throw new Error(
303
+ `manyToMany pair '${pairKey}' has conflicting left sql name values ('${existing.leftSqlName}' vs '${incoming.leftSqlName}').`,
304
+ );
305
+ }
306
+
307
+ if (existing.rightSqlName !== incoming.rightSqlName) {
308
+ throw new Error(
309
+ `manyToMany pair '${pairKey}' has conflicting right sql name values ('${existing.rightSqlName}' vs '${incoming.rightSqlName}').`,
310
+ );
311
+ }
312
+
313
+ if (existing.onDelete !== incoming.onDelete) {
314
+ throw new Error(
315
+ `manyToMany pair '${pairKey}' has conflicting onDelete values ('${existing.onDelete}' vs '${incoming.onDelete}').`,
316
+ );
317
+ }
318
+
319
+ if (existing.onUpdate !== incoming.onUpdate) {
320
+ throw new Error(
321
+ `manyToMany pair '${pairKey}' has conflicting onUpdate values ('${existing.onUpdate}' vs '${incoming.onUpdate}').`,
322
+ );
323
+ }
324
+
325
+ return existing;
326
+ }
327
+
328
+ function normalizeManyToManyRelations(definition: SchemaDefinition): void {
329
+ const pairMap = new Map<string, ManyToManyPair>();
330
+
331
+ for (const [sourceTableName, sourceTable] of Object.entries(
332
+ definition.tables,
333
+ )) {
334
+ for (const relation of Object.values(sourceTable.relations)) {
335
+ if (relation.relation !== "manyToMany") {
336
+ continue;
337
+ }
338
+
339
+ const sourceReferenceField = relation.referenceField ?? "id";
340
+ const targetReferenceField = relation.targetReferenceField ?? "id";
341
+
342
+ const normalizedPair = normalizeManyToManyPairKey(
343
+ sourceTableName,
344
+ sourceReferenceField,
345
+ relation.targetTable,
346
+ targetReferenceField,
347
+ );
348
+
349
+ const defaultLeftField = defaultManyToManyFieldName(
350
+ normalizedPair.leftTable,
351
+ normalizedPair.rightTable,
352
+ "source",
353
+ );
354
+ const defaultRightField = defaultManyToManyFieldName(
355
+ normalizedPair.rightTable,
356
+ normalizedPair.leftTable,
357
+ "target",
358
+ );
359
+
360
+ const pair: ManyToManyPair = {
361
+ leftTable: normalizedPair.leftTable,
362
+ leftReferenceField: normalizedPair.leftReferenceField,
363
+ rightTable: normalizedPair.rightTable,
364
+ rightReferenceField: normalizedPair.rightReferenceField,
365
+ junctionTable:
366
+ relation.junctionTable ??
367
+ defaultManyToManyJunctionTable(
368
+ normalizedPair.leftTable,
369
+ normalizedPair.rightTable,
370
+ ),
371
+ leftField: normalizedPair.sourceIsLeft
372
+ ? (relation.sourceField ?? defaultLeftField)
373
+ : (relation.targetField ?? defaultLeftField),
374
+ rightField: normalizedPair.sourceIsLeft
375
+ ? (relation.targetField ?? defaultRightField)
376
+ : (relation.sourceField ?? defaultRightField),
377
+ leftSqlName: normalizedPair.sourceIsLeft
378
+ ? relation.sourceSqlName
379
+ : relation.targetSqlName,
380
+ rightSqlName: normalizedPair.sourceIsLeft
381
+ ? relation.targetSqlName
382
+ : relation.sourceSqlName,
383
+ onDelete: relation.onDelete,
384
+ onUpdate: relation.onUpdate,
385
+ };
386
+
387
+ if (pair.leftField === pair.rightField) {
388
+ throw new Error(
389
+ `manyToMany pair '${normalizedPair.key}' resolves to duplicate junction fields '${pair.leftField}'. Set sourceField/targetField explicitly.`,
390
+ );
391
+ }
392
+
393
+ const existing = pairMap.get(normalizedPair.key);
394
+ if (existing) {
395
+ mergeManyToManyPairConfig(existing, pair, normalizedPair.key);
396
+ } else {
397
+ pairMap.set(normalizedPair.key, pair);
398
+ }
399
+
400
+ relation.referenceField = sourceReferenceField;
401
+ relation.targetReferenceField = targetReferenceField;
402
+ relation.junctionTable = pair.junctionTable;
403
+ relation.sourceField = normalizedPair.sourceIsLeft
404
+ ? pair.leftField
405
+ : pair.rightField;
406
+ relation.targetField = normalizedPair.sourceIsLeft
407
+ ? pair.rightField
408
+ : pair.leftField;
409
+ relation.sourceSqlName = normalizedPair.sourceIsLeft
410
+ ? pair.leftSqlName
411
+ : pair.rightSqlName;
412
+ relation.targetSqlName = normalizedPair.sourceIsLeft
413
+ ? pair.rightSqlName
414
+ : pair.leftSqlName;
415
+ }
416
+ }
417
+
418
+ for (const pair of pairMap.values()) {
419
+ if (pair.junctionTable in definition.tables) {
420
+ throw new Error(
421
+ `manyToMany auto junction table '${pair.junctionTable}' conflicts with an existing table. Set a different junctionTable name.`,
422
+ );
423
+ }
424
+
425
+ const leftType =
426
+ getLocalReferenceType(
427
+ definition,
428
+ pair.leftTable,
429
+ pair.leftReferenceField,
430
+ ) ?? "string";
431
+ const rightType =
432
+ getLocalReferenceType(
433
+ definition,
434
+ pair.rightTable,
435
+ pair.rightReferenceField,
436
+ ) ?? "string";
437
+
438
+ definition.tables[pair.junctionTable] = {
439
+ kind: "table",
440
+ columns: {
441
+ [pair.leftField]: {
442
+ kind: "column",
443
+ type: leftType,
444
+ sqlName: pair.leftSqlName,
445
+ notNull: true,
446
+ nullable: false,
447
+ references: {
448
+ table: pair.leftTable,
449
+ column: pair.leftReferenceField,
450
+ onDelete: pair.onDelete,
451
+ onUpdate: pair.onUpdate,
452
+ },
453
+ index: true,
454
+ },
455
+ [pair.rightField]: {
456
+ kind: "column",
457
+ type: rightType,
458
+ sqlName: pair.rightSqlName,
459
+ notNull: true,
460
+ nullable: false,
461
+ references: {
462
+ table: pair.rightTable,
463
+ column: pair.rightReferenceField,
464
+ onDelete: pair.onDelete,
465
+ onUpdate: pair.onUpdate,
466
+ },
467
+ index: true,
468
+ },
469
+ },
470
+ relations: {
471
+ [pair.leftTable]: {
472
+ kind: "relation",
473
+ relation: "one",
474
+ targetTable: pair.leftTable,
475
+ field: pair.leftField,
476
+ referenceField: pair.leftReferenceField,
477
+ },
478
+ [pair.rightTable]: {
479
+ kind: "relation",
480
+ relation: "one",
481
+ targetTable: pair.rightTable,
482
+ field: pair.rightField,
483
+ referenceField: pair.rightReferenceField,
484
+ },
485
+ },
486
+ };
487
+ }
488
+ }
489
+
490
+ function validateNullableFlags(definition: SchemaDefinition): void {
491
+ for (const [tableName, table] of Object.entries(definition.tables)) {
492
+ for (const [columnName, column] of Object.entries(table.columns)) {
493
+ if (column.notNull === true && column.nullable === true) {
494
+ throw new Error(
495
+ `Invalid nullable configuration on '${tableName}.${columnName}': cannot set both notNull and nullable to true.`,
496
+ );
497
+ }
498
+ }
499
+
500
+ for (const [relationName, relation] of Object.entries(table.relations)) {
501
+ if (
502
+ relation.relation !== "manyToMany" &&
503
+ relation.notNull === true &&
504
+ relation.nullable === true
505
+ ) {
506
+ throw new Error(
507
+ `Invalid nullable configuration on '${tableName}.${relationName}': cannot set both notNull and nullable to true.`,
508
+ );
509
+ }
510
+ }
511
+ }
512
+ }
513
+
158
514
  function normalizeSchemaDefinition(
159
515
  definition: SchemaDefinition,
160
516
  ): SchemaDefinition {
517
+ validateNullableFlags(definition);
161
518
  const normalized = cloneSchemaDefinition(definition);
162
519
 
163
520
  for (const [tableName, table] of Object.entries(normalized.tables)) {
@@ -188,7 +545,13 @@ function normalizeSchemaDefinition(
188
545
  {
189
546
  fkType: relation.fkType,
190
547
  sqlName: relation.sqlName,
191
- notNull: relation.notNull,
548
+ notNull: resolveNotNullFromNullableFlags(
549
+ `${tableName}.${relationName}`,
550
+ relation,
551
+ true,
552
+ ),
553
+ onDelete: relation.onDelete,
554
+ onUpdate: relation.onUpdate,
192
555
  },
193
556
  inferredType,
194
557
  );
@@ -211,6 +574,7 @@ function normalizeSchemaDefinition(
211
574
  const referenceField = relation.referenceField ?? "id";
212
575
  const inferredField =
213
576
  relation.field ?? `${singularize(sourceTableName)}Id`;
577
+ relation.field = inferredField;
214
578
  const inferredType =
215
579
  getLocalReferenceType(normalized, sourceTableName, referenceField) ??
216
580
  relation.fkType ??
@@ -225,18 +589,27 @@ function normalizeSchemaDefinition(
225
589
  {
226
590
  fkType: relation.fkType,
227
591
  sqlName: relation.sqlName,
228
- notNull: relation.notNull,
592
+ notNull: resolveNotNullFromNullableFlags(
593
+ `${sourceTableName}.${relation.targetTable}`,
594
+ relation,
595
+ true,
596
+ ),
597
+ onDelete: relation.onDelete,
598
+ onUpdate: relation.onUpdate,
229
599
  },
230
600
  inferredType,
231
601
  );
232
602
  }
233
603
  }
234
604
 
605
+ normalizeManyToManyRelations(normalized);
606
+
235
607
  return normalized;
236
608
  }
237
609
 
238
610
  function isOptionalInsertColumn(column: ColumnDefinition): boolean {
239
611
  return (
612
+ column.primaryKey === true ||
240
613
  column.notNull !== true ||
241
614
  column.autoIncrement === true ||
242
615
  column.sqlDefault !== undefined ||
@@ -357,6 +730,117 @@ function resolveColumnReference(
357
730
  };
358
731
  }
359
732
 
733
+ function emitManyToManyRuntimeMetadata(definition: SchemaDefinition): string {
734
+ const tableEntries: string[] = [];
735
+
736
+ for (const [tableName, table] of Object.entries(definition.tables)) {
737
+ const relationEntries: string[] = [];
738
+
739
+ for (const [relationName, relation] of Object.entries(table.relations)) {
740
+ if (relation.relation !== "manyToMany") {
741
+ continue;
742
+ }
743
+
744
+ if (!relation.junctionTable) {
745
+ continue;
746
+ }
747
+
748
+ relationEntries.push(
749
+ `${quote(relationName)}: {
750
+ targetTable: ${quote(relation.targetTable)},
751
+ junctionTable: ${quote(relation.junctionTable)},
752
+ sourceField: ${quote(relation.sourceField ?? "")},
753
+ targetField: ${quote(relation.targetField ?? "")},
754
+ },`,
755
+ );
756
+ }
757
+
758
+ if (relationEntries.length === 0) {
759
+ continue;
760
+ }
761
+
762
+ tableEntries.push(
763
+ `${quote(tableName)}: {
764
+ ${relationEntries.map((entry) => `\t${entry}`).join("\n")}
765
+ },`,
766
+ );
767
+ }
768
+
769
+ if (tableEntries.length === 0) {
770
+ return "export const __appflareManyToMany = {} as const;\n";
771
+ }
772
+
773
+ return `export const __appflareManyToMany = {
774
+ ${tableEntries.map((entry) => `\t${entry}`).join("\n")}
775
+ } as const;
776
+ `;
777
+ }
778
+
779
+ function emitRuntimeRelationMetadata(definition: SchemaDefinition): string {
780
+ const tableEntries: string[] = [];
781
+
782
+ for (const [tableName, table] of Object.entries(definition.tables)) {
783
+ const relationEntries: string[] = [];
784
+
785
+ for (const [relationName, relation] of Object.entries(table.relations)) {
786
+ if (relation.relation === "one") {
787
+ relationEntries.push(
788
+ `${quote(relationName)}: {
789
+ kind: "one",
790
+ targetTable: ${quote(relation.targetTable)},
791
+ sourceField: ${quote(relation.field ?? "")},
792
+ referenceField: ${quote(relation.referenceField ?? "id")},
793
+ },`,
794
+ );
795
+ continue;
796
+ }
797
+
798
+ if (relation.relation === "many") {
799
+ relationEntries.push(
800
+ `${quote(relationName)}: {
801
+ kind: "many",
802
+ targetTable: ${quote(relation.targetTable)},
803
+ sourceField: ${quote(relation.field ?? "")},
804
+ referenceField: ${quote(relation.referenceField ?? "id")},
805
+ },`,
806
+ );
807
+ continue;
808
+ }
809
+
810
+ relationEntries.push(
811
+ `${quote(relationName)}: {
812
+ kind: "manyToMany",
813
+ targetTable: ${quote(relation.targetTable)},
814
+ junctionTable: ${quote(relation.junctionTable ?? "")},
815
+ sourceField: ${quote(relation.sourceField ?? "")},
816
+ targetField: ${quote(relation.targetField ?? "")},
817
+ referenceField: ${quote(relation.referenceField ?? "id")},
818
+ targetReferenceField: ${quote(relation.targetReferenceField ?? "id")},
819
+ },`,
820
+ );
821
+ }
822
+
823
+ if (relationEntries.length === 0) {
824
+ continue;
825
+ }
826
+
827
+ tableEntries.push(
828
+ `${quote(tableName)}: {
829
+ ${relationEntries.map((entry) => `\t${entry}`).join("\n")}
830
+ },`,
831
+ );
832
+ }
833
+
834
+ if (tableEntries.length === 0) {
835
+ return "export const __appflareRelations = {} as const;\n";
836
+ }
837
+
838
+ return `export const __appflareRelations = {
839
+ ${tableEntries.map((entry) => `\t${entry}`).join("\n")}
840
+ } as const;
841
+ `;
842
+ }
843
+
360
844
  function emitDrizzleSchema(
361
845
  definition: SchemaDefinition,
362
846
  strategy: "camelToSnake",
@@ -388,6 +872,10 @@ function emitDrizzleSchema(
388
872
  for (const [fieldName, column] of Object.entries(table.columns)) {
389
873
  let expr = drizzleBaseColumn(fieldName, column, strategy);
390
874
 
875
+ if (column.uuidPrimaryKey) {
876
+ expr += ".$defaultFn(() => crypto.randomUUID())";
877
+ }
878
+
391
879
  if (column.primaryKey) {
392
880
  expr += column.autoIncrement
393
881
  ? ".primaryKey({ autoIncrement: true })"
@@ -402,7 +890,18 @@ function emitDrizzleSchema(
402
890
 
403
891
  const reference = resolveColumnReference(fieldName, column, table);
404
892
  if (reference) {
405
- expr += `.references(() => ${reference.tableName}.${reference.fieldName})`;
893
+ if (column.references?.onDelete || column.references?.onUpdate) {
894
+ const actions: string[] = [];
895
+ if (column.references.onDelete) {
896
+ actions.push(`onDelete: ${quote(column.references.onDelete)}`);
897
+ }
898
+ if (column.references.onUpdate) {
899
+ actions.push(`onUpdate: ${quote(column.references.onUpdate)}`);
900
+ }
901
+ expr += `.references(() => ${reference.tableName}.${reference.fieldName}, { ${actions.join(", ")} })`;
902
+ } else {
903
+ expr += `.references(() => ${reference.tableName}.${reference.fieldName})`;
904
+ }
406
905
  }
407
906
 
408
907
  if (column.unique) {
@@ -446,8 +945,17 @@ function emitDrizzleSchema(
446
945
  return relation.relation === "many";
447
946
  },
448
947
  ) as Array<[string, ManyRelationDefinition]>;
948
+ const manyToManyRelations = Object.entries(table.relations).filter(
949
+ ([, relation]) => {
950
+ return relation.relation === "manyToMany";
951
+ },
952
+ ) as Array<[string, ManyToManyRelationDefinition]>;
449
953
 
450
- if (oneRelations.length === 0 && manyRelations.length === 0) {
954
+ if (
955
+ oneRelations.length === 0 &&
956
+ manyRelations.length === 0 &&
957
+ manyToManyRelations.length === 0
958
+ ) {
451
959
  continue;
452
960
  }
453
961
 
@@ -461,6 +969,14 @@ function emitDrizzleSchema(
461
969
  for (const [relationName, relation] of manyRelations) {
462
970
  relationLines.push(`\t${relationName}: many(${relation.targetTable}),`);
463
971
  }
972
+ for (const [relationName, relation] of manyToManyRelations) {
973
+ if (!relation.junctionTable) {
974
+ throw new Error(
975
+ `manyToMany relation '${tableName}.${relationName}' is missing junctionTable after normalization.`,
976
+ );
977
+ }
978
+ relationLines.push(`\t${relationName}: many(${relation.junctionTable}),`);
979
+ }
464
980
 
465
981
  relationBlocks.push(
466
982
  `export const ${tableName}Relations = relations(${tableName}, ({ one, many }) => ({\n${relationLines.join("\n")}\n}));`,
@@ -474,12 +990,17 @@ ${buildExternalTableImportLines(externalTables)}
474
990
  ${tableBlocks.join("\n\n")}
475
991
 
476
992
  ${relationBlocks.join("\n\n")}
993
+
994
+ ${emitManyToManyRuntimeMetadata(definition)}
995
+
996
+ ${emitRuntimeRelationMetadata(definition)}
477
997
  `;
478
998
  }
479
999
 
480
1000
  function zodSchemaExpression(
481
1001
  column: ColumnDefinition,
482
1002
  optional: boolean,
1003
+ nullable: boolean,
483
1004
  ): string {
484
1005
  let expr = "z.unknown()";
485
1006
  if (column.type === "int") {
@@ -499,6 +1020,10 @@ function zodSchemaExpression(
499
1020
  expr += ".optional()";
500
1021
  }
501
1022
 
1023
+ if (nullable) {
1024
+ expr += ".nullable()";
1025
+ }
1026
+
502
1027
  return expr;
503
1028
  }
504
1029
 
@@ -512,10 +1037,10 @@ function emitZodSchemas(definition: SchemaDefinition): string {
512
1037
 
513
1038
  for (const [fieldName, column] of Object.entries(table.columns)) {
514
1039
  insertLines.push(
515
- `\t${fieldName}: ${zodSchemaExpression(column, isOptionalInsertColumn(column))},`,
1040
+ `\t${fieldName}: ${zodSchemaExpression(column, isOptionalInsertColumn(column), isNullableSelectColumn(column))},`,
516
1041
  );
517
1042
  selectLines.push(
518
- `\t${fieldName}: ${zodSchemaExpression(column, isNullableSelectColumn(column))},`,
1043
+ `\t${fieldName}: ${zodSchemaExpression(column, isNullableSelectColumn(column), isNullableSelectColumn(column))},`,
519
1044
  );
520
1045
  }
521
1046
 
@@ -558,11 +1083,12 @@ function emitTypes(definition: SchemaDefinition): string {
558
1083
 
559
1084
  for (const [fieldName, column] of Object.entries(table.columns)) {
560
1085
  const tsType = toTypeScriptType(column);
1086
+ const nullableSuffix = isNullableSelectColumn(column) ? " | null" : "";
561
1087
  selectFields.push(
562
- `\t${fieldName}${isNullableSelectColumn(column) ? "?" : ""}: ${tsType};`,
1088
+ `\t${fieldName}${isNullableSelectColumn(column) ? "?" : ""}: ${tsType}${nullableSuffix};`,
563
1089
  );
564
1090
  insertFields.push(
565
- `\t${fieldName}${isOptionalInsertColumn(column) ? "?" : ""}: ${tsType};`,
1091
+ `\t${fieldName}${isOptionalInsertColumn(column) ? "?" : ""}: ${tsType}${nullableSuffix};`,
566
1092
  );
567
1093
  }
568
1094