@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/dist/index.es.js +86 -61
- package/dist/index.es.js.map +1 -1
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/services/FetchService.d.ts +0 -7
- package/dist/src-DlPBctw_.js.map +1 -1
- package/package.json +6 -6
- package/src/cli.ts +79 -0
- package/src/collections/validate-relations.ts +107 -16
- package/src/schema/generated-schema-staleness.ts +164 -0
- package/src/schema/introspect-db-logic.ts +1 -1
- package/src/services/FetchService.ts +0 -93
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.13.
|
|
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.
|
|
51
|
-
"@rebasepro/common": "0.13.
|
|
52
|
-
"@rebasepro/types": "0.13.
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(`
|
|
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
|