@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,131 @@
|
|
|
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 { generateSchema } from "./generate-drizzle-schema-logic";
|
|
7
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
8
|
+
import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
// --- Helper Functions ---
|
|
12
|
+
|
|
13
|
+
const formatTerminalText = (text: string, options: {
|
|
14
|
+
bold?: boolean;
|
|
15
|
+
backgroundColor?: "blue" | "green" | "red" | "yellow" | "cyan" | "magenta";
|
|
16
|
+
textColor?: "white" | "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan";
|
|
17
|
+
} = {}): string => {
|
|
18
|
+
let codes = "";
|
|
19
|
+
if (options.bold) codes += "\x1b[1m";
|
|
20
|
+
if (options.backgroundColor) {
|
|
21
|
+
const bgColors = {
|
|
22
|
+
blue: "\x1b[44m",
|
|
23
|
+
green: "\x1b[42m",
|
|
24
|
+
red: "\x1b[41m",
|
|
25
|
+
yellow: "\x1b[43m",
|
|
26
|
+
cyan: "\x1b[46m",
|
|
27
|
+
magenta: "\x1b[45m"
|
|
28
|
+
} as const;
|
|
29
|
+
codes += bgColors[options.backgroundColor];
|
|
30
|
+
}
|
|
31
|
+
if (options.textColor) {
|
|
32
|
+
const textColors = {
|
|
33
|
+
white: "\x1b[37m",
|
|
34
|
+
black: "\x1b[30m",
|
|
35
|
+
red: "\x1b[31m",
|
|
36
|
+
green: "\x1b[32m",
|
|
37
|
+
yellow: "\x1b[33m",
|
|
38
|
+
blue: "\x1b[34m",
|
|
39
|
+
magenta: "\x1b[35m",
|
|
40
|
+
cyan: "\x1b[36m"
|
|
41
|
+
} as const;
|
|
42
|
+
codes += textColors[options.textColor];
|
|
43
|
+
}
|
|
44
|
+
return `${codes}${text}\x1b[0m`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// --- Execution and Watch Logic ---
|
|
48
|
+
|
|
49
|
+
const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
|
|
50
|
+
try {
|
|
51
|
+
if (!collectionsFilePath) {
|
|
52
|
+
logger.error("Error: No collections file path provided. Skipping schema generation.");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resolvedPath = path.resolve(collectionsFilePath);
|
|
57
|
+
|
|
58
|
+
// Shared with the runtime and the doctor: what gets generated here must
|
|
59
|
+
// be exactly what the server serves, including directory-level defaults.
|
|
60
|
+
let collections: CollectionConfig[] = await loadCollectionsFromDirectory(resolvedPath);
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// If collections directory is empty but exists, or failed to find any, we still want to inject defaults
|
|
64
|
+
if (!collections || !Array.isArray(collections)) {
|
|
65
|
+
collections = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
// Sort collections by slug alphabetically to ensure deterministic schema generation
|
|
70
|
+
collections.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
71
|
+
|
|
72
|
+
const schemaContent = await generateSchema(collections);
|
|
73
|
+
|
|
74
|
+
if (outputPath) {
|
|
75
|
+
const outputDir = path.dirname(outputPath);
|
|
76
|
+
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
77
|
+
await fsPromises.writeFile(outputPath, schemaContent);
|
|
78
|
+
logger.info(`✅ Drizzle schema generated successfully at ${outputPath}`);
|
|
79
|
+
} else {
|
|
80
|
+
logger.info("✅ Drizzle schema generated successfully.");
|
|
81
|
+
logger.info(String(schemaContent));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logger.info(`You can now run ${formatTerminalText("rebase db generate", {
|
|
85
|
+
bold: true,
|
|
86
|
+
backgroundColor: "blue",
|
|
87
|
+
textColor: "black"
|
|
88
|
+
})} to generate the SQL migration files.`);
|
|
89
|
+
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.error("Error generating schema", { error: error });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const main = () => {
|
|
96
|
+
const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
|
|
97
|
+
const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
|
|
98
|
+
|
|
99
|
+
const outputPathArg = process.argv.find(arg => arg.startsWith("--output="));
|
|
100
|
+
const outputPath = outputPathArg ? outputPathArg.split("=")[1] : undefined;
|
|
101
|
+
|
|
102
|
+
const watch = process.argv.includes("--watch");
|
|
103
|
+
|
|
104
|
+
if (!collectionsFilePath) {
|
|
105
|
+
logger.info("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const resolvedPath = path.resolve(process.cwd(), collectionsFilePath);
|
|
110
|
+
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
|
|
111
|
+
|
|
112
|
+
if (watch) {
|
|
113
|
+
logger.info(`Watching for changes in ${resolvedPath}...`);
|
|
114
|
+
const watcher = chokidar.watch(resolvedPath, {
|
|
115
|
+
persistent: true,
|
|
116
|
+
ignoreInitial: false
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
watcher.on("all", (event, filePath) => {
|
|
120
|
+
logger.info(`[${event}] ${filePath}. Regenerating schema...`);
|
|
121
|
+
runGeneration(resolvedPath, resolvedOutputPath);
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
runGeneration(resolvedPath, resolvedOutputPath);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// This check ensures the script only runs when executed directly
|
|
129
|
+
if (import.meta.url.endsWith(process.argv[1])) {
|
|
130
|
+
main();
|
|
131
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, 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: CollectionConfig): { 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: CollectionConfig): boolean => {
|
|
34
|
+
return getPrimaryKeyProp(collection).type === "number";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getPrimaryKeyName = (collection: CollectionConfig): string => {
|
|
38
|
+
return getPrimaryKeyProp(collection).name;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): 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
|
+
type ResolveCollection = (slug: string) => CollectionConfig | undefined;
|
|
65
|
+
|
|
66
|
+
const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string => {
|
|
67
|
+
const tableName = getTableName(collection);
|
|
68
|
+
const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
69
|
+
? rule.operations
|
|
70
|
+
: [rule.operation ?? "all"];
|
|
71
|
+
|
|
72
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
73
|
+
|
|
74
|
+
return ops.map((op, opIdx) => {
|
|
75
|
+
const policyName = rule.name
|
|
76
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
77
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
78
|
+
|
|
79
|
+
return generateSinglePolicyDdl(collection, rule, op, policyName, resolveCollection);
|
|
80
|
+
}).join("");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
|
|
84
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
85
|
+
const tableName = getTableName(collection);
|
|
86
|
+
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
87
|
+
const operationUpper = operation.toUpperCase();
|
|
88
|
+
const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
89
|
+
|
|
90
|
+
const needsUsing = operation !== "insert";
|
|
91
|
+
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
92
|
+
|
|
93
|
+
// Desugar the rule (access / ownerField / roles / structured condition / raw
|
|
94
|
+
// SQL) into the shared PolicyExpression model, then compile to SQL. This is
|
|
95
|
+
// the same normalization the client-side evaluator uses, so DDL and UI agree.
|
|
96
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
97
|
+
|
|
98
|
+
let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
|
|
99
|
+
let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
|
|
100
|
+
|
|
101
|
+
if (!usingClause && needsUsing) {
|
|
102
|
+
usingClause = "false";
|
|
103
|
+
}
|
|
104
|
+
if (!withCheckClause && needsWithCheck) {
|
|
105
|
+
withCheckClause = "false";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let ddl = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";\n`;
|
|
109
|
+
ddl += `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
|
|
110
|
+
if (usingClause) ddl += ` USING (${usingClause})`;
|
|
111
|
+
if (withCheckClause) ddl += ` WITH CHECK (${withCheckClause})`;
|
|
112
|
+
return `${ddl};\n`;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
116
|
+
switch (prop.type) {
|
|
117
|
+
case "string": {
|
|
118
|
+
const stringProp = prop as StringProperty;
|
|
119
|
+
if (stringProp.enum) {
|
|
120
|
+
const tableName = getTableName(collection);
|
|
121
|
+
const colName = resolveColumnName(propName, prop);
|
|
122
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
123
|
+
return `"${schema}"."${tableName}_${colName}"`;
|
|
124
|
+
}
|
|
125
|
+
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
126
|
+
return "UUID";
|
|
127
|
+
}
|
|
128
|
+
if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
129
|
+
return "TEXT";
|
|
130
|
+
}
|
|
131
|
+
if (stringProp.columnType === "char") {
|
|
132
|
+
return "CHAR(255)";
|
|
133
|
+
}
|
|
134
|
+
return "VARCHAR(255)";
|
|
135
|
+
}
|
|
136
|
+
case "number": {
|
|
137
|
+
const numProp = prop as NumberProperty;
|
|
138
|
+
const isId = isIdProperty(propName, prop, collection);
|
|
139
|
+
if ("isId" in numProp && numProp.isId === "increment") {
|
|
140
|
+
return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
|
|
141
|
+
}
|
|
142
|
+
if (numProp.columnType) {
|
|
143
|
+
if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
|
|
144
|
+
return numProp.columnType.toUpperCase();
|
|
145
|
+
}
|
|
146
|
+
return (numProp.validation?.integer || isId) ? "INTEGER" : "NUMERIC";
|
|
147
|
+
}
|
|
148
|
+
case "boolean":
|
|
149
|
+
return "BOOLEAN";
|
|
150
|
+
case "date": {
|
|
151
|
+
const dateProp = prop as DateProperty;
|
|
152
|
+
if (dateProp.columnType === "date") return "DATE";
|
|
153
|
+
if (dateProp.columnType === "time") return "TIME";
|
|
154
|
+
return "TIMESTAMP WITH TIME ZONE";
|
|
155
|
+
}
|
|
156
|
+
case "map": {
|
|
157
|
+
const mapProp = prop as MapProperty;
|
|
158
|
+
return mapProp.columnType === "json" ? "JSON" : "JSONB";
|
|
159
|
+
}
|
|
160
|
+
case "array": {
|
|
161
|
+
const arrayProp = prop as ArrayProperty;
|
|
162
|
+
let colType = arrayProp.columnType;
|
|
163
|
+
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
164
|
+
const ofProp = arrayProp.of as Property;
|
|
165
|
+
if (ofProp.type === "string") {
|
|
166
|
+
colType = "text[]";
|
|
167
|
+
} else if (ofProp.type === "number") {
|
|
168
|
+
colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
169
|
+
} else if (ofProp.type === "boolean") {
|
|
170
|
+
colType = "boolean[]";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (colType === "json") return "JSON";
|
|
174
|
+
if (colType === "text[]") return "TEXT[]";
|
|
175
|
+
if (colType === "integer[]") return "INTEGER[]";
|
|
176
|
+
if (colType === "boolean[]") return "BOOLEAN[]";
|
|
177
|
+
if (colType === "numeric[]") return "NUMERIC[]";
|
|
178
|
+
return "JSONB";
|
|
179
|
+
}
|
|
180
|
+
case "vector": {
|
|
181
|
+
const vp = prop as VectorProperty;
|
|
182
|
+
return `VECTOR(${vp.dimensions})`;
|
|
183
|
+
}
|
|
184
|
+
case "binary": {
|
|
185
|
+
return "BYTEA";
|
|
186
|
+
}
|
|
187
|
+
case "relation": {
|
|
188
|
+
const refProp = prop as RelationProperty;
|
|
189
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
190
|
+
const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
191
|
+
if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
|
|
192
|
+
throw new Error(`Relation ${propName} is not an owning one-to-one/many-to-one relation`);
|
|
193
|
+
}
|
|
194
|
+
let targetCollection: CollectionConfig;
|
|
195
|
+
try {
|
|
196
|
+
targetCollection = relation.target();
|
|
197
|
+
} catch {
|
|
198
|
+
return "VARCHAR(255)";
|
|
199
|
+
}
|
|
200
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
201
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
|
|
202
|
+
}
|
|
203
|
+
case "reference": {
|
|
204
|
+
const refProp = prop as ReferenceProperty;
|
|
205
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
206
|
+
if (!targetCollection) return "VARCHAR(255)";
|
|
207
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
208
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
|
|
209
|
+
}
|
|
210
|
+
default:
|
|
211
|
+
return "VARCHAR(255)";
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const generatePostgresDdl = async (
|
|
216
|
+
collections: CollectionConfig[],
|
|
217
|
+
options: { includePolicies?: boolean } = { includePolicies: true }
|
|
218
|
+
): Promise<string> => {
|
|
219
|
+
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
220
|
+
|
|
221
|
+
// 1. Create custom schemas
|
|
222
|
+
const uniqueSchemas = Array.from(new Set([
|
|
223
|
+
"auth",
|
|
224
|
+
...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
|
|
225
|
+
]));
|
|
226
|
+
uniqueSchemas.forEach(schema => {
|
|
227
|
+
if (schema) ddl += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
|
|
228
|
+
});
|
|
229
|
+
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
230
|
+
|
|
231
|
+
// 2. Generate Enums
|
|
232
|
+
collections.forEach(collection => {
|
|
233
|
+
const collectionTable = getTableName(collection);
|
|
234
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
235
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
236
|
+
if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
237
|
+
const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
|
|
238
|
+
const values = Array.isArray(prop.enum)
|
|
239
|
+
? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
|
|
240
|
+
String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
|
|
241
|
+
)
|
|
242
|
+
: Object.keys(prop.enum);
|
|
243
|
+
if (values.length > 0) {
|
|
244
|
+
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
if (ddl.endsWith(";\n")) ddl += "\n";
|
|
250
|
+
|
|
251
|
+
const allTablesToGenerate = new Map<string, {
|
|
252
|
+
collection: CollectionConfig,
|
|
253
|
+
isJunction?: boolean,
|
|
254
|
+
relation?: Relation,
|
|
255
|
+
sourceCollection?: CollectionConfig
|
|
256
|
+
}>();
|
|
257
|
+
|
|
258
|
+
// Identify all tables
|
|
259
|
+
for (const collection of collections) {
|
|
260
|
+
const tableName = getTableName(collection);
|
|
261
|
+
if (tableName) {
|
|
262
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
266
|
+
for (const relation of Object.values(resolvedRelations)) {
|
|
267
|
+
if (relation.through) {
|
|
268
|
+
const junctionTableName = relation.through.table;
|
|
269
|
+
if (!allTablesToGenerate.has(junctionTableName)) {
|
|
270
|
+
allTablesToGenerate.set(junctionTableName, {
|
|
271
|
+
collection: {
|
|
272
|
+
table: junctionTableName,
|
|
273
|
+
properties: {}
|
|
274
|
+
} as CollectionConfig,
|
|
275
|
+
isJunction: true,
|
|
276
|
+
relation: relation,
|
|
277
|
+
sourceCollection: collection
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3. Generate tables
|
|
285
|
+
const fkStatements: string[] = [];
|
|
286
|
+
for (const [tableName, {
|
|
287
|
+
collection,
|
|
288
|
+
isJunction,
|
|
289
|
+
relation,
|
|
290
|
+
sourceCollection
|
|
291
|
+
}] of allTablesToGenerate.entries()) {
|
|
292
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
293
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
294
|
+
|
|
295
|
+
if (isJunction && relation && sourceCollection && relation.through) {
|
|
296
|
+
const targetCollection = relation.target();
|
|
297
|
+
const sourceTable = getTableName(sourceCollection);
|
|
298
|
+
const targetTable = getTableName(targetCollection);
|
|
299
|
+
const sourceSchema = isPostgresCollectionConfig(sourceCollection) && sourceCollection.schema ? sourceCollection.schema : "public";
|
|
300
|
+
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
301
|
+
const { sourceColumn, targetColumn } = relation.through;
|
|
302
|
+
|
|
303
|
+
const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : (getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "VARCHAR(255)");
|
|
304
|
+
const targetColType = isNumericId(targetCollection) ? "INTEGER" : (getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "VARCHAR(255)");
|
|
305
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
306
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
307
|
+
|
|
308
|
+
const onDelete = relation.onDelete ?? "CASCADE";
|
|
309
|
+
|
|
310
|
+
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
311
|
+
ddl += ` "${sourceColumn}" ${sourceColType} NOT NULL,\n`;
|
|
312
|
+
ddl += ` "${targetColumn}" ${targetColType} NOT NULL,\n`;
|
|
313
|
+
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
314
|
+
ddl += `);\n\n`;
|
|
315
|
+
|
|
316
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
317
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
318
|
+
} else if (!isJunction) {
|
|
319
|
+
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
320
|
+
const columns: string[] = [];
|
|
321
|
+
|
|
322
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
323
|
+
if (prop.type === "relation") {
|
|
324
|
+
const refProp = prop as RelationProperty;
|
|
325
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
326
|
+
const relInfo = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
327
|
+
|
|
328
|
+
if (!relInfo || relInfo.direction !== "owning" || relInfo.cardinality !== "one" || !relInfo.localKey) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let targetCollection: CollectionConfig;
|
|
337
|
+
try {
|
|
338
|
+
targetCollection = relInfo.target();
|
|
339
|
+
} catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const targetTable = getTableName(targetCollection);
|
|
344
|
+
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
345
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
346
|
+
const fkColType = getSqlColumnType(propName, prop, collection, collections);
|
|
347
|
+
|
|
348
|
+
const onUpdate = relInfo.onUpdate ? ` ON UPDATE ${relInfo.onUpdate.toUpperCase()}` : "";
|
|
349
|
+
const required = prop.validation?.required;
|
|
350
|
+
const onDeleteVal = relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL");
|
|
351
|
+
|
|
352
|
+
let colDef = ` "${relInfo.localKey}" ${fkColType}`;
|
|
353
|
+
if (required) colDef += " NOT NULL";
|
|
354
|
+
columns.push(colDef);
|
|
355
|
+
|
|
356
|
+
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};`);
|
|
357
|
+
} else if (prop.type === "reference") {
|
|
358
|
+
const refProp = prop as ReferenceProperty;
|
|
359
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
360
|
+
const colName = resolveColumnName(propName, prop);
|
|
361
|
+
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
362
|
+
const required = prop.validation?.required;
|
|
363
|
+
|
|
364
|
+
if (!targetCollection) {
|
|
365
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
366
|
+
if (required) colDef += " NOT NULL";
|
|
367
|
+
columns.push(colDef);
|
|
368
|
+
} else {
|
|
369
|
+
const targetTable = getTableName(targetCollection);
|
|
370
|
+
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
371
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
372
|
+
const onDelete = required ? "CASCADE" : "SET NULL";
|
|
373
|
+
|
|
374
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
375
|
+
if (required) colDef += " NOT NULL";
|
|
376
|
+
columns.push(colDef);
|
|
377
|
+
|
|
378
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
const colName = resolveColumnName(propName, prop);
|
|
382
|
+
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
383
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
384
|
+
|
|
385
|
+
if (isIdProperty(propName, prop, collection)) {
|
|
386
|
+
colDef += " PRIMARY KEY";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true && prop.isId !== "increment") {
|
|
390
|
+
if (prop.isId === "uuid") {
|
|
391
|
+
colDef += " DEFAULT gen_random_uuid()";
|
|
392
|
+
} else if (prop.isId === "cuid") {
|
|
393
|
+
colDef += " DEFAULT cuid()";
|
|
394
|
+
} else if (typeof prop.isId === "string") {
|
|
395
|
+
colDef += ` DEFAULT ${prop.isId}`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!isIdProperty(propName, prop, collection) && prop.validation?.unique) {
|
|
400
|
+
colDef += " UNIQUE";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (prop.type === "date") {
|
|
404
|
+
const dateProp = prop as DateProperty;
|
|
405
|
+
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
|
|
406
|
+
colDef += " DEFAULT now()";
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (prop.validation?.required && !colDef.includes("PRIMARY KEY")) {
|
|
411
|
+
colDef += " NOT NULL";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
columns.push(colDef);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Backwards compatibility: add default id primary key if missing
|
|
419
|
+
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
420
|
+
if (!hasPk) {
|
|
421
|
+
columns.unshift(' "id" VARCHAR(255) PRIMARY KEY');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
ddl += columns.join(",\n");
|
|
425
|
+
ddl += `\n);\n\n`;
|
|
426
|
+
|
|
427
|
+
if (options.includePolicies) {
|
|
428
|
+
// Enable RLS and add Policies. No FORCE: authenticated requests
|
|
429
|
+
// run as the non-owner `rebase_user` role, which plain ENABLE
|
|
430
|
+
// already binds. The owner (server context) must bypass — it is
|
|
431
|
+
// the trusted plane (auth flows, dataAsAdmin).
|
|
432
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
433
|
+
ddl += `\n`;
|
|
434
|
+
|
|
435
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
436
|
+
if (securityRules.length > 0) {
|
|
437
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
438
|
+
securityRules.forEach((rule: SecurityRule) => {
|
|
439
|
+
ddl += generatePolicyDdl(collection, rule, resolveCollection);
|
|
440
|
+
});
|
|
441
|
+
ddl += "\n";
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (fkStatements.length > 0) {
|
|
448
|
+
ddl += "-- Foreign Key Constraints\n";
|
|
449
|
+
ddl += fkStatements.join("\n") + "\n\n";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return ddl;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
|
|
456
|
+
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
457
|
+
|
|
458
|
+
const allTablesToGenerate = new Map<string, {
|
|
459
|
+
collection: CollectionConfig
|
|
460
|
+
}>();
|
|
461
|
+
|
|
462
|
+
for (const collection of collections) {
|
|
463
|
+
const tableName = getTableName(collection);
|
|
464
|
+
if (tableName) {
|
|
465
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const [tableName, { collection }] of allTablesToGenerate.entries()) {
|
|
470
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
471
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
472
|
+
|
|
473
|
+
// No FORCE: user requests run as the non-owner `rebase_user` role
|
|
474
|
+
// (plain ENABLE binds them); the owner is the trusted server context.
|
|
475
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
476
|
+
ddl += `\n`;
|
|
477
|
+
|
|
478
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
479
|
+
if (securityRules.length > 0) {
|
|
480
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
481
|
+
securityRules.forEach((rule: SecurityRule) => {
|
|
482
|
+
ddl += generatePolicyDdl(collection, rule, resolveCollection);
|
|
483
|
+
});
|
|
484
|
+
ddl += "\n";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return ddl;
|
|
489
|
+
};
|
|
490
|
+
|