@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.gcd6689e

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.13.1-canary.gcd6689e",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/codegen": "0.13.0",
51
- "@rebasepro/common": "0.13.0",
52
- "@rebasepro/types": "0.13.0",
53
- "@rebasepro/server": "0.13.0",
54
- "@rebasepro/utils": "0.13.0"
50
+ "@rebasepro/codegen": "0.13.1-canary.gcd6689e",
51
+ "@rebasepro/common": "0.13.1-canary.gcd6689e",
52
+ "@rebasepro/types": "0.13.1-canary.gcd6689e",
53
+ "@rebasepro/utils": "0.13.1-canary.gcd6689e",
54
+ "@rebasepro/server": "0.13.1-canary.gcd6689e"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
package/src/cli.ts CHANGED
@@ -828,7 +828,86 @@ async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
828
828
  }
829
829
  }
830
830
 
831
+ /**
832
+ * `schema stale [--fix]` — is the generated Drizzle schema older than the rule
833
+ * that derives its foreign-key names?
834
+ *
835
+ * This exists for one upgrade path and is worth the command. 0.13 derives
836
+ * `category_id` where 0.12 derived `categorie_id`; boot-ensure renames the
837
+ * database column to match, and the project's checked-in
838
+ * `backend/src/schema.generated.ts` is then wrong in a way nothing the developer
839
+ * did would explain. Relation validation refuses to boot on it, permanently,
840
+ * because the rename has already been applied and will not run again.
841
+ *
842
+ * `rebase dev` calls this with `--fix` before starting the backend, so the
843
+ * upgrade behaves the way the release note says it does: the column moves and the
844
+ * project keeps working. Without `--fix` it reports and exits non-zero, which is
845
+ * what a build or a CI step wants.
846
+ */
847
+ async function schemaStaleCommand(rawArgs: string[]): Promise<void> {
848
+ const argsList = arg(
849
+ {
850
+ "--collections": String,
851
+ "--output": String,
852
+ "--fix": Boolean,
853
+ "-c": "--collections",
854
+ "-o": "--output"
855
+ },
856
+ { argv: rawArgs.slice(2), permissive: true }
857
+ );
858
+
859
+ const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
860
+ const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
861
+ const schemaFile = path.resolve(process.cwd(), outputPath);
862
+
863
+ // No generated schema yet is not staleness — a fresh project has not run the
864
+ // generator, and saying "stale" about a file that does not exist would send
865
+ // the reader looking for something to fix.
866
+ if (!fs.existsSync(schemaFile)) return;
867
+
868
+ const { loadCollections } = await import("./schema/doctor");
869
+ const { findLegacyForeignKeyNames, describeLegacyForeignKeyNames } =
870
+ await import("./schema/generated-schema-staleness");
871
+
872
+ let stale;
873
+ try {
874
+ const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
875
+ stale = findLegacyForeignKeyNames(fs.readFileSync(schemaFile, "utf8"), collections);
876
+ } catch (err) {
877
+ // Best-effort by design: a collections directory that will not load is a
878
+ // real error, but it is one the boot reports far better than this does.
879
+ logger.debug(`schema stale: skipped (${err instanceof Error ? err.message : String(err)})`);
880
+ return;
881
+ }
882
+
883
+ if (stale.length === 0) return;
884
+
885
+ logger.info("");
886
+ logger.warn(chalk.yellow(
887
+ ` ⚠️ ${outputPath} names ${stale.length} foreign key(s) the way an earlier release did:`
888
+ ));
889
+ logger.info(chalk.gray(describeLegacyForeignKeyNames(stale)));
890
+ logger.info("");
891
+
892
+ if (!argsList["--fix"]) {
893
+ logger.error(chalk.red(
894
+ " The database column has already been renamed at boot, so the generated schema no " +
895
+ "longer matches it and the server will refuse to start."
896
+ ));
897
+ logger.error(chalk.red(" Run `rebase schema generate` to regenerate it."));
898
+ process.exit(1);
899
+ }
900
+
901
+ logger.info(chalk.gray(" Regenerating the Drizzle schema so it matches..."));
902
+ await schemaCommand("generate", ["schema", "generate", `--collections=${collectionsPath}`, `--output=${outputPath}`]);
903
+ }
904
+
831
905
  async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
906
+ if (subcommand === "stale") {
907
+ await schemaStaleCommand(rawArgs);
908
+ return;
909
+ }
910
+
832
911
  if (subcommand === "generate") {
833
912
  const argsList = arg(
834
913
  {
@@ -2,6 +2,7 @@ import { getTableColumns } from "drizzle-orm";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
4
4
  import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
5
+ import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
5
6
 
6
7
  import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
7
8
 
@@ -58,6 +59,68 @@ const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join
58
59
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
59
60
  const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
60
61
 
62
+ /**
63
+ * Distinguish "this column name is wrong" from "the generated schema is old".
64
+ *
65
+ * They present identically here — a relation asks for a column the registered
66
+ * table does not have — but they are opposite problems with opposite fixes, and
67
+ * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
68
+ * projects.
69
+ *
70
+ * The registered table is not the database. It comes from the project's
71
+ * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
72
+ * derives foreign-key names: `categories` yields `category_id` where it used to
73
+ * yield `categorie_id`. Boot-ensure renames the database column to match, so by
74
+ * the time this runs the *database* is correct and the *generated module* is the
75
+ * stale one. Reporting "not a column" then points at the wrong artifact, and the
76
+ * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
77
+ * name because that is what the stale module still has — talks the reader into
78
+ * pinning a column that no longer exists.
79
+ *
80
+ * So when the wanted name is what the current rule derives, and the table
81
+ * carries what the *previous* rule would have derived from the same source, say
82
+ * that instead.
83
+ *
84
+ * @param wanted the column the relation asks for
85
+ * @param available every column the registered table has
86
+ * @param sources names the default could have been derived from (a slug, a
87
+ * relation name) — checking against these rather than guessing
88
+ * backwards from `wanted` keeps the match exact
89
+ */
90
+ function staleCodegenRename(
91
+ wanted: string,
92
+ available: Set<string>,
93
+ sources: string[]
94
+ ): { legacy: string; current: string } | null {
95
+ for (const source of sources) {
96
+ if (!source) continue;
97
+ const current = generateForeignKeyName(source);
98
+ const legacy = legacyForeignKeyName(source);
99
+ // Only a name that actually moved, and only when the table still has the
100
+ // old spelling and not the new one.
101
+ if (current !== wanted || legacy === current) continue;
102
+ if (available.has(legacy) && !available.has(current)) return { legacy, current };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** The shared explanation, so every relation kind reports it identically. */
108
+ function staleCodegenDefect(
109
+ table: string,
110
+ { legacy, current }: { legacy: string; current: string }
111
+ ): Pick<RelationDefect, "problem" | "fix"> {
112
+ return {
113
+ problem:
114
+ `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this ` +
115
+ `release derives \`${current}\` — the generated schema predates the foreign-key ` +
116
+ "naming fix and no longer describes the database",
117
+ fix:
118
+ "regenerate it with `rebase schema generate` (or `pnpm run schema:generate`). The " +
119
+ "database column has already been renamed for you at boot, so nothing else is needed. " +
120
+ `To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
121
+ };
122
+ }
123
+
61
124
  /**
62
125
  * Relations whose names do not resolve against the registered schema.
63
126
  *
@@ -117,11 +180,20 @@ kind: relation.kind };
117
180
  switch (relation.kind) {
118
181
  case "belongsTo": {
119
182
  if (!sourceColumns.has(relation.localKey)) {
120
- defects.push({
121
- ...at,
122
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
123
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
124
- });
183
+ // `localKey` defaults to the relation name run through
184
+ // the foreign-key rule, so it moves with that rule.
185
+ const stale = staleCodegenRename(
186
+ relation.localKey,
187
+ sourceColumns,
188
+ [relation.relationName, targetCollection.slug]
189
+ );
190
+ defects.push(stale
191
+ ? { ...at, ...staleCodegenDefect(sourceTableName, stale) }
192
+ : {
193
+ ...at,
194
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
195
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
196
+ });
125
197
  }
126
198
  break;
127
199
  }
@@ -129,11 +201,20 @@ kind: relation.kind };
129
201
  case "hasOne":
130
202
  case "hasMany": {
131
203
  if (!targetColumns.has(relation.foreignKeyOnTarget)) {
132
- defects.push({
133
- ...at,
134
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
135
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
136
- });
204
+ // The default is derived from *this* collection's slug —
205
+ // the column on the target that points back here.
206
+ const stale = staleCodegenRename(
207
+ relation.foreignKeyOnTarget,
208
+ targetColumns,
209
+ [collection.slug]
210
+ );
211
+ defects.push(stale
212
+ ? { ...at, ...staleCodegenDefect(targetTableName, stale) }
213
+ : {
214
+ ...at,
215
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
216
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
217
+ });
137
218
  }
138
219
  // `sourceKey` is the easiest of the two to put on the wrong
139
220
  // side — it is the only column in a `hasMany` that lives
@@ -167,14 +248,24 @@ kind: relation.kind };
167
248
  break;
168
249
  }
169
250
  const junctionColumns = columnNames(junction);
251
+ // Junction columns are the ones that actually moved in 0.13:
252
+ // each defaults to its endpoint collection's *slug* run
253
+ // through the foreign-key rule, and slugs are plural.
254
+ const derivedFrom = {
255
+ sourceColumn: [collection.slug],
256
+ targetColumn: [targetCollection.slug]
257
+ } as const;
170
258
  for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
171
259
  if (!junctionColumns.has(column)) {
172
- defects.push({
173
- ...at,
174
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
175
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
176
- (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
177
- });
260
+ const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
261
+ defects.push(stale
262
+ ? { ...at, ...staleCodegenDefect(table, stale) }
263
+ : {
264
+ ...at,
265
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
266
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
267
+ (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
268
+ });
178
269
  }
179
270
  }
180
271
  break;
@@ -0,0 +1,164 @@
1
+ import { CollectionConfig } from "@rebasepro/types";
2
+ import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
3
+ import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
4
+
5
+ /**
6
+ * Notice a generated Drizzle schema that a *library upgrade* invalidated.
7
+ *
8
+ * `rebase dev` already watches `config/collections` and warns when a collection
9
+ * file changes. That covers drift the developer caused. It cannot cover this one,
10
+ * because nothing the developer owns changed: 0.13 derives `category_id` where
11
+ * 0.12 derived `categorie_id`, from the same unedited collection. The watcher
12
+ * never fires, and `backend/src/schema.generated.ts` quietly stops describing the
13
+ * schema the runtime expects.
14
+ *
15
+ * The consequence is not cosmetic. Boot-ensure renames the column in the
16
+ * database, then relation validation reads the stale module and refuses to
17
+ * start — on that boot and every boot after it, because the rename is already
18
+ * applied and will not be attempted again.
19
+ *
20
+ * Deliberately narrow: this answers "does the generated schema name a foreign key
21
+ * the way the previous rule did", not "is this file what we would generate now".
22
+ * The wide question would report every whitespace change in the generator as a
23
+ * fatal staleness, and a check that cries wolf gets switched off.
24
+ */
25
+
26
+ /** One column the generated schema names under the pre-0.13 rule. */
27
+ export interface LegacyForeignKeyName {
28
+ /** Table whose column declaration is stale. */
29
+ table: string;
30
+ /** The name the generated schema declares. */
31
+ legacy: string;
32
+ /** The name this release derives, and which the database now carries. */
33
+ current: string;
34
+ /** `<collection>.<relation>` that derives it, for the message. */
35
+ relation: string;
36
+ }
37
+
38
+ /**
39
+ * The slice of the generated source declaring one table.
40
+ *
41
+ * Scoping matters: two junctions in one file can carry columns of the same name,
42
+ * and a whole-file match would attribute a stale column to whichever table the
43
+ * reader looks at first. Returns "" when the table is not in the file at all,
44
+ * which is not staleness — it is a table the generator has not been asked about.
45
+ */
46
+ function tableBlock(source: string, table: string): string {
47
+ const start = source.indexOf(`pgTable("${table}"`);
48
+ if (start === -1) return "";
49
+ // Generated files put every table in its own `export const`, so the next one
50
+ // is the end of this block. No brace counting, nothing to get wrong.
51
+ const next = source.indexOf("\nexport ", start);
52
+ return next === -1 ? source.slice(start) : source.slice(start, next);
53
+ }
54
+
55
+ /**
56
+ * Whether a block *declares* the column, rather than merely mentioning it.
57
+ *
58
+ * A comment explaining the rename, or a policy expression naming the old column,
59
+ * must not read as a declaration — otherwise regenerating the file would not
60
+ * clear the finding and the check would be permanently red.
61
+ */
62
+ function declaresColumn(block: string, column: string): boolean {
63
+ // `categorie_id: integer("categorie_id")` — the Drizzle column shape. The key
64
+ // and the string argument agree in generated output, so requiring both is
65
+ // both precise and cheap.
66
+ const key = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ return new RegExp(`(^|[\\s,{])${key}\\s*:\\s*\\w+\\(\\s*["']${key}["']`, "m").test(block);
68
+ }
69
+
70
+ /**
71
+ * @param generatedSource contents of `backend/src/schema.generated.ts`
72
+ * @param collections the project's collections, as this release reads them
73
+ */
74
+ export function findLegacyForeignKeyNames(
75
+ generatedSource: string,
76
+ collections: CollectionConfig[]
77
+ ): LegacyForeignKeyName[] {
78
+ const found: LegacyForeignKeyName[] = [];
79
+ const seen = new Set<string>();
80
+
81
+ /**
82
+ * Report `wanted` as stale when the generated schema declares what the
83
+ * previous rule would have derived from `source` instead.
84
+ *
85
+ * The `wanted !== current` guard is what honours an explicitly named column.
86
+ * An author who pinned `categorie_id` has said so on the relation, so the
87
+ * generated file agreeing with them is correct, not stale — and the rename
88
+ * note documents exactly that opt-out.
89
+ */
90
+ const consider = (
91
+ table: string,
92
+ wanted: string,
93
+ sourceName: string | undefined,
94
+ relation: string
95
+ ): void => {
96
+ if (!table || !wanted || !sourceName) return;
97
+
98
+ const current = generateForeignKeyName(sourceName);
99
+ const legacy = legacyForeignKeyName(sourceName);
100
+ if (legacy === current) return; // this name never moved
101
+ if (wanted !== current) return; // pinned by the author, or unrelated
102
+
103
+ const block = tableBlock(generatedSource, table);
104
+ if (!block) return;
105
+ if (!declaresColumn(block, legacy)) return;
106
+ if (declaresColumn(block, current)) return; // already regenerated
107
+
108
+ const key = `${table}.${legacy}`;
109
+ if (seen.has(key)) return;
110
+ seen.add(key);
111
+ found.push({ table, legacy, current, relation });
112
+ };
113
+
114
+ for (const collection of collections) {
115
+ const sourceTable = getTableName(collection);
116
+
117
+ for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
118
+ const at = `${collection.slug}.${name}`;
119
+
120
+ let target: CollectionConfig | undefined;
121
+ try {
122
+ target = relation.target?.();
123
+ } catch {
124
+ // A throwing target thunk is its own defect, reported at boot by
125
+ // `validate-relations`. Nothing to say about its column names.
126
+ continue;
127
+ }
128
+
129
+ switch (relation.kind) {
130
+ case "belongsTo":
131
+ consider(sourceTable, relation.localKey, relation.relationName, at);
132
+ break;
133
+
134
+ case "hasOne":
135
+ case "hasMany":
136
+ if (target) {
137
+ consider(getTableName(target), relation.foreignKeyOnTarget, collection.slug, at);
138
+ }
139
+ break;
140
+
141
+ case "manyToMany":
142
+ consider(relation.through.table, relation.through.sourceColumn, collection.slug, at);
143
+ if (target) {
144
+ consider(relation.through.table, relation.through.targetColumn, target.slug, at);
145
+ }
146
+ break;
147
+
148
+ default:
149
+ // `via` joins are written by hand — there is no derived name
150
+ // for the rule change to have moved.
151
+ break;
152
+ }
153
+ }
154
+ }
155
+
156
+ return found;
157
+ }
158
+
159
+ /** One-line summary for a log or a CLI notice. */
160
+ export function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): string {
161
+ return found
162
+ .map(f => ` • ${f.table}.${f.legacy} → ${f.current} (${f.relation})`)
163
+ .join("\n");
164
+ }
@@ -1089,7 +1089,7 @@ export function generateCollectionFile(
1089
1089
 
1090
1090
  if (derivedFacts) {
1091
1091
  const titleProperty = deriveTitleProperty(derivedFacts);
1092
- if (titleProperty) adminEntries.push(`titleProperty: ${quote(titleProperty)}`);
1092
+ if (titleProperty) adminEntries.push(`display: { title: ${quote(titleProperty)} }`);
1093
1093
 
1094
1094
  const kanbanProperty = deriveKanbanProperty(derivedFacts);
1095
1095
  if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(kanbanProperty)}\n }`);
@@ -1454,99 +1454,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1454
1454
  return !!this.getQueryBuilder(tableName);
1455
1455
  }
1456
1456
 
1457
- /**
1458
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
1459
- * for efficient JOIN-based relation loading.
1460
- * Returns null if the API is not available or the query fails.
1461
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1462
- */
1463
- private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
1464
- collectionPath: string,
1465
- collection: CollectionConfig,
1466
- options: {
1467
- filter?: FilterValues<Extract<keyof M, string>>;
1468
- orderBy?: string;
1469
- order?: "desc" | "asc";
1470
- limit?: number;
1471
- },
1472
- include: string[],
1473
- idInfo: { fieldName: string; type: "string" | "number" },
1474
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1475
- ): Promise<Record<string, unknown>[] | null> {
1476
- try {
1477
-
1478
- const table = getTableForCollection(collection, this.registry);
1479
- const tableName = getTableName(table);
1480
- const queryTarget = this.getQueryBuilder(tableName);
1481
-
1482
- if (!queryTarget?.findMany) return null;
1483
-
1484
- // Build the `with` config from include array
1485
- const resolvedRelations = resolveCollectionRelations(collection);
1486
- const withConfig: Record<string, boolean> = {};
1487
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1488
- if (include[0] === "*" || include.includes(key)) {
1489
- // Use the Drizzle relation name (from the schema)
1490
- const drizzleRelName = relation.relationName || key;
1491
- withConfig[drizzleRelName] = true;
1492
- }
1493
- }
1494
-
1495
- // Build query options
1496
- const queryOpts: Record<string, unknown> = { with: withConfig };
1497
- if (options.limit) queryOpts.limit = options.limit;
1498
-
1499
- // Build where clause
1500
- if (options.filter) {
1501
- const filterConditions = this.buildFilterConditions(
1502
- options.filter, table, collectionPath
1503
- );
1504
- if (filterConditions.length > 0) {
1505
- queryOpts.where = and(...filterConditions);
1506
- }
1507
- }
1508
-
1509
- // Build orderBy
1510
- if (options.orderBy) {
1511
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1512
- if (orderByField) {
1513
- queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1514
- }
1515
- }
1516
-
1517
-
1518
- const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1519
-
1520
- // Inline the nested Drizzle results, columns only — no synthesized id.
1521
- return results.map((row: Record<string, unknown>) => {
1522
- const flat: Record<string, unknown> = {};
1523
- for (const [k, v] of Object.entries(row)) {
1524
- if (Array.isArray(v)) {
1525
- // Many relation — inline each nested row
1526
- flat[k] = v.map((item: Record<string, unknown>) => {
1527
- // Junction table rows may have the target nested, unwrap those
1528
- const keys = Object.keys(item);
1529
- const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1530
- if (nestedObj && keys.length <= 3) {
1531
- return { ...(item[nestedObj] as Record<string, unknown>) };
1532
- }
1533
- return { ...item };
1534
- });
1535
- } else if (typeof v === "object" && v !== null) {
1536
- // One-to-one relation — inline the target's columns
1537
- flat[k] = { ...(v as Record<string, unknown>) };
1538
- } else {
1539
- flat[k] = v;
1540
- }
1541
- }
1542
- return flat;
1543
- });
1544
- } catch (e) {
1545
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
1546
- return null;
1547
- }
1548
- }
1549
-
1550
1457
  /**
1551
1458
  * Fallback path used when db.query is unavailable.
1552
1459
  * The primary path uses db.query.findMany with `with` config, which