@rebasepro/server-postgres 0.0.1-canary.4829d6e
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/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +640 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
|
+
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
4
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
|
+
import { createHash } from "crypto";
|
|
6
|
+
import { logger } from "@rebasepro/server";
|
|
7
|
+
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
8
|
+
// --- Helper Functions ---
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the SQL column name for a property.
|
|
12
|
+
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
13
|
+
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
14
|
+
*/
|
|
15
|
+
const resolveColumnName = (propName: string, prop?: Property | null): string => {
|
|
16
|
+
if (prop && "columnName" in prop && typeof prop.columnName === "string") {
|
|
17
|
+
return prop.columnName;
|
|
18
|
+
}
|
|
19
|
+
return toSnakeCase(propName);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
|
|
23
|
+
if (collection.properties) {
|
|
24
|
+
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
|
|
25
|
+
if (idPropEntry) {
|
|
26
|
+
const prop = idPropEntry[1] as unknown as Property;
|
|
27
|
+
const isUuid = prop.type === "string" && "isId" in prop && (prop as unknown as StringProperty).isId === "uuid";
|
|
28
|
+
return { name: idPropEntry[0],
|
|
29
|
+
type: prop.type === "number" ? "number" : "string",
|
|
30
|
+
isUuid };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Fallback
|
|
34
|
+
const idProp = collection.properties?.["id"] as unknown as Property | undefined;
|
|
35
|
+
if (idProp?.type === "number") {
|
|
36
|
+
return { name: "id",
|
|
37
|
+
type: "number",
|
|
38
|
+
isUuid: false };
|
|
39
|
+
}
|
|
40
|
+
const isUuid = idProp?.type === "string" && "isId" in idProp && (idProp as unknown as StringProperty).isId === "uuid";
|
|
41
|
+
return { name: "id",
|
|
42
|
+
type: "string",
|
|
43
|
+
isUuid: isUuid ?? false };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Given a raw DB column name (e.g. "client_id"), find the Drizzle property key
|
|
48
|
+
* on the collection that maps to that column. A property matches if:
|
|
49
|
+
* (a) it has an explicit `columnName` equal to the given column, OR
|
|
50
|
+
* (b) its snake_case form equals the given column.
|
|
51
|
+
*
|
|
52
|
+
* Returns the property key (the Drizzle object key) if found, or the original
|
|
53
|
+
* column name as a fallback.
|
|
54
|
+
*/
|
|
55
|
+
const resolvePropertyKeyForColumn = (collection: CollectionConfig, column: string): string => {
|
|
56
|
+
if (!collection.properties) return column;
|
|
57
|
+
for (const [propKey, prop] of Object.entries(collection.properties)) {
|
|
58
|
+
const p = prop as Property;
|
|
59
|
+
// Explicit columnName match
|
|
60
|
+
if ("columnName" in p && typeof (p as unknown as Record<string, unknown>).columnName === "string") {
|
|
61
|
+
if ((p as unknown as Record<string, unknown>).columnName === column) return propKey;
|
|
62
|
+
}
|
|
63
|
+
// Convention match: snake_case(propKey) === column
|
|
64
|
+
if (toSnakeCase(propKey) === column) return propKey;
|
|
65
|
+
// Exact match (propKey is already the column name)
|
|
66
|
+
if (propKey === column) return propKey;
|
|
67
|
+
}
|
|
68
|
+
return column;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isNumericId = (collection: CollectionConfig): boolean => {
|
|
72
|
+
return getPrimaryKeyProp(collection).type === "number";
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const getPrimaryKeyName = (collection: CollectionConfig): string => {
|
|
76
|
+
return getPrimaryKeyProp(collection).name;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
|
|
80
|
+
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
81
|
+
|
|
82
|
+
// We only fallback to "id" if NO property is explicitly marked with `isId: true` or a generator string
|
|
83
|
+
const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
|
|
84
|
+
return !hasExplicitId && propName === "id";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const getDrizzleColumn = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string | null => {
|
|
88
|
+
|
|
89
|
+
const colName = resolveColumnName(propName, prop);
|
|
90
|
+
let columnDefinition: string;
|
|
91
|
+
|
|
92
|
+
switch (prop.type) {
|
|
93
|
+
case "string": {
|
|
94
|
+
const stringProp = prop as unknown as StringProperty;
|
|
95
|
+
if (stringProp.enum) {
|
|
96
|
+
const enumName = getEnumVarName(getTableName(collection), propName);
|
|
97
|
+
columnDefinition = `${enumName}("${colName}")`;
|
|
98
|
+
} else if ("isId" in stringProp && stringProp.isId === "uuid") {
|
|
99
|
+
columnDefinition = `uuid("${colName}")`;
|
|
100
|
+
} else if (stringProp.columnType === "uuid") {
|
|
101
|
+
columnDefinition = `uuid("${colName}")`;
|
|
102
|
+
} else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
103
|
+
columnDefinition = `text("${colName}")`;
|
|
104
|
+
} else if (stringProp.columnType === "char") {
|
|
105
|
+
columnDefinition = `char("${colName}")`;
|
|
106
|
+
} else {
|
|
107
|
+
columnDefinition = `varchar("${colName}")`;
|
|
108
|
+
}
|
|
109
|
+
if (isIdProperty(propName, prop, collection)) {
|
|
110
|
+
columnDefinition += ".primaryKey()";
|
|
111
|
+
}
|
|
112
|
+
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
113
|
+
if (stringProp.isId === "uuid") {
|
|
114
|
+
columnDefinition += ".defaultRandom()";
|
|
115
|
+
} else if (stringProp.isId === "cuid") {
|
|
116
|
+
columnDefinition += ".default(sql`cuid()`)";
|
|
117
|
+
} else if (typeof stringProp.isId === "string") {
|
|
118
|
+
const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`")
|
|
119
|
+
? stringProp.isId.substring(4, stringProp.isId.length - 1)
|
|
120
|
+
: stringProp.isId;
|
|
121
|
+
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (stringProp.validation?.unique) {
|
|
125
|
+
columnDefinition += ".unique()";
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "number": {
|
|
130
|
+
const numProp = prop as unknown as NumberProperty;
|
|
131
|
+
const isId = isIdProperty(propName, prop, collection);
|
|
132
|
+
|
|
133
|
+
let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
|
|
134
|
+
if (numProp.columnType) {
|
|
135
|
+
if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
|
|
136
|
+
else baseType = `${numProp.columnType}("${colName}")`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ("isId" in numProp && numProp.isId === "increment") {
|
|
140
|
+
columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
141
|
+
} else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
142
|
+
columnDefinition = baseType;
|
|
143
|
+
const sqlContent = numProp.isId.startsWith("sql`") && numProp.isId.endsWith("`")
|
|
144
|
+
? numProp.isId.substring(4, numProp.isId.length - 1)
|
|
145
|
+
: numProp.isId;
|
|
146
|
+
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
147
|
+
} else {
|
|
148
|
+
columnDefinition = baseType;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isId) {
|
|
152
|
+
columnDefinition += ".primaryKey()";
|
|
153
|
+
}
|
|
154
|
+
if (numProp.validation?.unique) {
|
|
155
|
+
columnDefinition += ".unique()";
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "boolean":
|
|
160
|
+
columnDefinition = `boolean("${colName}")`;
|
|
161
|
+
break;
|
|
162
|
+
case "date": {
|
|
163
|
+
const dateProp = prop as DateProperty;
|
|
164
|
+
if (dateProp.columnType === "date") {
|
|
165
|
+
columnDefinition = `date("${colName}", { mode: 'string' })`;
|
|
166
|
+
} else if (dateProp.columnType === "time") {
|
|
167
|
+
columnDefinition = `time("${colName}")`;
|
|
168
|
+
} else {
|
|
169
|
+
columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
|
|
170
|
+
}
|
|
171
|
+
// autoValue: database-level default for initial value on INSERT
|
|
172
|
+
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
|
|
173
|
+
columnDefinition += ".default(sql`now()`)";
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "map": {
|
|
178
|
+
const mapProp = prop as MapProperty;
|
|
179
|
+
if (mapProp.columnType === "json") {
|
|
180
|
+
columnDefinition = `json("${colName}")`;
|
|
181
|
+
} else {
|
|
182
|
+
columnDefinition = `jsonb("${colName}")`;
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case "array": {
|
|
187
|
+
const arrayProp = prop as ArrayProperty;
|
|
188
|
+
let colType = arrayProp.columnType;
|
|
189
|
+
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
190
|
+
const ofProp = arrayProp.of as Property;
|
|
191
|
+
if (ofProp.type === "string") {
|
|
192
|
+
colType = "text[]";
|
|
193
|
+
} else if (ofProp.type === "number") {
|
|
194
|
+
colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
195
|
+
} else if (ofProp.type === "boolean") {
|
|
196
|
+
colType = "boolean[]";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (colType === "json") {
|
|
201
|
+
columnDefinition = `json("${colName}")`;
|
|
202
|
+
} else if (colType === "text[]") {
|
|
203
|
+
columnDefinition = `text("${colName}").array()`;
|
|
204
|
+
} else if (colType === "integer[]") {
|
|
205
|
+
columnDefinition = `integer("${colName}").array()`;
|
|
206
|
+
} else if (colType === "boolean[]") {
|
|
207
|
+
columnDefinition = `boolean("${colName}").array()`;
|
|
208
|
+
} else if (colType === "numeric[]") {
|
|
209
|
+
columnDefinition = `numeric("${colName}").array()`;
|
|
210
|
+
} else {
|
|
211
|
+
columnDefinition = `jsonb("${colName}")`;
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "vector": {
|
|
216
|
+
const vp = prop as VectorProperty;
|
|
217
|
+
columnDefinition = `vector("${colName}", { dimensions: ${vp.dimensions} })`;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "binary": {
|
|
221
|
+
columnDefinition = `customType({ dataType() { return 'bytea'; } })("${colName}")`;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "relation": {
|
|
225
|
+
const refProp = prop as RelationProperty;
|
|
226
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
227
|
+
const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
228
|
+
|
|
229
|
+
// Only owning one-to-one/many-to-one relations create a column here.
|
|
230
|
+
if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The localKey property is the source of truth for the FK column name.
|
|
235
|
+
if (!relation.localKey) {
|
|
236
|
+
logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// If the localKey property is defined elsewhere in the properties, it will be handled there.
|
|
241
|
+
// This logic is for when the relation property itself defines the FK.
|
|
242
|
+
if (collection.properties[relation.localKey] && propName !== relation.localKey) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let targetCollection: CollectionConfig;
|
|
247
|
+
try {
|
|
248
|
+
targetCollection = relation.target();
|
|
249
|
+
} catch {
|
|
250
|
+
return null; // Cannot resolve target
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const fkColumnName = relation.localKey;
|
|
254
|
+
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
255
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
256
|
+
const targetIdField = pkProp.name;
|
|
257
|
+
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
|
|
258
|
+
|
|
259
|
+
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
260
|
+
const required = prop.validation?.required;
|
|
261
|
+
const onDeleteVal = relation.onDelete ?? (required ? "cascade" : "set null");
|
|
262
|
+
const onDelete = `onDelete: \"${onDeleteVal}\"`;
|
|
263
|
+
|
|
264
|
+
const refOptionsParts = [onUpdate, onDelete].filter(Boolean);
|
|
265
|
+
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
266
|
+
|
|
267
|
+
let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
|
|
268
|
+
|
|
269
|
+
if (required) {
|
|
270
|
+
columnDef += ".notNull()";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return ` ${relation.localKey}: ${columnDef}`;
|
|
274
|
+
}
|
|
275
|
+
case "reference": {
|
|
276
|
+
const refProp = prop as ReferenceProperty;
|
|
277
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
278
|
+
if (!targetCollection) {
|
|
279
|
+
columnDefinition = `varchar("${colName}")`;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
284
|
+
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
285
|
+
const targetIdField = pkProp.name;
|
|
286
|
+
const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`);
|
|
287
|
+
|
|
288
|
+
const required = prop.validation?.required;
|
|
289
|
+
const onDelete = required ? "cascade" : "set null";
|
|
290
|
+
const refOptions = `{ onDelete: "${onDelete}" }`;
|
|
291
|
+
|
|
292
|
+
columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${refOptions})`;
|
|
293
|
+
if (required) {
|
|
294
|
+
columnDefinition += ".notNull()";
|
|
295
|
+
}
|
|
296
|
+
// Skip the standard notNull() handling below because we did it here with references
|
|
297
|
+
return ` ${propName}: ${columnDefinition}`;
|
|
298
|
+
}
|
|
299
|
+
default:
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (prop.validation?.required) {
|
|
304
|
+
columnDefinition += ".notNull()";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return ` ${propName}: ${columnDefinition}`;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
312
|
+
*/
|
|
313
|
+
const wrapSql = (clause: string): string => `sql\`${clause}\``;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Generates a deterministic hash based on the rule configuration.
|
|
317
|
+
*/
|
|
318
|
+
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
319
|
+
const data = JSON.stringify({
|
|
320
|
+
a: rule.access,
|
|
321
|
+
m: rule.mode,
|
|
322
|
+
op: rule.operation,
|
|
323
|
+
ops: rule.operations?.slice().sort(),
|
|
324
|
+
own: rule.ownerField,
|
|
325
|
+
rol: rule.roles?.slice().sort(),
|
|
326
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
327
|
+
u: rule.using,
|
|
328
|
+
w: rule.withCheck,
|
|
329
|
+
c: rule.condition,
|
|
330
|
+
ch: rule.check
|
|
331
|
+
});
|
|
332
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
|
|
337
|
+
*
|
|
338
|
+
* Supports the full spectrum:
|
|
339
|
+
* - Convenience shortcuts: ownerField, access, roles
|
|
340
|
+
* - Raw SQL: using, withCheck
|
|
341
|
+
* - Mode: permissive (default) or restrictive
|
|
342
|
+
* - operations[] array: generates one policy per operation
|
|
343
|
+
* - Combinations: roles + ownerField, roles + raw SQL, etc.
|
|
344
|
+
*/
|
|
345
|
+
type ResolveCollection = (slug: string) => CollectionConfig | undefined;
|
|
346
|
+
|
|
347
|
+
const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, index: number, resolveCollection: ResolveCollection): string => {
|
|
348
|
+
const tableName = getTableName(collection);
|
|
349
|
+
// Resolve operations: operations[] takes precedence over operation (singular)
|
|
350
|
+
const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
351
|
+
? rule.operations
|
|
352
|
+
: [rule.operation ?? "all"];
|
|
353
|
+
|
|
354
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
355
|
+
|
|
356
|
+
// Generate one pgPolicy per operation
|
|
357
|
+
return ops.map((op, opIdx) => {
|
|
358
|
+
const policyName = rule.name
|
|
359
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
360
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
361
|
+
|
|
362
|
+
return generateSinglePolicyCode(collection, rule, op, policyName, resolveCollection);
|
|
363
|
+
}).join("");
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Generates a single pgPolicy() call for one specific operation.
|
|
368
|
+
*/
|
|
369
|
+
const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
|
|
370
|
+
const mode = rule.mode ?? "permissive";
|
|
371
|
+
|
|
372
|
+
// Determine which clauses this operation needs:
|
|
373
|
+
// SELECT, DELETE → USING only
|
|
374
|
+
// INSERT → WITH CHECK only
|
|
375
|
+
// UPDATE, ALL → both USING and WITH CHECK
|
|
376
|
+
const needsUsing = operation !== "insert";
|
|
377
|
+
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
378
|
+
|
|
379
|
+
// Desugar the rule (access / ownerField / roles / structured condition / raw
|
|
380
|
+
// SQL) into the shared PolicyExpression model, then compile to SQL — the same
|
|
381
|
+
// normalization the DDL generator and the client-side evaluator use.
|
|
382
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
383
|
+
|
|
384
|
+
let usingClause = needsUsing && usingExpr ? wrapSql(policyToPostgres(usingExpr, collection, { resolveCollection })) : null;
|
|
385
|
+
let withCheckClause = needsWithCheck && withCheckExpr ? wrapSql(policyToPostgres(withCheckExpr, collection, { resolveCollection })) : null;
|
|
386
|
+
|
|
387
|
+
// Fallback: if we still have no clauses, deny all (safety net)
|
|
388
|
+
if (!usingClause && needsUsing) {
|
|
389
|
+
usingClause = "sql`false`";
|
|
390
|
+
}
|
|
391
|
+
if (!withCheckClause && needsWithCheck) {
|
|
392
|
+
withCheckClause = "sql`false`";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Build the policy options object
|
|
396
|
+
const parts: string[] = [];
|
|
397
|
+
parts.push(`as: "${mode}"`);
|
|
398
|
+
parts.push(`for: "${operation}"`);
|
|
399
|
+
const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
400
|
+
parts.push(`to: [${toRoles.map(r => `"${r}"`).join(", ")}]`);
|
|
401
|
+
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
402
|
+
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
403
|
+
|
|
404
|
+
return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Computes a deterministic shared relation name for Drizzle.
|
|
409
|
+
*
|
|
410
|
+
* Drizzle requires both sides of a relation (owning + inverse) to use the
|
|
411
|
+
* exact same `relationName` string so it can pair them. Each collection
|
|
412
|
+
* definition may use a different local `relationName`, so we need a canonical
|
|
413
|
+
* form that both sides can independently compute.
|
|
414
|
+
*
|
|
415
|
+
* Strategy: `{owningTable}_{foreignKey}`
|
|
416
|
+
* - owning side → `{thisTable}_{localKey}` e.g. "jobs_company_id"
|
|
417
|
+
* - inverse side → `{targetTable}_{foreignKeyOnTarget}` e.g. "jobs_company_id"
|
|
418
|
+
*
|
|
419
|
+
* For M2M with junction tables the owning relation name is already shared via
|
|
420
|
+
* the junction table wiring, so we keep it as-is.
|
|
421
|
+
*
|
|
422
|
+
* Falls back to the local relation name when the counterpart can't be resolved.
|
|
423
|
+
*/
|
|
424
|
+
const computeSharedRelationName = (
|
|
425
|
+
rel: Relation,
|
|
426
|
+
sourceCollection: CollectionConfig,
|
|
427
|
+
_collections: CollectionConfig[]
|
|
428
|
+
): string => {
|
|
429
|
+
const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
|
|
430
|
+
|
|
431
|
+
// --- owning one (belongs-to) ---
|
|
432
|
+
if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
|
|
433
|
+
// Normalise the localKey to the actual Drizzle property name so that
|
|
434
|
+
// the owning side produces the same relation name as the inverse side
|
|
435
|
+
// (which resolves foreignKeyOnTarget via the same helper).
|
|
436
|
+
const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
|
|
437
|
+
return `${getTableName(sourceCollection)}_${normalisedKey}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- inverse many (one-to-many has-many) ---
|
|
441
|
+
if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
|
|
442
|
+
// The owning table is the *target*, the FK column is foreignKeyOnTarget.
|
|
443
|
+
// Resolve to the Drizzle property key on the target so it matches the
|
|
444
|
+
// owning side's normalised localKey.
|
|
445
|
+
try {
|
|
446
|
+
const targetCollection = rel.target();
|
|
447
|
+
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
448
|
+
return `${getTableName(targetCollection)}_${normalisedFK}`;
|
|
449
|
+
} catch {
|
|
450
|
+
return fallback;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- inverse one (one-to-one inverse) ---
|
|
455
|
+
if (rel.direction === "inverse" && rel.cardinality === "one") {
|
|
456
|
+
if (rel.foreignKeyOnTarget) {
|
|
457
|
+
// FK lives on the target table — resolve to Drizzle property key
|
|
458
|
+
try {
|
|
459
|
+
const targetCollection = rel.target();
|
|
460
|
+
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
461
|
+
return `${getTableName(targetCollection)}_${normalisedFK}`;
|
|
462
|
+
} catch {
|
|
463
|
+
return fallback;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// No explicit foreignKeyOnTarget — try to find the corresponding owning relation
|
|
467
|
+
try {
|
|
468
|
+
const targetCollection = rel.target();
|
|
469
|
+
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
470
|
+
const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
|
|
471
|
+
targetRel.direction === "owning" &&
|
|
472
|
+
targetRel.cardinality === "one" &&
|
|
473
|
+
targetRel.localKey &&
|
|
474
|
+
targetRel.target().slug === sourceCollection.slug
|
|
475
|
+
);
|
|
476
|
+
if (correspondingRelation && correspondingRelation.localKey) {
|
|
477
|
+
return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
|
|
478
|
+
}
|
|
479
|
+
} catch {
|
|
480
|
+
// ignore
|
|
481
|
+
}
|
|
482
|
+
return fallback;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// --- M2M owning (through) — keep local name (already shared via junction wiring) ---
|
|
486
|
+
// --- M2M inverse — keep local name ---
|
|
487
|
+
// --- joinPath — not emitted as Drizzle relations ---
|
|
488
|
+
return fallback;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// --- Main Schema Generation Logic ---
|
|
492
|
+
export const generateSchema = async (collections: CollectionConfig[], stripPolicies = false): Promise<string> => {
|
|
493
|
+
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
const hasUuid = collections.some(c =>
|
|
497
|
+
c.properties && Object.values(c.properties).some(
|
|
498
|
+
(p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
|
|
499
|
+
)
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const hasVector = collections.some(c =>
|
|
503
|
+
c.properties && Object.values(c.properties).some(
|
|
504
|
+
(p: Property) => p.type === "vector"
|
|
505
|
+
)
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const hasBinary = collections.some(c =>
|
|
509
|
+
c.properties && Object.values(c.properties).some(
|
|
510
|
+
(p: Property) => p.type === "binary"
|
|
511
|
+
)
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
|
|
515
|
+
const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
|
|
516
|
+
if (hasUuid) pgCoreImports.push("uuid");
|
|
517
|
+
if (hasVector) pgCoreImports.push("vector");
|
|
518
|
+
if (hasBinary) pgCoreImports.push("customType");
|
|
519
|
+
|
|
520
|
+
const uniqueSchemas = Array.from(new Set(
|
|
521
|
+
collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
|
|
522
|
+
));
|
|
523
|
+
if (uniqueSchemas.length > 0) {
|
|
524
|
+
pgCoreImports.push("pgSchema");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
|
|
528
|
+
schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
|
|
529
|
+
|
|
530
|
+
uniqueSchemas.forEach(schema => {
|
|
531
|
+
schemaContent += `export const ${schema}Schema = pgSchema("${schema}");\n`;
|
|
532
|
+
});
|
|
533
|
+
if (uniqueSchemas.length > 0) {
|
|
534
|
+
schemaContent += "\n";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const exportedTableVars: string[] = [];
|
|
538
|
+
const exportedEnumVars: string[] = [];
|
|
539
|
+
const exportedRelationVars: string[] = [];
|
|
540
|
+
|
|
541
|
+
const allTablesToGenerate = new Map<string, {
|
|
542
|
+
collection: CollectionConfig,
|
|
543
|
+
isJunction?: boolean,
|
|
544
|
+
relation?: Relation,
|
|
545
|
+
sourceCollection?: CollectionConfig
|
|
546
|
+
}>();
|
|
547
|
+
|
|
548
|
+
// 1. Generate Enums
|
|
549
|
+
collections.forEach(collection => {
|
|
550
|
+
const collectionPath = getTableName(collection);
|
|
551
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
552
|
+
|
|
553
|
+
if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
554
|
+
const enumVarName = getEnumVarName(collectionPath, propName);
|
|
555
|
+
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
556
|
+
const values = Array.isArray(prop.enum)
|
|
557
|
+
? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
|
|
558
|
+
String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
|
|
559
|
+
)
|
|
560
|
+
: Object.keys(prop.enum);
|
|
561
|
+
if (values.length > 0) {
|
|
562
|
+
schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
|
|
563
|
+
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
schemaContent += "\n";
|
|
569
|
+
|
|
570
|
+
// 2. Identify all tables (collections and junction tables only)
|
|
571
|
+
for (const collection of collections) {
|
|
572
|
+
const tableName = getTableName(collection);
|
|
573
|
+
if (tableName) {
|
|
574
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
578
|
+
for (const relation of Object.values(resolvedRelations)) {
|
|
579
|
+
if (relation.through) { // Standard M2M junction table
|
|
580
|
+
const junctionTableName = relation.through.table;
|
|
581
|
+
if (!allTablesToGenerate.has(junctionTableName)) {
|
|
582
|
+
allTablesToGenerate.set(junctionTableName, {
|
|
583
|
+
collection: {
|
|
584
|
+
table: junctionTableName,
|
|
585
|
+
properties: {}
|
|
586
|
+
} as CollectionConfig,
|
|
587
|
+
isJunction: true,
|
|
588
|
+
relation: relation,
|
|
589
|
+
sourceCollection: collection
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// joinPath relations use existing user-controlled tables - no generation needed
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 3. Generate pgTable definitions for all unique tables
|
|
598
|
+
for (const [tableName, {
|
|
599
|
+
collection,
|
|
600
|
+
isJunction,
|
|
601
|
+
relation,
|
|
602
|
+
sourceCollection
|
|
603
|
+
}] of allTablesToGenerate.entries()) {
|
|
604
|
+
const tableVarName = getTableVarName(tableName);
|
|
605
|
+
if (isJunction && relation && sourceCollection && relation.through) {
|
|
606
|
+
const targetCollection = relation.target();
|
|
607
|
+
const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : undefined) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : undefined);
|
|
608
|
+
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
609
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
610
|
+
const {
|
|
611
|
+
sourceColumn,
|
|
612
|
+
targetColumn
|
|
613
|
+
} = relation.through;
|
|
614
|
+
|
|
615
|
+
const onDelete = relation.onDelete ?? "cascade";
|
|
616
|
+
const refOptions = `{ onDelete: \"${onDelete}\" }`;
|
|
617
|
+
|
|
618
|
+
const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
|
|
619
|
+
const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
|
|
620
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
621
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
622
|
+
|
|
623
|
+
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
624
|
+
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
625
|
+
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
626
|
+
schemaContent += "}, (table) => ({\n";
|
|
627
|
+
schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
|
|
628
|
+
schemaContent += "}));\n\n";
|
|
629
|
+
} else if (!isJunction) {
|
|
630
|
+
const schema = isPostgresCollectionConfig(collection) ? collection.schema : undefined;
|
|
631
|
+
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
632
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
633
|
+
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
634
|
+
const columns = new Set<string>();
|
|
635
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
636
|
+
const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
|
|
637
|
+
if (columnString) columns.add(columnString);
|
|
638
|
+
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
|
|
642
|
+
// We should generate a basic id column if one was completely omitted.
|
|
643
|
+
const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
|
|
644
|
+
if (!hasIdColumn) {
|
|
645
|
+
columns.add(" id: varchar(\"id\").primaryKey()");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
649
|
+
|
|
650
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
651
|
+
if (!stripPolicies && securityRules.length > 0) {
|
|
652
|
+
schemaContent += "\n}, (table) => ([\n";
|
|
653
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
654
|
+
securityRules.forEach((rule: SecurityRule, idx: number) => {
|
|
655
|
+
schemaContent += generatePolicyCode(collection, rule, idx, resolveCollection);
|
|
656
|
+
});
|
|
657
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
658
|
+
} else {
|
|
659
|
+
// No explicit policies — RLS enabled with deny-all default (Postgres denies
|
|
660
|
+
// everything when RLS is on and no permissive policies exist).
|
|
661
|
+
schemaContent += "\n}).enableRLS();\n\n";
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!exportedTableVars.includes(tableVarName)) exportedTableVars.push(tableVarName);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 4. Generate Drizzle Relations
|
|
668
|
+
for (const [tableName, {
|
|
669
|
+
collection,
|
|
670
|
+
isJunction
|
|
671
|
+
}] of allTablesToGenerate.entries()) {
|
|
672
|
+
const tableVarName = getTableVarName(tableName);
|
|
673
|
+
const tableRelations: string[] = [];
|
|
674
|
+
|
|
675
|
+
if (isJunction) {
|
|
676
|
+
const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
|
|
677
|
+
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
|
|
678
|
+
const {
|
|
679
|
+
relation,
|
|
680
|
+
sourceCollection
|
|
681
|
+
} = relationInfo;
|
|
682
|
+
const targetCollection = relation.target();
|
|
683
|
+
const sourceTableVar = getTableVarName(getTableName(sourceCollection));
|
|
684
|
+
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
685
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
686
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
687
|
+
|
|
688
|
+
if (!relation?.through)
|
|
689
|
+
throw new Error("Internal, the relation should have a through property. Relations passed to this script should sanitized first with sanitizeRelation().");
|
|
690
|
+
|
|
691
|
+
// The owning relation's name — used on the source side of the junction
|
|
692
|
+
const owningRelationName = relation.relationName ?? toSnakeCase(getTableName(targetCollection));
|
|
693
|
+
|
|
694
|
+
// Find the inverse relation name on the target collection (if any)
|
|
695
|
+
// This is needed so the junction's target-side one() can pair with the
|
|
696
|
+
// inverse many() on the target table.
|
|
697
|
+
let inverseRelationName: string | null = null;
|
|
698
|
+
try {
|
|
699
|
+
const targetRelations = resolveCollectionRelations(targetCollection);
|
|
700
|
+
for (const [, targetRel] of Object.entries(targetRelations)) {
|
|
701
|
+
if (targetRel.direction === "inverse" &&
|
|
702
|
+
targetRel.cardinality === "many" &&
|
|
703
|
+
targetRel.inverseRelationName === owningRelationName) {
|
|
704
|
+
inverseRelationName = targetRel.relationName ?? null;
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
} catch {
|
|
709
|
+
// ignore — inverse side may not exist
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Source side one(): pairs with owning table's many(junctionTable, { relationName })
|
|
713
|
+
tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
|
|
714
|
+
|
|
715
|
+
// Target side one(): pairs with inverse table's many(junctionTable, { relationName })
|
|
716
|
+
// Always emit a relationName to avoid collisions with the source-side's owningRelationName.
|
|
717
|
+
// When no inverse relation exists on the target collection, synthesize a unique name.
|
|
718
|
+
const targetRelationName = inverseRelationName
|
|
719
|
+
? inverseRelationName
|
|
720
|
+
: `${tableName}_${relation.through.targetColumn}`;
|
|
721
|
+
tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: "${targetRelationName}"\n })`);
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
725
|
+
// Defensive safety net: track emitted `drizzleRelationName` values
|
|
726
|
+
// to prevent duplicate one()/many() entries in the generated schema.
|
|
727
|
+
// The root deduplication happens inside resolveCollectionRelations,
|
|
728
|
+
// but this guards against any future regressions in that utility.
|
|
729
|
+
const emittedRelationNames = new Set<string>();
|
|
730
|
+
for (const [relationKey, rel] of Object.entries(resolvedRelations)) {
|
|
731
|
+
try {
|
|
732
|
+
const target = rel.target();
|
|
733
|
+
const targetTableVar = getTableVarName(getTableName(target));
|
|
734
|
+
|
|
735
|
+
// Compute a deterministic shared relationName for Drizzle.
|
|
736
|
+
// Both sides of an owning/inverse pair MUST share the same
|
|
737
|
+
// relationName, otherwise Drizzle cannot pair them.
|
|
738
|
+
//
|
|
739
|
+
// Strategy: use "{ownerTable}_{foreignKey}" which is
|
|
740
|
+
// computable from either side:
|
|
741
|
+
// - owning side: {thisTable}_{localKey}
|
|
742
|
+
// - inverse side: {targetTable}_{foreignKeyOnTarget}
|
|
743
|
+
const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
|
|
744
|
+
|
|
745
|
+
// Skip if we've already emitted a relation with this drizzleRelationName
|
|
746
|
+
// for this table — prevents duplicate definitions when
|
|
747
|
+
// resolveCollectionRelations returns alias entries for the same FK.
|
|
748
|
+
const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
|
|
749
|
+
if (emittedRelationNames.has(deduplicationKey)) continue;
|
|
750
|
+
emittedRelationNames.add(deduplicationKey);
|
|
751
|
+
|
|
752
|
+
if (rel.cardinality === "one") {
|
|
753
|
+
if (rel.direction === "owning" && rel.localKey) {
|
|
754
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
755
|
+
} else if (rel.direction === "inverse") {
|
|
756
|
+
// Inverse one-to-one: the FK lives on the TARGET table, not here.
|
|
757
|
+
// Drizzle pairs inverse relations via `relationName` alone — specifying
|
|
758
|
+
// `fields`/`references` on the inverse side is invalid and causes
|
|
759
|
+
// `normalizeRelation` to crash with "Cannot read properties of
|
|
760
|
+
// undefined (reading 'referencedTable')".
|
|
761
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
762
|
+
}
|
|
763
|
+
} else if (rel.cardinality === "many") {
|
|
764
|
+
if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
|
|
765
|
+
// One-to-many inverse relation
|
|
766
|
+
tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
767
|
+
} else if (rel.through) {
|
|
768
|
+
// Many-to-many owning relation with explicit junction table
|
|
769
|
+
const junctionTableVar = getTableVarName(rel.through.table);
|
|
770
|
+
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
771
|
+
} else if (rel.direction === "inverse" && rel.inverseRelationName) {
|
|
772
|
+
// Many-to-many inverse relation - find the corresponding owning relation's junction table
|
|
773
|
+
try {
|
|
774
|
+
const targetCollection = rel.target();
|
|
775
|
+
const targetResolvedRelations = resolveCollectionRelations(targetCollection);
|
|
776
|
+
|
|
777
|
+
// Find the corresponding owning many-to-many relation on the target
|
|
778
|
+
const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
|
|
779
|
+
targetRel.direction === "owning" &&
|
|
780
|
+
targetRel.cardinality === "many" &&
|
|
781
|
+
targetRel.through &&
|
|
782
|
+
targetRel.relationName === rel.inverseRelationName
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
if (correspondingRelation && correspondingRelation.through) {
|
|
786
|
+
const junctionTableVar = getTableVarName(correspondingRelation.through.table);
|
|
787
|
+
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
788
|
+
} else {
|
|
789
|
+
logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
|
|
790
|
+
}
|
|
791
|
+
} catch (e) {
|
|
792
|
+
logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// joinPath relations don't generate Drizzle relations - they use existing user tables
|
|
796
|
+
}
|
|
797
|
+
} catch (e) {
|
|
798
|
+
logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Synthesize missing reciprocal relations
|
|
803
|
+
for (const otherCollection of collections) {
|
|
804
|
+
if (otherCollection.slug === collection.slug) continue;
|
|
805
|
+
|
|
806
|
+
const otherRelations = resolveCollectionRelations(otherCollection);
|
|
807
|
+
for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
|
|
808
|
+
if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
|
|
809
|
+
try {
|
|
810
|
+
const otherTarget = otherRel.target();
|
|
811
|
+
if (otherTarget.slug === collection.slug) {
|
|
812
|
+
const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
|
|
813
|
+
const deduplicationKey = `${drizzleRelationName}::owning`;
|
|
814
|
+
|
|
815
|
+
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
816
|
+
const otherTableVar = getTableVarName(getTableName(otherCollection));
|
|
817
|
+
// Resolve foreignKeyOnTarget to the Drizzle property key
|
|
818
|
+
// on THIS collection (the owning table). The raw FK column
|
|
819
|
+
// name (e.g. "client_id") may differ from the property key
|
|
820
|
+
// (e.g. "clientId") when `columnName` is set.
|
|
821
|
+
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
822
|
+
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
823
|
+
tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
824
|
+
emittedRelationNames.add(deduplicationKey);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
} catch (e) {
|
|
828
|
+
// ignore
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (tableRelations.length > 0) {
|
|
836
|
+
const relVarName = `${tableVarName}Relations`;
|
|
837
|
+
schemaContent += `export const ${relVarName} = drizzleRelations(${tableVarName}, ({ one, many }) => ({\n${tableRelations.join(",\n")}\n}));\n\n`;
|
|
838
|
+
if (!exportedRelationVars.includes(relVarName)) exportedRelationVars.push(relVarName);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// <<< ADDED: Final aggregated exports block
|
|
843
|
+
const tablesExport = `export const tables = { ${exportedTableVars.join(", ")} };\n`;
|
|
844
|
+
const enumsExport = `export const enums = { ${exportedEnumVars.join(", ")} };\n`;
|
|
845
|
+
const relationsExport = `export const relations = { ${exportedRelationVars.join(", ")} };\n\n`;
|
|
846
|
+
schemaContent += tablesExport + enumsExport + relationsExport;
|
|
847
|
+
|
|
848
|
+
return schemaContent;
|
|
849
|
+
};
|
|
850
|
+
|