@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.
Files changed (55) hide show
  1. package/dist/PostgresBackendDriver.d.ts +8 -0
  2. package/dist/auth/services.d.ts +21 -1
  3. package/dist/cli-errors.d.ts +29 -0
  4. package/dist/cli-helpers.d.ts +7 -0
  5. package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
  6. package/dist/index.es.js +2987 -230
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-default-policies.d.ts +12 -0
  9. package/dist/schema/auth-schema.d.ts +227 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  11. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  12. package/dist/services/entityService.d.ts +1 -1
  13. package/dist/utils/pg-error-utils.d.ts +10 -0
  14. package/dist/utils/table-classification.d.ts +8 -0
  15. package/package.json +15 -9
  16. package/src/PostgresBackendDriver.ts +200 -68
  17. package/src/PostgresBootstrapper.ts +34 -2
  18. package/src/auth/ensure-tables.ts +56 -1
  19. package/src/auth/services.ts +94 -1
  20. package/src/cli-errors.ts +162 -0
  21. package/src/cli-helpers.ts +183 -0
  22. package/src/cli.ts +264 -245
  23. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  24. package/src/data-transformer.ts +2 -2
  25. package/src/schema/auth-default-policies.ts +97 -0
  26. package/src/schema/auth-schema.ts +25 -2
  27. package/src/schema/doctor.ts +2 -4
  28. package/src/schema/generate-drizzle-schema-logic.ts +20 -82
  29. package/src/schema/generate-drizzle-schema.ts +3 -5
  30. package/src/schema/generate-postgres-ddl-logic.ts +487 -0
  31. package/src/schema/generate-postgres-ddl.ts +116 -0
  32. package/src/services/EntityPersistService.ts +10 -8
  33. package/src/services/entityService.ts +28 -3
  34. package/src/services/realtimeService.ts +26 -2
  35. package/src/utils/pg-error-utils.ts +16 -0
  36. package/src/utils/table-classification.ts +16 -0
  37. package/src/websocket.ts +9 -0
  38. package/test/auth-default-policies.test.ts +89 -0
  39. package/test/cli-helpers-extended.test.ts +324 -0
  40. package/test/cli-helpers.test.ts +59 -0
  41. package/test/connection.test.ts +292 -0
  42. package/test/databasePoolManager.test.ts +289 -0
  43. package/test/doctor-extended.test.ts +443 -0
  44. package/test/e2e/db-e2e.test.ts +293 -0
  45. package/test/e2e/pg-setup.ts +79 -0
  46. package/test/entity-callbacks-redaction.test.ts +86 -0
  47. package/test/entity-persist-composite-keys.test.ts +451 -0
  48. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  49. package/test/generate-postgres-ddl.test.ts +300 -0
  50. package/test/mfa-service.test.ts +544 -0
  51. package/test/pg-error-utils.test.ts +50 -1
  52. package/test/postgresDataDriver.test.ts +2 -1
  53. package/test/realtimeService-channels.test.ts +696 -0
  54. package/test/unmapped-tables-safety.test.ts +55 -342
  55. 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
- const parsedId = parsedIdObj[idInfo.fieldName];
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(eq(idField, parsedId));
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
- const result = await this.db.execute(sql.raw(sqlText));
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.`);