@rebasepro/server-postgresql 0.0.1-canary.f81da60 → 0.1.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 (59) hide show
  1. package/dist/index.es.js +287 -21
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +287 -21
  4. package/dist/index.umd.js.map +1 -1
  5. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
  6. package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
  7. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +44 -9
  8. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
  9. package/dist/types/src/controllers/auth.d.ts +8 -2
  10. package/dist/types/src/controllers/client.d.ts +13 -0
  11. package/dist/types/src/controllers/navigation.d.ts +18 -6
  12. package/dist/types/src/controllers/registry.d.ts +9 -1
  13. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  14. package/dist/types/src/rebase_context.d.ts +17 -0
  15. package/dist/types/src/types/collections.d.ts +20 -1
  16. package/dist/types/src/types/component_ref.d.ts +47 -0
  17. package/dist/types/src/types/entity_views.d.ts +2 -1
  18. package/dist/types/src/types/index.d.ts +1 -0
  19. package/dist/types/src/types/properties.d.ts +15 -3
  20. package/dist/types/src/types/translations.d.ts +2 -0
  21. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  22. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  23. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  24. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  25. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  26. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  27. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  28. package/package.json +5 -5
  29. package/src/PostgresBackendDriver.ts +23 -6
  30. package/src/cli.ts +10 -2
  31. package/src/data-transformer.ts +84 -1
  32. package/src/schema/doctor.ts +14 -2
  33. package/src/schema/generate-drizzle-schema-logic.ts +52 -5
  34. package/src/schema/introspect-db-inference.ts +238 -0
  35. package/src/schema/introspect-db-logic.ts +365 -61
  36. package/src/schema/introspect-db.ts +66 -23
  37. package/src/services/EntityFetchService.ts +16 -0
  38. package/src/services/EntityPersistService.ts +88 -12
  39. package/test/generate-drizzle-schema.test.ts +295 -0
  40. package/test/introspect-db-generation.test.ts +32 -10
  41. package/test/property-ordering.test.ts +395 -0
  42. package/jest-all.log +0 -3128
  43. package/jest.log +0 -49
  44. package/scratch.ts +0 -41
  45. package/test-drizzle-bug.ts +0 -18
  46. package/test-drizzle-out/0000_cultured_freak.sql +0 -7
  47. package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
  48. package/test-drizzle-out/meta/0000_snapshot.json +0 -55
  49. package/test-drizzle-out/meta/0001_snapshot.json +0 -63
  50. package/test-drizzle-out/meta/_journal.json +0 -20
  51. package/test-drizzle-prompt.sh +0 -2
  52. package/test-policy-prompt.sh +0 -3
  53. package/test-programmatic.ts +0 -30
  54. package/test-programmatic2.ts +0 -59
  55. package/test-schema-no-policies.ts +0 -12
  56. package/test_drizzle_mock.js +0 -3
  57. package/test_find_changed.mjs +0 -32
  58. package/test_hash.js +0 -14
  59. package/test_output.txt +0 -3145
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "esbuild",
3
+ "version": "0.27.3",
4
+ "description": "An extremely fast JavaScript and CSS bundler and minifier.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/evanw/esbuild.git"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node install.js"
11
+ },
12
+ "main": "lib/main.js",
13
+ "types": "lib/main.d.ts",
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "bin": {
18
+ "esbuild": "bin/esbuild"
19
+ },
20
+ "optionalDependencies": {
21
+ "@esbuild/aix-ppc64": "0.27.3",
22
+ "@esbuild/android-arm": "0.27.3",
23
+ "@esbuild/android-arm64": "0.27.3",
24
+ "@esbuild/android-x64": "0.27.3",
25
+ "@esbuild/darwin-arm64": "0.27.3",
26
+ "@esbuild/darwin-x64": "0.27.3",
27
+ "@esbuild/freebsd-arm64": "0.27.3",
28
+ "@esbuild/freebsd-x64": "0.27.3",
29
+ "@esbuild/linux-arm": "0.27.3",
30
+ "@esbuild/linux-arm64": "0.27.3",
31
+ "@esbuild/linux-ia32": "0.27.3",
32
+ "@esbuild/linux-loong64": "0.27.3",
33
+ "@esbuild/linux-mips64el": "0.27.3",
34
+ "@esbuild/linux-ppc64": "0.27.3",
35
+ "@esbuild/linux-riscv64": "0.27.3",
36
+ "@esbuild/linux-s390x": "0.27.3",
37
+ "@esbuild/linux-x64": "0.27.3",
38
+ "@esbuild/netbsd-arm64": "0.27.3",
39
+ "@esbuild/netbsd-x64": "0.27.3",
40
+ "@esbuild/openbsd-arm64": "0.27.3",
41
+ "@esbuild/openbsd-x64": "0.27.3",
42
+ "@esbuild/openharmony-arm64": "0.27.3",
43
+ "@esbuild/sunos-x64": "0.27.3",
44
+ "@esbuild/win32-arm64": "0.27.3",
45
+ "@esbuild/win32-ia32": "0.27.3",
46
+ "@esbuild/win32-x64": "0.27.3"
47
+ },
48
+ "license": "MIT"
49
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgresql",
3
3
  "type": "module",
4
- "version": "0.0.1-canary.f81da60",
4
+ "version": "0.1.0",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -64,10 +64,10 @@
64
64
  "drizzle-orm": "^0.44.4",
65
65
  "execa": "^4.1.0",
66
66
  "pg": "^8.11.3",
67
- "@rebasepro/common": "0.0.1-canary.f81da60",
68
- "@rebasepro/server-core": "0.0.1-canary.f81da60",
69
- "@rebasepro/types": "0.0.1-canary.f81da60",
70
- "@rebasepro/utils": "0.0.1-canary.f81da60"
67
+ "@rebasepro/server-core": "0.1.0",
68
+ "@rebasepro/common": "0.1.0",
69
+ "@rebasepro/utils": "0.1.0",
70
+ "@rebasepro/types": "0.1.0"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@types/jest": "^29.5.14",
@@ -4,9 +4,9 @@ import { BranchService } from "./services/BranchService";
4
4
  import { RealtimeService } from "./services/realtimeService";
5
5
  import { DatabasePoolManager } from "./databasePoolManager";
6
6
  import { DrizzleClient } from "./interfaces";
7
- import { User } from "@rebasepro/types";
7
+ import { User, RebaseClient } from "@rebasepro/types";
8
8
  import { sql as drizzleSql } from "drizzle-orm";
9
- import { buildPropertyCallbacks } from "@rebasepro/common";
9
+ import { buildPropertyCallbacks, updateDateAutoValues } from "@rebasepro/common";
10
10
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
11
11
  import {
12
12
  DataDriver,
@@ -44,6 +44,7 @@ export class PostgresBackendDriver implements DataDriver {
44
44
  public branchService?: BranchService;
45
45
  public user?: User;
46
46
  public data: RebaseData;
47
+ public client?: RebaseClient;
47
48
 
48
49
  /**
49
50
  * When true, realtime notifications are deferred until after the
@@ -163,7 +164,8 @@ propertyCallbacks: undefined };
163
164
  const contextForCallback = {
164
165
  user: this.user,
165
166
  driver: this,
166
- data: this.data
167
+ data: this.data,
168
+ client: this.client
167
169
  } as unknown as RebaseCallContext; // Backend context
168
170
  return Promise.all(entities.map(async (entity) => {
169
171
  let fetched = entity;
@@ -272,7 +274,8 @@ propertyCallbacks: undefined };
272
274
  const contextForCallback = {
273
275
  user: this.user,
274
276
  driver: this,
275
- data: this.data
277
+ data: this.data,
278
+ client: this.client
276
279
  } as unknown as RebaseCallContext; // Backend context
277
280
  if (callbacks?.afterRead) {
278
281
  entity = await callbacks.afterRead({
@@ -354,7 +357,8 @@ propertyCallbacks: undefined };
354
357
  const contextForCallback = {
355
358
  user: this.user,
356
359
  driver: this,
357
- data: this.data
360
+ data: this.data,
361
+ client: this.client
358
362
  } as unknown as RebaseCallContext;
359
363
 
360
364
  // Fetch previous values for callbacks AND history recording
@@ -395,6 +399,17 @@ propertyCallbacks: undefined };
395
399
 
396
400
  }
397
401
 
402
+ // Apply autoValue timestamps (on_create / on_update) at the application layer.
403
+ // This handles updated_at fields for all writes that flow through the Rebase backend.
404
+ if (resolvedCollection?.properties) {
405
+ updatedValues = updateDateAutoValues({
406
+ inputValues: updatedValues,
407
+ properties: resolvedCollection.properties,
408
+ status: status ?? "new",
409
+ timestampNowValue: new Date()
410
+ });
411
+ }
412
+
398
413
  try {
399
414
  let savedEntity = await this.entityService.saveEntity<M>(
400
415
  path,
@@ -517,7 +532,8 @@ propertyCallbacks: undefined };
517
532
  const contextForCallback = {
518
533
  user: this.user,
519
534
  driver: this,
520
- data: this.data
535
+ data: this.data,
536
+ client: this.client
521
537
  } as unknown as RebaseCallContext;
522
538
 
523
539
  if (callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -940,6 +956,7 @@ roles: userRoles })}, true)
940
956
  txDelegate.entityService = txEntityService;
941
957
  txDelegate._deferNotifications = true;
942
958
  txDelegate._pendingNotifications = pendingNotifications;
959
+ txDelegate.client = this.delegate.client;
943
960
 
944
961
  return await operation(txDelegate);
945
962
  });
package/src/cli.ts CHANGED
@@ -43,7 +43,7 @@ export async function runPluginCommand(args: string[]) {
43
43
  }
44
44
 
45
45
  async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
46
- const VALID_ACTIONS = ["push", "pull", "generate", "migrate", "studio", "branch"];
46
+ const VALID_ACTIONS = ["push", "generate", "migrate", "studio", "branch"];
47
47
  if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
48
48
  console.error(chalk.red(`Unknown db command. Valid: ${VALID_ACTIONS.join(", ")}`));
49
49
  process.exit(1);
@@ -68,6 +68,12 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
68
68
  console.log("");
69
69
  console.log(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
70
70
  console.log("");
71
+ } else if (subcommand === "pull") {
72
+ console.log("");
73
+ console.log(chalk.yellow(" ⚠ \"rebase db pull\" has been removed."));
74
+ console.log(chalk.gray(" Use \"rebase schema introspect\" instead."));
75
+ console.log("");
76
+ process.exit(1);
71
77
  } else {
72
78
  console.log("");
73
79
  console.log(chalk.bold(` 🗄️ Rebase DB ${subcommand.charAt(0).toUpperCase() + subcommand.slice(1)}`));
@@ -538,9 +544,11 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
538
544
  const argsList = arg(
539
545
  {
540
546
  "--output": String,
547
+ "--collections": String,
541
548
  "--force": Boolean,
542
549
  "--schema": String,
543
550
  "-o": "--output",
551
+ "-c": "--collections",
544
552
  "-f": "--force"
545
553
  },
546
554
  {
@@ -561,7 +569,7 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
561
569
  process.exit(1);
562
570
  }
563
571
 
564
- const outputPath = argsList["--output"] || path.join("..", "config", "collections");
572
+ const outputPath = argsList["--output"] || argsList["--collections"] || path.join("..", "config", "collections");
565
573
 
566
574
  console.log("");
567
575
  console.log(chalk.bold(" 🔍 Rebase Schema Introspector"));
@@ -258,7 +258,26 @@ export function serializePropertyToServer(value: unknown, property: Property): u
258
258
  }
259
259
  return value;
260
260
 
261
+ case "string":
262
+ if (typeof value === "string") {
263
+ if (value.startsWith("data:application/octet-stream;base64,")) {
264
+ const base64Data = value.split(",")[1];
265
+ if (base64Data) {
266
+ return Buffer.from(base64Data, "base64");
267
+ }
268
+ }
269
+ }
270
+ return value;
271
+
261
272
  default:
273
+ if (typeof value === "string") {
274
+ if (value.startsWith("data:application/octet-stream;base64,")) {
275
+ const base64Data = value.split(",")[1];
276
+ if (base64Data) {
277
+ return Buffer.from(base64Data, "base64");
278
+ }
279
+ }
280
+ }
262
281
  return value;
263
282
  }
264
283
  }
@@ -447,6 +466,45 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
447
466
  }
448
467
 
449
468
  switch (property.type) {
469
+ case "string": {
470
+ if (typeof value === "string") return value;
471
+
472
+ // Handle Buffer objects (e.g. from PostgreSQL bytea columns)
473
+ let isBuffer = false;
474
+ let buf: Buffer | null = null;
475
+
476
+ if (Buffer.isBuffer(value)) {
477
+ isBuffer = true;
478
+ buf = value;
479
+ } else if (typeof value === "object" && value !== null && (value as any).type === "Buffer" && Array.isArray((value as any).data)) {
480
+ isBuffer = true;
481
+ buf = Buffer.from((value as any).data);
482
+ }
483
+
484
+ if (isBuffer && buf) {
485
+ // Heuristic: if all bytes are printable ASCII, return utf8, else base64
486
+ let isPrintable = true;
487
+ for (let i = 0; i < buf.length; i++) {
488
+ const b = buf[i];
489
+ // Allow standard printable ASCII + common whitespace (\r, \n, \t)
490
+ if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
491
+ isPrintable = false;
492
+ break;
493
+ }
494
+ }
495
+ return isPrintable ? buf.toString("utf8") : `data:application/octet-stream;base64,${buf.toString("base64")}`;
496
+ }
497
+
498
+ if (typeof value === "object" && value !== null) {
499
+ try {
500
+ return JSON.stringify(value);
501
+ } catch {
502
+ return String(value);
503
+ }
504
+ }
505
+ return String(value);
506
+ }
507
+
450
508
  case "relation":
451
509
  // Transform ID back to relation object with type information
452
510
  if (typeof value === "string" || typeof value === "number") {
@@ -538,8 +596,33 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
538
596
  return null;
539
597
  }
540
598
 
541
- default:
599
+ default: {
600
+ // Fallback for buffers in case they are mapped to something other than string
601
+ let isBuffer = false;
602
+ let buf: Buffer | null = null;
603
+
604
+ if (Buffer.isBuffer(value)) {
605
+ isBuffer = true;
606
+ buf = value;
607
+ } else if (typeof value === "object" && value !== null && (value as any).type === "Buffer" && Array.isArray((value as any).data)) {
608
+ isBuffer = true;
609
+ buf = Buffer.from((value as any).data);
610
+ }
611
+
612
+ if (isBuffer && buf) {
613
+ let isPrintable = true;
614
+ for (let i = 0; i < buf.length; i++) {
615
+ const b = buf[i];
616
+ if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
617
+ isPrintable = false;
618
+ break;
619
+ }
620
+ }
621
+ return isPrintable ? buf.toString("utf8") : `data:application/octet-stream;base64,${buf.toString("base64")}`;
622
+ }
623
+
542
624
  return value;
625
+ }
543
626
  }
544
627
  }
545
628
 
@@ -17,6 +17,18 @@ import { generateSchema } from "./generate-drizzle-schema-logic";
17
17
  import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
18
18
  import { toSnakeCase } from "@rebasepro/utils";
19
19
 
20
+ /**
21
+ * Resolve the SQL column name for a property.
22
+ * Uses the explicit `columnName` when set (e.g. from introspection),
23
+ * falling back to `toSnakeCase(propName)` for manually-authored collections.
24
+ */
25
+ const resolveColumnName = (propName: string, prop?: Property | null): string => {
26
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") {
27
+ return prop.columnName;
28
+ }
29
+ return toSnakeCase(propName);
30
+ };
31
+
20
32
  // ── Types ────────────────────────────────────────────────────────────────
21
33
 
22
34
  export type IssueSeverity = "error" | "warning" | "info";
@@ -316,7 +328,7 @@ export async function checkCollectionsVsDatabase(
316
328
  const resolvedRelations = resolveCollectionRelations(collection);
317
329
  const relation = findRelation(resolvedRelations, (prop as RelationProperty).relationName ?? propName);
318
330
  if (relation?.direction === "owning" && relation.cardinality === "one" && relation.localKey) {
319
- const fkColName = toSnakeCase(relation.localKey);
331
+ const fkColName = relation.localKey;
320
332
  if (!dbColumnMap.has(fkColName)) {
321
333
  issues.push({
322
334
  severity: "error",
@@ -349,7 +361,7 @@ export async function checkCollectionsVsDatabase(
349
361
  continue;
350
362
  }
351
363
 
352
- const colName = toSnakeCase(propName);
364
+ const colName = resolveColumnName(propName, prop);
353
365
 
354
366
  // Skip system columns — they're handled automatically
355
367
  if (systemColumns.has(colName)) continue;
@@ -5,6 +5,18 @@ import { toSnakeCase } from "@rebasepro/utils";
5
5
  import { createHash } from "crypto";
6
6
  // --- Helper Functions ---
7
7
 
8
+ /**
9
+ * Resolve the SQL column name for a property.
10
+ * Uses the explicit `columnName` when set (e.g. from introspection),
11
+ * falling back to `toSnakeCase(propName)` for manually-authored collections.
12
+ */
13
+ const resolveColumnName = (propName: string, prop?: Property | null): string => {
14
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") {
15
+ return prop.columnName;
16
+ }
17
+ return toSnakeCase(propName);
18
+ };
19
+
8
20
  const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number", isUuid: boolean } => {
9
21
  if (collection.properties) {
10
22
  const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
@@ -46,7 +58,7 @@ const isIdProperty = (propName: string, prop: Property, collection: EntityCollec
46
58
  };
47
59
 
48
60
  const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string | null => {
49
- const colName = toSnakeCase(propName);
61
+ const colName = resolveColumnName(propName, prop);
50
62
  let columnDefinition: string;
51
63
 
52
64
  switch (prop.type) {
@@ -126,6 +138,10 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
126
138
  } else {
127
139
  columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
128
140
  }
141
+ // autoValue: database-level default for initial value on INSERT
142
+ if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
143
+ columnDefinition += `.default(sql\`now()\`)`;
144
+ }
129
145
  break;
130
146
  }
131
147
  case "map":
@@ -167,7 +183,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
167
183
  return null; // Cannot resolve target
168
184
  }
169
185
 
170
- const fkColumnName = toSnakeCase(relation.localKey);
186
+ const fkColumnName = relation.localKey;
171
187
  const targetTableVar = getTableVarName(getTableName(targetCollection));
172
188
  const pkProp = getPrimaryKeyProp(targetCollection);
173
189
  const targetIdField = pkProp.name;
@@ -461,6 +477,8 @@ const computeSharedRelationName = (
461
477
  export const generateSchema = async (collections: EntityCollection[], stripPolicies = false): Promise<string> => {
462
478
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
463
479
 
480
+
481
+
464
482
  const hasUuid = collections.some(c =>
465
483
  c.properties && Object.values(c.properties).some(
466
484
  (p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
@@ -496,7 +514,7 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
496
514
  Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
497
515
  if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
498
516
  const enumVarName = getEnumVarName(collectionPath, propName);
499
- const enumDbName = `${collectionPath}_${toSnakeCase(propName)}`;
517
+ const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
500
518
  const values = Array.isArray(prop.enum)
501
519
  ? prop.enum.map(v => String(v.id ?? v))
502
520
  : Object.keys(prop.enum);
@@ -560,8 +578,8 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
560
578
  const targetId = getPrimaryKeyName(targetCollection);
561
579
 
562
580
  schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
563
- schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${toSnakeCase(sourceColumn)}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
564
- schemaContent += ` ${targetColumn}: ${targetColType}(\"${toSnakeCase(targetColumn)}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
581
+ schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
582
+ schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
565
583
  schemaContent += "}, (table) => ({\n";
566
584
  schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
567
585
  schemaContent += "}));\n\n";
@@ -571,6 +589,7 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
571
589
  Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
572
590
  const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
573
591
  if (columnString) columns.add(columnString);
592
+
574
593
  });
575
594
 
576
595
  // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
@@ -728,6 +747,33 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
728
747
  console.warn(`Could not generate relation ${relationKey} for ${collection.name}:`, e);
729
748
  }
730
749
  }
750
+
751
+ // Synthesize missing reciprocal relations
752
+ for (const otherCollection of collections) {
753
+ if (otherCollection.slug === collection.slug) continue;
754
+
755
+ const otherRelations = resolveCollectionRelations(otherCollection);
756
+ for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
757
+ if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
758
+ try {
759
+ const otherTarget = otherRel.target();
760
+ if (otherTarget.slug === collection.slug) {
761
+ const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
762
+ const deduplicationKey = `${drizzleRelationName}::owning`;
763
+
764
+ if (!emittedRelationNames.has(deduplicationKey)) {
765
+ const otherTableVar = getTableVarName(getTableName(otherCollection));
766
+ const synthKey = `_synth_${otherTableVar}_${otherRel.foreignKeyOnTarget}`;
767
+ tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${otherRel.foreignKeyOnTarget}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
768
+ emittedRelationNames.add(deduplicationKey);
769
+ }
770
+ }
771
+ } catch (e) {
772
+ // ignore
773
+ }
774
+ }
775
+ }
776
+ }
731
777
  }
732
778
 
733
779
  if (tableRelations.length > 0) {
@@ -745,3 +791,4 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
745
791
 
746
792
  return schemaContent;
747
793
  };
794
+