@rebasepro/server-postgresql 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +8 -0
- package/dist/auth/services.d.ts +21 -1
- package/dist/cli-errors.d.ts +29 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
- package/dist/index.es.js +2987 -230
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-default-policies.d.ts +12 -0
- package/dist/schema/auth-schema.d.ts +227 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/services/entityService.d.ts +1 -1
- package/dist/utils/pg-error-utils.d.ts +10 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/package.json +15 -9
- package/src/PostgresBackendDriver.ts +200 -68
- package/src/PostgresBootstrapper.ts +34 -2
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +264 -245
- package/src/collections/PostgresCollectionRegistry.ts +6 -6
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-default-policies.ts +97 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +20 -82
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +487 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +10 -8
- package/src/services/entityService.ts +28 -3
- package/src/services/realtimeService.ts +26 -2
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-callbacks-redaction.test.ts +86 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/postgresDataDriver.test.ts +2 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vitest.e2e.config.ts +10 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
3
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
6
|
+
|
|
7
|
+
// --- Helper Functions ---
|
|
8
|
+
|
|
9
|
+
const resolveColumnName = (propName: string, prop?: Property | null): string => {
|
|
10
|
+
if (prop && "columnName" in prop && typeof prop.columnName === "string") {
|
|
11
|
+
return prop.columnName;
|
|
12
|
+
}
|
|
13
|
+
return toSnakeCase(propName);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number", isUuid: boolean } => {
|
|
17
|
+
if (collection.properties) {
|
|
18
|
+
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
|
|
19
|
+
if (idPropEntry) {
|
|
20
|
+
const prop = idPropEntry[1] as unknown as Property;
|
|
21
|
+
const isUuid = prop.type === "string" && "isId" in prop && (prop as unknown as StringProperty).isId === "uuid";
|
|
22
|
+
return { name: idPropEntry[0], type: prop.type === "number" ? "number" : "string", isUuid };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const idProp = collection.properties?.["id"] as unknown as Property | undefined;
|
|
26
|
+
if (idProp?.type === "number") {
|
|
27
|
+
return { name: "id", type: "number", isUuid: false };
|
|
28
|
+
}
|
|
29
|
+
const isUuid = idProp?.type === "string" && "isId" in idProp && (idProp as unknown as StringProperty).isId === "uuid";
|
|
30
|
+
return { name: "id", type: "string", isUuid: isUuid ?? false };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isNumericId = (collection: EntityCollection): boolean => {
|
|
34
|
+
return getPrimaryKeyProp(collection).type === "number";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getPrimaryKeyName = (collection: EntityCollection): string => {
|
|
38
|
+
return getPrimaryKeyProp(collection).name;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isIdProperty = (propName: string, prop: Property, collection: EntityCollection): boolean => {
|
|
42
|
+
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
43
|
+
const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
|
|
44
|
+
return !hasExplicitId && propName === "id";
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
48
|
+
const data = JSON.stringify({
|
|
49
|
+
a: rule.access,
|
|
50
|
+
m: rule.mode,
|
|
51
|
+
op: rule.operation,
|
|
52
|
+
ops: rule.operations?.slice().sort(),
|
|
53
|
+
own: rule.ownerField,
|
|
54
|
+
rol: rule.roles?.slice().sort(),
|
|
55
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
56
|
+
u: rule.using,
|
|
57
|
+
w: rule.withCheck,
|
|
58
|
+
c: rule.condition,
|
|
59
|
+
ch: rule.check
|
|
60
|
+
});
|
|
61
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const generatePolicyDdl = (collection: EntityCollection, rule: SecurityRule): string => {
|
|
65
|
+
const tableName = getTableName(collection);
|
|
66
|
+
const ops: SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
67
|
+
? rule.operations
|
|
68
|
+
: [rule.operation ?? "all"];
|
|
69
|
+
|
|
70
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
71
|
+
|
|
72
|
+
return ops.map((op, opIdx) => {
|
|
73
|
+
const policyName = rule.name
|
|
74
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
75
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
76
|
+
|
|
77
|
+
return generateSinglePolicyDdl(collection, rule, op, policyName);
|
|
78
|
+
}).join("");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const generateSinglePolicyDdl = (collection: EntityCollection, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
|
|
82
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
83
|
+
const tableName = getTableName(collection);
|
|
84
|
+
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
85
|
+
const operationUpper = operation.toUpperCase();
|
|
86
|
+
const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
87
|
+
|
|
88
|
+
const needsUsing = operation !== "insert";
|
|
89
|
+
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
90
|
+
|
|
91
|
+
// Desugar the rule (access / ownerField / roles / structured condition / raw
|
|
92
|
+
// SQL) into the shared PolicyExpression model, then compile to SQL. This is
|
|
93
|
+
// the same normalization the client-side evaluator uses, so DDL and UI agree.
|
|
94
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
95
|
+
|
|
96
|
+
let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection) : null;
|
|
97
|
+
let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection) : null;
|
|
98
|
+
|
|
99
|
+
if (!usingClause && needsUsing) {
|
|
100
|
+
usingClause = "false";
|
|
101
|
+
}
|
|
102
|
+
if (!withCheckClause && needsWithCheck) {
|
|
103
|
+
withCheckClause = "false";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let ddl = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";\n`;
|
|
107
|
+
ddl += `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
|
|
108
|
+
if (usingClause) ddl += ` USING (${usingClause})`;
|
|
109
|
+
if (withCheckClause) ddl += ` WITH CHECK (${withCheckClause})`;
|
|
110
|
+
return `${ddl};\n`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getSqlColumnType = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string => {
|
|
114
|
+
switch (prop.type) {
|
|
115
|
+
case "string": {
|
|
116
|
+
const stringProp = prop as StringProperty;
|
|
117
|
+
if (stringProp.enum) {
|
|
118
|
+
const tableName = getTableName(collection);
|
|
119
|
+
const colName = resolveColumnName(propName, prop);
|
|
120
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
121
|
+
return `"${schema}"."${tableName}_${colName}"`;
|
|
122
|
+
}
|
|
123
|
+
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
124
|
+
return "UUID";
|
|
125
|
+
}
|
|
126
|
+
if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
127
|
+
return "TEXT";
|
|
128
|
+
}
|
|
129
|
+
if (stringProp.columnType === "char") {
|
|
130
|
+
return "CHAR(255)";
|
|
131
|
+
}
|
|
132
|
+
return "VARCHAR(255)";
|
|
133
|
+
}
|
|
134
|
+
case "number": {
|
|
135
|
+
const numProp = prop as NumberProperty;
|
|
136
|
+
const isId = isIdProperty(propName, prop, collection);
|
|
137
|
+
if ("isId" in numProp && numProp.isId === "increment") {
|
|
138
|
+
return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
|
|
139
|
+
}
|
|
140
|
+
if (numProp.columnType) {
|
|
141
|
+
if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
|
|
142
|
+
return numProp.columnType.toUpperCase();
|
|
143
|
+
}
|
|
144
|
+
return (numProp.validation?.integer || isId) ? "INTEGER" : "NUMERIC";
|
|
145
|
+
}
|
|
146
|
+
case "boolean":
|
|
147
|
+
return "BOOLEAN";
|
|
148
|
+
case "date": {
|
|
149
|
+
const dateProp = prop as DateProperty;
|
|
150
|
+
if (dateProp.columnType === "date") return "DATE";
|
|
151
|
+
if (dateProp.columnType === "time") return "TIME";
|
|
152
|
+
return "TIMESTAMP WITH TIME ZONE";
|
|
153
|
+
}
|
|
154
|
+
case "map": {
|
|
155
|
+
const mapProp = prop as MapProperty;
|
|
156
|
+
return mapProp.columnType === "json" ? "JSON" : "JSONB";
|
|
157
|
+
}
|
|
158
|
+
case "array": {
|
|
159
|
+
const arrayProp = prop as ArrayProperty;
|
|
160
|
+
let colType = arrayProp.columnType;
|
|
161
|
+
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
162
|
+
const ofProp = arrayProp.of as Property;
|
|
163
|
+
if (ofProp.type === "string") {
|
|
164
|
+
colType = "text[]";
|
|
165
|
+
} else if (ofProp.type === "number") {
|
|
166
|
+
colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
167
|
+
} else if (ofProp.type === "boolean") {
|
|
168
|
+
colType = "boolean[]";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (colType === "json") return "JSON";
|
|
172
|
+
if (colType === "text[]") return "TEXT[]";
|
|
173
|
+
if (colType === "integer[]") return "INTEGER[]";
|
|
174
|
+
if (colType === "boolean[]") return "BOOLEAN[]";
|
|
175
|
+
if (colType === "numeric[]") return "NUMERIC[]";
|
|
176
|
+
return "JSONB";
|
|
177
|
+
}
|
|
178
|
+
case "vector": {
|
|
179
|
+
const vp = prop as VectorProperty;
|
|
180
|
+
return `VECTOR(${vp.dimensions})`;
|
|
181
|
+
}
|
|
182
|
+
case "binary": {
|
|
183
|
+
return "BYTEA";
|
|
184
|
+
}
|
|
185
|
+
case "relation": {
|
|
186
|
+
const refProp = prop as RelationProperty;
|
|
187
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
188
|
+
const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
189
|
+
if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
|
|
190
|
+
throw new Error(`Relation ${propName} is not an owning one-to-one/many-to-one relation`);
|
|
191
|
+
}
|
|
192
|
+
let targetCollection: EntityCollection;
|
|
193
|
+
try {
|
|
194
|
+
targetCollection = relation.target();
|
|
195
|
+
} catch {
|
|
196
|
+
return "VARCHAR(255)";
|
|
197
|
+
}
|
|
198
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
199
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
|
|
200
|
+
}
|
|
201
|
+
case "reference": {
|
|
202
|
+
const refProp = prop as ReferenceProperty;
|
|
203
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
204
|
+
if (!targetCollection) return "VARCHAR(255)";
|
|
205
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
206
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
return "VARCHAR(255)";
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const generatePostgresDdl = async (
|
|
214
|
+
collections: EntityCollection[],
|
|
215
|
+
options: { includePolicies?: boolean } = { includePolicies: true }
|
|
216
|
+
): Promise<string> => {
|
|
217
|
+
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
218
|
+
|
|
219
|
+
// 1. Create custom schemas
|
|
220
|
+
const uniqueSchemas = Array.from(new Set([
|
|
221
|
+
"auth",
|
|
222
|
+
...collections.map(c => isPostgresCollection(c) ? c.schema : undefined).filter(Boolean)
|
|
223
|
+
]));
|
|
224
|
+
uniqueSchemas.forEach(schema => {
|
|
225
|
+
if (schema) ddl += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
|
|
226
|
+
});
|
|
227
|
+
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
228
|
+
|
|
229
|
+
// 2. Generate Enums
|
|
230
|
+
collections.forEach(collection => {
|
|
231
|
+
const collectionTable = getTableName(collection);
|
|
232
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
233
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
234
|
+
if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
235
|
+
const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
|
|
236
|
+
const values = Array.isArray(prop.enum)
|
|
237
|
+
? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
|
|
238
|
+
String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
|
|
239
|
+
)
|
|
240
|
+
: Object.keys(prop.enum);
|
|
241
|
+
if (values.length > 0) {
|
|
242
|
+
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
if (ddl.endsWith(";\n")) ddl += "\n";
|
|
248
|
+
|
|
249
|
+
const allTablesToGenerate = new Map<string, {
|
|
250
|
+
collection: EntityCollection,
|
|
251
|
+
isJunction?: boolean,
|
|
252
|
+
relation?: Relation,
|
|
253
|
+
sourceCollection?: EntityCollection
|
|
254
|
+
}>();
|
|
255
|
+
|
|
256
|
+
// Identify all tables
|
|
257
|
+
for (const collection of collections) {
|
|
258
|
+
const tableName = getTableName(collection);
|
|
259
|
+
if (tableName) {
|
|
260
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
264
|
+
for (const relation of Object.values(resolvedRelations)) {
|
|
265
|
+
if (relation.through) {
|
|
266
|
+
const junctionTableName = relation.through.table;
|
|
267
|
+
if (!allTablesToGenerate.has(junctionTableName)) {
|
|
268
|
+
allTablesToGenerate.set(junctionTableName, {
|
|
269
|
+
collection: {
|
|
270
|
+
table: junctionTableName,
|
|
271
|
+
properties: {}
|
|
272
|
+
} as EntityCollection,
|
|
273
|
+
isJunction: true,
|
|
274
|
+
relation: relation,
|
|
275
|
+
sourceCollection: collection
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 3. Generate tables
|
|
283
|
+
const fkStatements: string[] = [];
|
|
284
|
+
for (const [tableName, {
|
|
285
|
+
collection,
|
|
286
|
+
isJunction,
|
|
287
|
+
relation,
|
|
288
|
+
sourceCollection
|
|
289
|
+
}] of allTablesToGenerate.entries()) {
|
|
290
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
291
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
292
|
+
|
|
293
|
+
if (isJunction && relation && sourceCollection && relation.through) {
|
|
294
|
+
const targetCollection = relation.target();
|
|
295
|
+
const sourceTable = getTableName(sourceCollection);
|
|
296
|
+
const targetTable = getTableName(targetCollection);
|
|
297
|
+
const sourceSchema = isPostgresCollection(sourceCollection) && sourceCollection.schema ? sourceCollection.schema : "public";
|
|
298
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
299
|
+
const { sourceColumn, targetColumn } = relation.through;
|
|
300
|
+
|
|
301
|
+
const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : (getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "VARCHAR(255)");
|
|
302
|
+
const targetColType = isNumericId(targetCollection) ? "INTEGER" : (getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "VARCHAR(255)");
|
|
303
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
304
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
305
|
+
|
|
306
|
+
const onDelete = relation.onDelete ?? "CASCADE";
|
|
307
|
+
|
|
308
|
+
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
309
|
+
ddl += ` "${sourceColumn}" ${sourceColType} NOT NULL,\n`;
|
|
310
|
+
ddl += ` "${targetColumn}" ${targetColType} NOT NULL,\n`;
|
|
311
|
+
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
312
|
+
ddl += `);\n\n`;
|
|
313
|
+
|
|
314
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
315
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
316
|
+
} else if (!isJunction) {
|
|
317
|
+
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
318
|
+
const columns: string[] = [];
|
|
319
|
+
|
|
320
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
321
|
+
if (prop.type === "relation") {
|
|
322
|
+
const refProp = prop as RelationProperty;
|
|
323
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
324
|
+
const relInfo = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
325
|
+
|
|
326
|
+
if (!relInfo || relInfo.direction !== "owning" || relInfo.cardinality !== "one" || !relInfo.localKey) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let targetCollection: EntityCollection;
|
|
335
|
+
try {
|
|
336
|
+
targetCollection = relInfo.target();
|
|
337
|
+
} catch {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const targetTable = getTableName(targetCollection);
|
|
342
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
343
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
344
|
+
const fkColType = getSqlColumnType(propName, prop, collection, collections);
|
|
345
|
+
|
|
346
|
+
const onUpdate = relInfo.onUpdate ? ` ON UPDATE ${relInfo.onUpdate.toUpperCase()}` : "";
|
|
347
|
+
const required = prop.validation?.required;
|
|
348
|
+
const onDeleteVal = relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL");
|
|
349
|
+
|
|
350
|
+
let colDef = ` "${relInfo.localKey}" ${fkColType}`;
|
|
351
|
+
if (required) colDef += " NOT NULL";
|
|
352
|
+
columns.push(colDef);
|
|
353
|
+
|
|
354
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${relInfo.localKey}_fkey" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
|
|
355
|
+
} else if (prop.type === "reference") {
|
|
356
|
+
const refProp = prop as ReferenceProperty;
|
|
357
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
358
|
+
const colName = resolveColumnName(propName, prop);
|
|
359
|
+
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
360
|
+
const required = prop.validation?.required;
|
|
361
|
+
|
|
362
|
+
if (!targetCollection) {
|
|
363
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
364
|
+
if (required) colDef += " NOT NULL";
|
|
365
|
+
columns.push(colDef);
|
|
366
|
+
} else {
|
|
367
|
+
const targetTable = getTableName(targetCollection);
|
|
368
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
369
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
370
|
+
const onDelete = required ? "CASCADE" : "SET NULL";
|
|
371
|
+
|
|
372
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
373
|
+
if (required) colDef += " NOT NULL";
|
|
374
|
+
columns.push(colDef);
|
|
375
|
+
|
|
376
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
const colName = resolveColumnName(propName, prop);
|
|
380
|
+
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
381
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
382
|
+
|
|
383
|
+
if (isIdProperty(propName, prop, collection)) {
|
|
384
|
+
colDef += " PRIMARY KEY";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true && prop.isId !== "increment") {
|
|
388
|
+
if (prop.isId === "uuid") {
|
|
389
|
+
colDef += " DEFAULT gen_random_uuid()";
|
|
390
|
+
} else if (prop.isId === "cuid") {
|
|
391
|
+
colDef += " DEFAULT cuid()";
|
|
392
|
+
} else if (typeof prop.isId === "string") {
|
|
393
|
+
colDef += ` DEFAULT ${prop.isId}`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (!isIdProperty(propName, prop, collection) && prop.validation?.unique) {
|
|
398
|
+
colDef += " UNIQUE";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (prop.type === "date") {
|
|
402
|
+
const dateProp = prop as DateProperty;
|
|
403
|
+
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
|
|
404
|
+
colDef += " DEFAULT now()";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (prop.validation?.required && !colDef.includes("PRIMARY KEY")) {
|
|
409
|
+
colDef += " NOT NULL";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
columns.push(colDef);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Backwards compatibility: add default id primary key if missing
|
|
417
|
+
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
418
|
+
if (!hasPk) {
|
|
419
|
+
columns.unshift(' "id" VARCHAR(255) PRIMARY KEY');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
ddl += columns.join(",\n");
|
|
423
|
+
ddl += `\n);\n\n`;
|
|
424
|
+
|
|
425
|
+
if (options.includePolicies) {
|
|
426
|
+
// Enable RLS and add Policies
|
|
427
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
428
|
+
if (collection.auth) {
|
|
429
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" FORCE ROW LEVEL SECURITY;\n`;
|
|
430
|
+
}
|
|
431
|
+
ddl += `\n`;
|
|
432
|
+
|
|
433
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
434
|
+
if (securityRules.length > 0) {
|
|
435
|
+
securityRules.forEach((rule: SecurityRule) => {
|
|
436
|
+
ddl += generatePolicyDdl(collection, rule);
|
|
437
|
+
});
|
|
438
|
+
ddl += "\n";
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (fkStatements.length > 0) {
|
|
445
|
+
ddl += "-- Foreign Key Constraints\n";
|
|
446
|
+
ddl += fkStatements.join("\n") + "\n\n";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return ddl;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export const generatePostgresPoliciesDdl = (collections: EntityCollection[]): string => {
|
|
453
|
+
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
454
|
+
|
|
455
|
+
const allTablesToGenerate = new Map<string, {
|
|
456
|
+
collection: EntityCollection
|
|
457
|
+
}>();
|
|
458
|
+
|
|
459
|
+
for (const collection of collections) {
|
|
460
|
+
const tableName = getTableName(collection);
|
|
461
|
+
if (tableName) {
|
|
462
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const [tableName, { collection }] of allTablesToGenerate.entries()) {
|
|
467
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
468
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
469
|
+
|
|
470
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
471
|
+
if (collection.auth) {
|
|
472
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" FORCE ROW LEVEL SECURITY;\n`;
|
|
473
|
+
}
|
|
474
|
+
ddl += `\n`;
|
|
475
|
+
|
|
476
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
477
|
+
if (securityRules.length > 0) {
|
|
478
|
+
securityRules.forEach((rule: SecurityRule) => {
|
|
479
|
+
ddl += generatePolicyDdl(collection, rule);
|
|
480
|
+
});
|
|
481
|
+
ddl += "\n";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return ddl;
|
|
486
|
+
};
|
|
487
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { promises as fsPromises } from "fs";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import chokidar from "chokidar";
|
|
6
|
+
import { generatePostgresDdl, generatePostgresPoliciesDdl } from "./generate-postgres-ddl-logic";
|
|
7
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
8
|
+
import { logger } from "@rebasepro/server-core";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
|
|
12
|
+
try {
|
|
13
|
+
if (!collectionsFilePath) {
|
|
14
|
+
logger.error("Error: No collections file path provided. Skipping schema generation.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const resolvedPath = path.resolve(collectionsFilePath);
|
|
19
|
+
let collections: EntityCollection[] = [];
|
|
20
|
+
const stats = fs.statSync(resolvedPath);
|
|
21
|
+
|
|
22
|
+
if (stats.isDirectory()) {
|
|
23
|
+
const files = fs.readdirSync(resolvedPath);
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
if ((file.endsWith(".ts") || file.endsWith(".js")) &&
|
|
26
|
+
!file.includes(".test.") &&
|
|
27
|
+
!file.endsWith(".d.ts") &&
|
|
28
|
+
file !== "index.ts" && file !== "index.js") {
|
|
29
|
+
|
|
30
|
+
const filePath = path.join(resolvedPath, file);
|
|
31
|
+
try {
|
|
32
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
33
|
+
const module = await import(fileUrl);
|
|
34
|
+
if (module && module.default) {
|
|
35
|
+
collections.push(module.default);
|
|
36
|
+
}
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
39
|
+
logger.error(`Error loading ${file}`, { detail: message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
|
|
45
|
+
const imported = await import(fileUrl);
|
|
46
|
+
collections = imported.backendCollections || imported.collections;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!collections || !Array.isArray(collections)) {
|
|
50
|
+
collections = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sort collections by slug alphabetically to ensure deterministic DDL generation
|
|
54
|
+
collections.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
55
|
+
|
|
56
|
+
const ddlContent = await generatePostgresDdl(collections, { includePolicies: false });
|
|
57
|
+
const policiesContent = generatePostgresPoliciesDdl(collections);
|
|
58
|
+
|
|
59
|
+
if (outputPath) {
|
|
60
|
+
const outputDir = path.dirname(outputPath);
|
|
61
|
+
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
62
|
+
await fsPromises.writeFile(outputPath, ddlContent);
|
|
63
|
+
logger.info(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
|
|
64
|
+
|
|
65
|
+
const policiesPath = path.join(outputDir, "policies.sql");
|
|
66
|
+
await fsPromises.writeFile(policiesPath, policiesContent);
|
|
67
|
+
logger.info(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
|
|
68
|
+
} else {
|
|
69
|
+
logger.info("✅ PostgreSQL DDL generated successfully.");
|
|
70
|
+
logger.info(String(ddlContent));
|
|
71
|
+
logger.info("\n✅ PostgreSQL Policies DDL generated successfully.");
|
|
72
|
+
logger.info(String(policiesContent));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
} catch (error) {
|
|
76
|
+
logger.error("Error generating DDL schema", { error: error });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const main = () => {
|
|
81
|
+
const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
|
|
82
|
+
const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
|
|
83
|
+
|
|
84
|
+
const outputPathArg = process.argv.find(arg => arg.startsWith("--output="));
|
|
85
|
+
const outputPath = outputPathArg ? outputPathArg.split("=")[1] : undefined;
|
|
86
|
+
|
|
87
|
+
const watch = process.argv.includes("--watch");
|
|
88
|
+
|
|
89
|
+
if (!collectionsFilePath) {
|
|
90
|
+
logger.info("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resolvedPath = path.resolve(process.cwd(), collectionsFilePath);
|
|
95
|
+
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
|
|
96
|
+
|
|
97
|
+
if (watch) {
|
|
98
|
+
logger.info(`Watching for changes in ${resolvedPath}...`);
|
|
99
|
+
const watcher = chokidar.watch(resolvedPath, {
|
|
100
|
+
persistent: true,
|
|
101
|
+
ignoreInitial: false
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
watcher.on("all", (event, filePath) => {
|
|
105
|
+
logger.info(`[${event}] ${filePath}. Regenerating DDL schema...`);
|
|
106
|
+
runGeneration(resolvedPath, resolvedOutputPath);
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
runGeneration(resolvedPath, resolvedOutputPath);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// This check ensures the script only runs when executed directly
|
|
114
|
+
if (import.meta.url.endsWith(process.argv[1])) {
|
|
115
|
+
main();
|
|
116
|
+
}
|
|
@@ -40,19 +40,21 @@ export class EntityPersistService {
|
|
|
40
40
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
41
41
|
const table = getTableForCollection(collection, this.registry);
|
|
42
42
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
43
|
-
const idInfo = idInfoArray[0];
|
|
44
|
-
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
|
|
45
|
-
|
|
46
|
-
if (!idField) {
|
|
47
|
-
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
48
|
-
}
|
|
49
43
|
|
|
50
44
|
const parsedIdObj = parseIdValues(entityId, idInfoArray);
|
|
51
|
-
|
|
45
|
+
|
|
46
|
+
const conditions = [];
|
|
47
|
+
for (const info of idInfoArray) {
|
|
48
|
+
const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
|
|
49
|
+
if (!field) {
|
|
50
|
+
throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
51
|
+
}
|
|
52
|
+
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
53
|
+
}
|
|
52
54
|
|
|
53
55
|
await this.db
|
|
54
56
|
.delete(table)
|
|
55
|
-
.where(
|
|
57
|
+
.where(and(...conditions));
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -180,12 +180,37 @@ export class EntityService implements EntityRepository {
|
|
|
180
180
|
/**
|
|
181
181
|
* Execute raw SQL
|
|
182
182
|
*/
|
|
183
|
-
async executeSql(sqlText: string): Promise<Record<string, unknown>[]> {
|
|
183
|
+
async executeSql(sqlText: string, params?: unknown[]): Promise<Record<string, unknown>[]> {
|
|
184
184
|
if (process.env.NODE_ENV !== "production") {
|
|
185
|
-
console.debug("Executing raw SQL:", sqlText);
|
|
185
|
+
console.debug("Executing raw SQL:", sqlText, params?.length ? `with ${params.length} params` : "");
|
|
186
186
|
}
|
|
187
187
|
const { sql } = await import("drizzle-orm");
|
|
188
|
-
|
|
188
|
+
|
|
189
|
+
let result;
|
|
190
|
+
if (params && params.length > 0) {
|
|
191
|
+
// Build a parameterized query using Drizzle's sql tagged template.
|
|
192
|
+
// Split the SQL text on $1, $2, … placeholders and interleave
|
|
193
|
+
// with sql.param() calls so the underlying pg driver binds them safely.
|
|
194
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
195
|
+
const chunks: ReturnType<typeof sql.raw | typeof sql.param>[] = [];
|
|
196
|
+
for (let i = 0; i < parts.length; i++) {
|
|
197
|
+
if (i % 2 === 0) {
|
|
198
|
+
// Literal SQL text fragment
|
|
199
|
+
if (parts[i].length > 0) {
|
|
200
|
+
chunks.push(sql.raw(parts[i]));
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
// Parameter reference — $N (1-indexed)
|
|
204
|
+
const paramIndex = Number(parts[i]) - 1;
|
|
205
|
+
chunks.push(sql.param(params[paramIndex]));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const query = sql.join(chunks, sql.raw(""));
|
|
209
|
+
result = await this.db.execute(query);
|
|
210
|
+
} else {
|
|
211
|
+
result = await this.db.execute(sql.raw(sqlText));
|
|
212
|
+
}
|
|
213
|
+
|
|
189
214
|
const rows = result.rows;
|
|
190
215
|
if (process.env.NODE_ENV !== "production") {
|
|
191
216
|
console.debug(`SQL executed successfully. Returned ${Array.isArray(rows) ? rows.length : "non-array"} rows.`);
|