appflare 0.2.15 → 0.2.17

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,
@@ -196,6 +197,296 @@ function resolveNotNullFromNullableFlags(
196
197
  return defaultNotNull;
197
198
  }
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
+
199
490
  function validateNullableFlags(definition: SchemaDefinition): void {
200
491
  for (const [tableName, table] of Object.entries(definition.tables)) {
201
492
  for (const [columnName, column] of Object.entries(table.columns)) {
@@ -207,7 +498,11 @@ function validateNullableFlags(definition: SchemaDefinition): void {
207
498
  }
208
499
 
209
500
  for (const [relationName, relation] of Object.entries(table.relations)) {
210
- if (relation.notNull === true && relation.nullable === true) {
501
+ if (
502
+ relation.relation !== "manyToMany" &&
503
+ relation.notNull === true &&
504
+ relation.nullable === true
505
+ ) {
211
506
  throw new Error(
212
507
  `Invalid nullable configuration on '${tableName}.${relationName}': cannot set both notNull and nullable to true.`,
213
508
  );
@@ -279,6 +574,7 @@ function normalizeSchemaDefinition(
279
574
  const referenceField = relation.referenceField ?? "id";
280
575
  const inferredField =
281
576
  relation.field ?? `${singularize(sourceTableName)}Id`;
577
+ relation.field = inferredField;
282
578
  const inferredType =
283
579
  getLocalReferenceType(normalized, sourceTableName, referenceField) ??
284
580
  relation.fkType ??
@@ -306,6 +602,8 @@ function normalizeSchemaDefinition(
306
602
  }
307
603
  }
308
604
 
605
+ normalizeManyToManyRelations(normalized);
606
+
309
607
  return normalized;
310
608
  }
311
609
 
@@ -432,6 +730,117 @@ function resolveColumnReference(
432
730
  };
433
731
  }
434
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
+
435
844
  function emitDrizzleSchema(
436
845
  definition: SchemaDefinition,
437
846
  strategy: "camelToSnake",
@@ -536,8 +945,17 @@ function emitDrizzleSchema(
536
945
  return relation.relation === "many";
537
946
  },
538
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]>;
539
953
 
540
- if (oneRelations.length === 0 && manyRelations.length === 0) {
954
+ if (
955
+ oneRelations.length === 0 &&
956
+ manyRelations.length === 0 &&
957
+ manyToManyRelations.length === 0
958
+ ) {
541
959
  continue;
542
960
  }
543
961
 
@@ -551,6 +969,14 @@ function emitDrizzleSchema(
551
969
  for (const [relationName, relation] of manyRelations) {
552
970
  relationLines.push(`\t${relationName}: many(${relation.targetTable}),`);
553
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
+ }
554
980
 
555
981
  relationBlocks.push(
556
982
  `export const ${tableName}Relations = relations(${tableName}, ({ one, many }) => ({\n${relationLines.join("\n")}\n}));`,
@@ -564,6 +990,10 @@ ${buildExternalTableImportLines(externalTables)}
564
990
  ${tableBlocks.join("\n\n")}
565
991
 
566
992
  ${relationBlocks.join("\n\n")}
993
+
994
+ ${emitManyToManyRuntimeMetadata(definition)}
995
+
996
+ ${emitRuntimeRelationMetadata(definition)}
567
997
  `;
568
998
  }
569
999
 
@@ -52,12 +52,15 @@ export function buildColumnHeaders(
52
52
  .join("");
53
53
  }
54
54
 
55
- export function buildRowCells(columns: string[]): string {
55
+ export function buildRowCells(columns: string[], primaryKey?: string): string {
56
56
  return columns
57
- .map(
58
- (column) =>
59
- `<td><div class="truncate max-w-[200px] text-sm" title="\${String((row as any).${column} ?? '')}">\${String((row as any).${column} ?? '')}</div></td>`,
60
- )
57
+ .map((column) => {
58
+ if (primaryKey && column === primaryKey) {
59
+ return `<td><button type="button" class="truncate max-w-[200px] text-sm font-mono text-xs opacity-70 hover:opacity-100 cursor-copy text-left" title="Click to copy: \${String((row as any).${column} ?? '')}" data-copy-value="\${String((row as any).${column} ?? '')}" onclick="navigator.clipboard?.writeText(this.dataset.copyValue || '')">\${String((row as any).${column} ?? '')}</button></td>`;
60
+ }
61
+
62
+ return `<td><div class="truncate max-w-[200px] text-sm" title="\${String((row as any).${column} ?? '')}">\${String((row as any).${column} ?? '')}</div></td>`;
63
+ })
61
64
  .join("");
62
65
  }
63
66
 
@@ -31,7 +31,7 @@ export function buildTableRoute(table: DiscoveredTable): string {
31
31
  );
32
32
  const searchConditions = buildSearchConditions(table);
33
33
  const headers = buildColumnHeaders(table, columns);
34
- const rowCells = buildRowCells(columns);
34
+ const rowCells = buildRowCells(columns, primaryKey);
35
35
  const createInputs = createColumns
36
36
  .map((columnName) => buildFieldInput(table, columnName, "create"))
37
37
  .join("");
@@ -55,7 +55,7 @@ export function buildUsersTableHtml(): string {
55
55
  \t\t\t\t\t\t<tbody>
56
56
  \t\t\t\t\t\t\t\${data.map((row) => html\`
57
57
  \t\t\t\t\t\t\t\t<tr class="hover:bg-base-200/30 transition-colors">
58
- \t\t\t\t\t\t\t\t\t<td><div class="truncate max-w-[220px] text-sm font-mono text-xs opacity-60" title="\${String((row as any).id ?? '')}">\${String((row as any).id ?? '')}</div></td>
58
+ \t\t\t\t\t\t\t\t\t<td><button type="button" class="truncate max-w-[220px] text-sm font-mono text-xs opacity-70 hover:opacity-100 cursor-copy text-left" title="Click to copy: \${String((row as any).id ?? '')}" data-copy-value="\${String((row as any).id ?? '')}" onclick="navigator.clipboard?.writeText(this.dataset.copyValue || '')">\${String((row as any).id ?? '')}</button></td>
59
59
  \t\t\t\t\t\t\t\t\t<td><div class="truncate max-w-[220px] text-sm" title="\${String((row as any).name ?? '')}">\${String((row as any).name ?? '')}</div></td>
60
60
  \t\t\t\t\t\t\t\t\t<td><div class="truncate max-w-[260px] text-sm" title="\${String((row as any).email ?? '')}">\${String((row as any).email ?? '')}</div></td>
61
61
  \t\t\t\t\t\t\t\t\t<td><span class="badge badge-sm \${String((row as any).role ?? '') === 'admin' ? 'badge-primary' : 'badge-ghost'}">\${String((row as any).role ?? '')}</span></td>
@@ -123,5 +123,6 @@ ${buildDeleteUserModal()}
123
123
  \t\t\t\t</div>
124
124
  \t\t\t\t${paginationHtml}
125
125
  \t\t\t</div>
126
- \t\t\`;`;
126
+ \t\t\`;
127
+ `;
127
128
  }