@rebasepro/server-postgresql 0.6.1 → 0.7.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/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- 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 +198 -251
- package/src/schema/auth-default-policies.ts +90 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +4 -3
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +524 -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/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-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/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vitest.e2e.config.ts +10 -0
- package/build-errors.txt +0 -37
- package/dist/PostgresAdapter.d.ts +0 -6
- package/dist/PostgresBackendDriver.d.ts +0 -110
- package/dist/PostgresBootstrapper.d.ts +0 -46
- package/dist/auth/ensure-tables.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -231
- package/dist/cli.d.ts +0 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
- package/dist/connection.d.ts +0 -65
- package/dist/data-transformer.d.ts +0 -55
- package/dist/databasePoolManager.d.ts +0 -20
- package/dist/history/HistoryService.d.ts +0 -71
- package/dist/history/ensure-history-table.d.ts +0 -7
- package/dist/index.d.ts +0 -14
- package/dist/index.es.js +0 -10803
- package/dist/index.es.js.map +0 -1
- package/dist/interfaces.d.ts +0 -18
- package/dist/schema/auth-schema.d.ts +0 -2149
- package/dist/schema/doctor-cli.d.ts +0 -2
- package/dist/schema/doctor.d.ts +0 -52
- package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
- package/dist/schema/generate-drizzle-schema.d.ts +0 -1
- package/dist/schema/introspect-db-inference.d.ts +0 -5
- package/dist/schema/introspect-db-logic.d.ts +0 -118
- package/dist/schema/introspect-db.d.ts +0 -1
- package/dist/schema/test-schema.d.ts +0 -24
- package/dist/services/BranchService.d.ts +0 -47
- package/dist/services/EntityFetchService.d.ts +0 -214
- package/dist/services/EntityPersistService.d.ts +0 -40
- package/dist/services/RelationService.d.ts +0 -98
- package/dist/services/entity-helpers.d.ts +0 -38
- package/dist/services/entityService.d.ts +0 -110
- package/dist/services/index.d.ts +0 -4
- package/dist/services/realtimeService.d.ts +0 -220
- package/dist/types.d.ts +0 -3
- package/dist/utils/drizzle-conditions.d.ts +0 -138
- package/dist/utils/pg-array-null-patch.d.ts +0 -16
- package/dist/utils/pg-error-utils.d.ts +0 -55
- package/dist/websocket.d.ts +0 -11
|
@@ -118,6 +118,18 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
118
118
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Magic link tokens for passwordless email login
|
|
123
|
+
*/
|
|
124
|
+
const magicLinkTokens = tableCreator("magic_link_tokens", {
|
|
125
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
126
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
127
|
+
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
128
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
129
|
+
usedAt: timestamp("used_at"),
|
|
130
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
131
|
+
});
|
|
132
|
+
|
|
121
133
|
return {
|
|
122
134
|
usersSchema,
|
|
123
135
|
users,
|
|
@@ -127,7 +139,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
127
139
|
userIdentities,
|
|
128
140
|
mfaFactors,
|
|
129
141
|
mfaChallenges,
|
|
130
|
-
recoveryCodes
|
|
142
|
+
recoveryCodes,
|
|
143
|
+
magicLinkTokens
|
|
131
144
|
};
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -144,6 +157,7 @@ export const userIdentities = defaultAuthSchema.userIdentities;
|
|
|
144
157
|
export const mfaFactors = defaultAuthSchema.mfaFactors;
|
|
145
158
|
export const mfaChallenges = defaultAuthSchema.mfaChallenges;
|
|
146
159
|
export const recoveryCodes = defaultAuthSchema.recoveryCodes;
|
|
160
|
+
export const magicLinkTokens = defaultAuthSchema.magicLinkTokens;
|
|
147
161
|
|
|
148
162
|
// Relations
|
|
149
163
|
export const usersRelations = relations(users, ({ many }) => ({
|
|
@@ -151,7 +165,8 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|
|
151
165
|
passwordResetTokens: many(passwordResetTokens),
|
|
152
166
|
userIdentities: many(userIdentities),
|
|
153
167
|
mfaFactors: many(mfaFactors),
|
|
154
|
-
recoveryCodes: many(recoveryCodes)
|
|
168
|
+
recoveryCodes: many(recoveryCodes),
|
|
169
|
+
magicLinkTokens: many(magicLinkTokens)
|
|
155
170
|
}));
|
|
156
171
|
|
|
157
172
|
export const refreshTokensRelations = relations(refreshTokens, ({ one }) => ({
|
|
@@ -197,6 +212,13 @@ export const recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({
|
|
|
197
212
|
})
|
|
198
213
|
}));
|
|
199
214
|
|
|
215
|
+
export const magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({
|
|
216
|
+
user: one(users, {
|
|
217
|
+
fields: [magicLinkTokens.userId],
|
|
218
|
+
references: [users.id]
|
|
219
|
+
})
|
|
220
|
+
}));
|
|
221
|
+
|
|
200
222
|
// Type exports
|
|
201
223
|
export type User = typeof users.$inferSelect;
|
|
202
224
|
export type NewUser = typeof users.$inferInsert;
|
|
@@ -208,3 +230,4 @@ export type NewUserIdentity = typeof userIdentities.$inferInsert;
|
|
|
208
230
|
export type MfaFactorRow = typeof mfaFactors.$inferSelect;
|
|
209
231
|
export type MfaChallengeRow = typeof mfaChallenges.$inferSelect;
|
|
210
232
|
export type RecoveryCodeRow = typeof recoveryCodes.$inferSelect;
|
|
233
|
+
export type MagicLinkToken = typeof magicLinkTokens.$inferSelect;
|
package/src/schema/doctor.ts
CHANGED
|
@@ -143,8 +143,7 @@ export async function loadCollections(collectionsPath: string): Promise<EntityCo
|
|
|
143
143
|
const filePath = path.join(resolvedPath, file);
|
|
144
144
|
try {
|
|
145
145
|
const fileUrl = pathToFileURL(filePath).href;
|
|
146
|
-
const
|
|
147
|
-
const mod = await dynamicImport(fileUrl);
|
|
146
|
+
const mod = await import(fileUrl);
|
|
148
147
|
if (mod?.default) {
|
|
149
148
|
collections.push(mod.default);
|
|
150
149
|
}
|
|
@@ -156,8 +155,7 @@ export async function loadCollections(collectionsPath: string): Promise<EntityCo
|
|
|
156
155
|
}
|
|
157
156
|
} else {
|
|
158
157
|
const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
|
|
159
|
-
const
|
|
160
|
-
const imported = await dynamicImport(fileUrl);
|
|
158
|
+
const imported = await import(fileUrl);
|
|
161
159
|
const loaded = imported.backendCollections || imported.collections;
|
|
162
160
|
if (Array.isArray(loaded)) {
|
|
163
161
|
collections.push(...loaded);
|
|
@@ -4,6 +4,7 @@ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelatio
|
|
|
4
4
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
5
|
import { createHash } from "crypto";
|
|
6
6
|
import { logger } from "@rebasepro/server-core";
|
|
7
|
+
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
7
8
|
// --- Helper Functions ---
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -98,7 +99,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
|
|
|
98
99
|
columnDefinition = `uuid("${colName}")`;
|
|
99
100
|
} else if (stringProp.columnType === "uuid") {
|
|
100
101
|
columnDefinition = `uuid("${colName}")`;
|
|
101
|
-
} else if (stringProp.columnType === "text") {
|
|
102
|
+
} else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
102
103
|
columnDefinition = `text("${colName}")`;
|
|
103
104
|
} else if (stringProp.columnType === "char") {
|
|
104
105
|
columnDefinition = `char("${colName}")`;
|
|
@@ -707,8 +708,8 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
|
|
|
707
708
|
|
|
708
709
|
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
709
710
|
|
|
710
|
-
const securityRules =
|
|
711
|
-
if (!stripPolicies && securityRules
|
|
711
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
712
|
+
if (!stripPolicies && securityRules.length > 0) {
|
|
712
713
|
schemaContent += "\n}, (table) => ([\n";
|
|
713
714
|
securityRules.forEach((rule: SecurityRule, idx: number) => {
|
|
714
715
|
schemaContent += generatePolicyCode(collection, rule, idx);
|
|
@@ -68,8 +68,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
68
68
|
const filePath = path.join(resolvedPath, file);
|
|
69
69
|
try {
|
|
70
70
|
const fileUrl = pathToFileURL(filePath).href;
|
|
71
|
-
const
|
|
72
|
-
const module = await dynamicImport(fileUrl);
|
|
71
|
+
const module = await import(fileUrl);
|
|
73
72
|
if (module && module.default) {
|
|
74
73
|
collections.push(module.default);
|
|
75
74
|
}
|
|
@@ -81,8 +80,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
81
80
|
}
|
|
82
81
|
} else {
|
|
83
82
|
const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
|
|
84
|
-
const
|
|
85
|
-
const imported = await dynamicImport(fileUrl);
|
|
83
|
+
const imported = await import(fileUrl);
|
|
86
84
|
collections = imported.backendCollections || imported.collections;
|
|
87
85
|
}
|
|
88
86
|
|
|
@@ -101,7 +99,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
101
99
|
const outputDir = path.dirname(outputPath);
|
|
102
100
|
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
103
101
|
await fsPromises.writeFile(outputPath, schemaContent);
|
|
104
|
-
logger.info(
|
|
102
|
+
logger.info(`✅ Drizzle schema generated successfully at ${outputPath}`);
|
|
105
103
|
} else {
|
|
106
104
|
logger.info("✅ Drizzle schema generated successfully.");
|
|
107
105
|
logger.info(String(schemaContent));
|
|
@@ -0,0 +1,524 @@
|
|
|
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 } 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 resolveRawSql = (expression: string): string => {
|
|
48
|
+
return expression.replace(/\{(\w+)\}/g, (_, col) => col);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const unwrapSql = (sqlExpr: string): string => {
|
|
52
|
+
return sqlExpr;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const buildUsingClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
|
|
56
|
+
if (rule.using) {
|
|
57
|
+
return resolveRawSql(rule.using);
|
|
58
|
+
}
|
|
59
|
+
if (rule.access === "public") {
|
|
60
|
+
return "true";
|
|
61
|
+
}
|
|
62
|
+
if (rule.ownerField) {
|
|
63
|
+
const prop = collection.properties?.[rule.ownerField];
|
|
64
|
+
const colName = resolveColumnName(rule.ownerField, prop);
|
|
65
|
+
return `${colName} = auth.uid()`;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const buildWithCheckClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
|
|
71
|
+
if (rule.withCheck) {
|
|
72
|
+
return resolveRawSql(rule.withCheck);
|
|
73
|
+
}
|
|
74
|
+
return buildUsingClause(rule, collection);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
78
|
+
const data = JSON.stringify({
|
|
79
|
+
a: rule.access,
|
|
80
|
+
m: rule.mode,
|
|
81
|
+
op: rule.operation,
|
|
82
|
+
ops: rule.operations?.slice().sort(),
|
|
83
|
+
own: rule.ownerField,
|
|
84
|
+
rol: rule.roles?.slice().sort(),
|
|
85
|
+
pg: rule.pgRoles?.slice().sort(),
|
|
86
|
+
u: rule.using,
|
|
87
|
+
w: rule.withCheck
|
|
88
|
+
});
|
|
89
|
+
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const generatePolicyDdl = (collection: EntityCollection, rule: SecurityRule): string => {
|
|
93
|
+
const tableName = getTableName(collection);
|
|
94
|
+
const ops: SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
95
|
+
? rule.operations
|
|
96
|
+
: [rule.operation ?? "all"];
|
|
97
|
+
|
|
98
|
+
const ruleHash = getPolicyNameHash(rule);
|
|
99
|
+
|
|
100
|
+
return ops.map((op, opIdx) => {
|
|
101
|
+
const policyName = rule.name
|
|
102
|
+
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
103
|
+
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
104
|
+
|
|
105
|
+
return generateSinglePolicyDdl(collection, rule, op, policyName);
|
|
106
|
+
}).join("");
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const generateSinglePolicyDdl = (collection: EntityCollection, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
|
|
110
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
111
|
+
const tableName = getTableName(collection);
|
|
112
|
+
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
113
|
+
const operationUpper = operation.toUpperCase();
|
|
114
|
+
const roles = rule.roles ? [...rule.roles].sort() : undefined;
|
|
115
|
+
const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
116
|
+
|
|
117
|
+
const needsUsing = operation !== "insert";
|
|
118
|
+
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
119
|
+
|
|
120
|
+
let usingClause = needsUsing ? buildUsingClause(rule, collection) : null;
|
|
121
|
+
let withCheckClause = needsWithCheck ? buildWithCheckClause(rule, collection) : null;
|
|
122
|
+
|
|
123
|
+
if (roles && roles.length > 0) {
|
|
124
|
+
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
125
|
+
const roleCondition = `string_to_array(auth.roles(), ',') && ${rolesArrayString}`;
|
|
126
|
+
if (usingClause) {
|
|
127
|
+
usingClause = `(${usingClause}) AND (${roleCondition})`;
|
|
128
|
+
} else if (needsUsing) {
|
|
129
|
+
usingClause = roleCondition;
|
|
130
|
+
}
|
|
131
|
+
if (withCheckClause) {
|
|
132
|
+
withCheckClause = `(${withCheckClause}) AND (${roleCondition})`;
|
|
133
|
+
} else if (needsWithCheck) {
|
|
134
|
+
withCheckClause = roleCondition;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!usingClause && needsUsing) {
|
|
139
|
+
usingClause = "false";
|
|
140
|
+
}
|
|
141
|
+
if (!withCheckClause && needsWithCheck) {
|
|
142
|
+
withCheckClause = "false";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let ddl = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";\n`;
|
|
146
|
+
ddl += `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
|
|
147
|
+
if (usingClause) ddl += ` USING (${usingClause})`;
|
|
148
|
+
if (withCheckClause) ddl += ` WITH CHECK (${withCheckClause})`;
|
|
149
|
+
return `${ddl};\n`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const getSqlColumnType = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string => {
|
|
153
|
+
switch (prop.type) {
|
|
154
|
+
case "string": {
|
|
155
|
+
const stringProp = prop as StringProperty;
|
|
156
|
+
if (stringProp.enum) {
|
|
157
|
+
const tableName = getTableName(collection);
|
|
158
|
+
const colName = resolveColumnName(propName, prop);
|
|
159
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
160
|
+
return `"${schema}"."${tableName}_${colName}"`;
|
|
161
|
+
}
|
|
162
|
+
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
163
|
+
return "UUID";
|
|
164
|
+
}
|
|
165
|
+
if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
|
|
166
|
+
return "TEXT";
|
|
167
|
+
}
|
|
168
|
+
if (stringProp.columnType === "char") {
|
|
169
|
+
return "CHAR(255)";
|
|
170
|
+
}
|
|
171
|
+
return "VARCHAR(255)";
|
|
172
|
+
}
|
|
173
|
+
case "number": {
|
|
174
|
+
const numProp = prop as NumberProperty;
|
|
175
|
+
const isId = isIdProperty(propName, prop, collection);
|
|
176
|
+
if ("isId" in numProp && numProp.isId === "increment") {
|
|
177
|
+
return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
|
|
178
|
+
}
|
|
179
|
+
if (numProp.columnType) {
|
|
180
|
+
if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
|
|
181
|
+
return numProp.columnType.toUpperCase();
|
|
182
|
+
}
|
|
183
|
+
return (numProp.validation?.integer || isId) ? "INTEGER" : "NUMERIC";
|
|
184
|
+
}
|
|
185
|
+
case "boolean":
|
|
186
|
+
return "BOOLEAN";
|
|
187
|
+
case "date": {
|
|
188
|
+
const dateProp = prop as DateProperty;
|
|
189
|
+
if (dateProp.columnType === "date") return "DATE";
|
|
190
|
+
if (dateProp.columnType === "time") return "TIME";
|
|
191
|
+
return "TIMESTAMP WITH TIME ZONE";
|
|
192
|
+
}
|
|
193
|
+
case "map": {
|
|
194
|
+
const mapProp = prop as MapProperty;
|
|
195
|
+
return mapProp.columnType === "json" ? "JSON" : "JSONB";
|
|
196
|
+
}
|
|
197
|
+
case "array": {
|
|
198
|
+
const arrayProp = prop as ArrayProperty;
|
|
199
|
+
let colType = arrayProp.columnType;
|
|
200
|
+
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
201
|
+
const ofProp = arrayProp.of as Property;
|
|
202
|
+
if (ofProp.type === "string") {
|
|
203
|
+
colType = "text[]";
|
|
204
|
+
} else if (ofProp.type === "number") {
|
|
205
|
+
colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
206
|
+
} else if (ofProp.type === "boolean") {
|
|
207
|
+
colType = "boolean[]";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (colType === "json") return "JSON";
|
|
211
|
+
if (colType === "text[]") return "TEXT[]";
|
|
212
|
+
if (colType === "integer[]") return "INTEGER[]";
|
|
213
|
+
if (colType === "boolean[]") return "BOOLEAN[]";
|
|
214
|
+
if (colType === "numeric[]") return "NUMERIC[]";
|
|
215
|
+
return "JSONB";
|
|
216
|
+
}
|
|
217
|
+
case "vector": {
|
|
218
|
+
const vp = prop as VectorProperty;
|
|
219
|
+
return `VECTOR(${vp.dimensions})`;
|
|
220
|
+
}
|
|
221
|
+
case "binary": {
|
|
222
|
+
return "BYTEA";
|
|
223
|
+
}
|
|
224
|
+
case "relation": {
|
|
225
|
+
const refProp = prop as RelationProperty;
|
|
226
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
227
|
+
const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
228
|
+
if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
|
|
229
|
+
throw new Error(`Relation ${propName} is not an owning one-to-one/many-to-one relation`);
|
|
230
|
+
}
|
|
231
|
+
let targetCollection: EntityCollection;
|
|
232
|
+
try {
|
|
233
|
+
targetCollection = relation.target();
|
|
234
|
+
} catch {
|
|
235
|
+
return "VARCHAR(255)";
|
|
236
|
+
}
|
|
237
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
238
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
|
|
239
|
+
}
|
|
240
|
+
case "reference": {
|
|
241
|
+
const refProp = prop as ReferenceProperty;
|
|
242
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
243
|
+
if (!targetCollection) return "VARCHAR(255)";
|
|
244
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
245
|
+
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
return "VARCHAR(255)";
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const generatePostgresDdl = async (
|
|
253
|
+
collections: EntityCollection[],
|
|
254
|
+
options: { includePolicies?: boolean } = { includePolicies: true }
|
|
255
|
+
): Promise<string> => {
|
|
256
|
+
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
257
|
+
|
|
258
|
+
// 1. Create custom schemas
|
|
259
|
+
const uniqueSchemas = Array.from(new Set([
|
|
260
|
+
"auth",
|
|
261
|
+
...collections.map(c => isPostgresCollection(c) ? c.schema : undefined).filter(Boolean)
|
|
262
|
+
]));
|
|
263
|
+
uniqueSchemas.forEach(schema => {
|
|
264
|
+
if (schema) ddl += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
|
|
265
|
+
});
|
|
266
|
+
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
267
|
+
|
|
268
|
+
// 2. Generate Enums
|
|
269
|
+
collections.forEach(collection => {
|
|
270
|
+
const collectionTable = getTableName(collection);
|
|
271
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
272
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
273
|
+
if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
274
|
+
const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
|
|
275
|
+
const values = Array.isArray(prop.enum)
|
|
276
|
+
? prop.enum.map(v => String(v.id ?? v))
|
|
277
|
+
: Object.keys(prop.enum);
|
|
278
|
+
if (values.length > 0) {
|
|
279
|
+
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
if (ddl.endsWith(";\n")) ddl += "\n";
|
|
285
|
+
|
|
286
|
+
const allTablesToGenerate = new Map<string, {
|
|
287
|
+
collection: EntityCollection,
|
|
288
|
+
isJunction?: boolean,
|
|
289
|
+
relation?: Relation,
|
|
290
|
+
sourceCollection?: EntityCollection
|
|
291
|
+
}>();
|
|
292
|
+
|
|
293
|
+
// Identify all tables
|
|
294
|
+
for (const collection of collections) {
|
|
295
|
+
const tableName = getTableName(collection);
|
|
296
|
+
if (tableName) {
|
|
297
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
301
|
+
for (const relation of Object.values(resolvedRelations)) {
|
|
302
|
+
if (relation.through) {
|
|
303
|
+
const junctionTableName = relation.through.table;
|
|
304
|
+
if (!allTablesToGenerate.has(junctionTableName)) {
|
|
305
|
+
allTablesToGenerate.set(junctionTableName, {
|
|
306
|
+
collection: {
|
|
307
|
+
table: junctionTableName,
|
|
308
|
+
properties: {}
|
|
309
|
+
} as EntityCollection,
|
|
310
|
+
isJunction: true,
|
|
311
|
+
relation: relation,
|
|
312
|
+
sourceCollection: collection
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 3. Generate tables
|
|
320
|
+
const fkStatements: string[] = [];
|
|
321
|
+
for (const [tableName, {
|
|
322
|
+
collection,
|
|
323
|
+
isJunction,
|
|
324
|
+
relation,
|
|
325
|
+
sourceCollection
|
|
326
|
+
}] of allTablesToGenerate.entries()) {
|
|
327
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
328
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
329
|
+
|
|
330
|
+
if (isJunction && relation && sourceCollection && relation.through) {
|
|
331
|
+
const targetCollection = relation.target();
|
|
332
|
+
const sourceTable = getTableName(sourceCollection);
|
|
333
|
+
const targetTable = getTableName(targetCollection);
|
|
334
|
+
const sourceSchema = isPostgresCollection(sourceCollection) && sourceCollection.schema ? sourceCollection.schema : "public";
|
|
335
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
336
|
+
const { sourceColumn, targetColumn } = relation.through;
|
|
337
|
+
|
|
338
|
+
const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : (getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "VARCHAR(255)");
|
|
339
|
+
const targetColType = isNumericId(targetCollection) ? "INTEGER" : (getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "VARCHAR(255)");
|
|
340
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
341
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
342
|
+
|
|
343
|
+
const onDelete = relation.onDelete ?? "CASCADE";
|
|
344
|
+
|
|
345
|
+
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
346
|
+
ddl += ` "${sourceColumn}" ${sourceColType} NOT NULL,\n`;
|
|
347
|
+
ddl += ` "${targetColumn}" ${targetColType} NOT NULL,\n`;
|
|
348
|
+
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
349
|
+
ddl += `);\n\n`;
|
|
350
|
+
|
|
351
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
352
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
353
|
+
} else if (!isJunction) {
|
|
354
|
+
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
355
|
+
const columns: string[] = [];
|
|
356
|
+
|
|
357
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
358
|
+
if (prop.type === "relation") {
|
|
359
|
+
const refProp = prop as RelationProperty;
|
|
360
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
361
|
+
const relInfo = findRelation(resolvedRelations, refProp.relationName ?? propName);
|
|
362
|
+
|
|
363
|
+
if (!relInfo || relInfo.direction !== "owning" || relInfo.cardinality !== "one" || !relInfo.localKey) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let targetCollection: EntityCollection;
|
|
372
|
+
try {
|
|
373
|
+
targetCollection = relInfo.target();
|
|
374
|
+
} catch {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const targetTable = getTableName(targetCollection);
|
|
379
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
380
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
381
|
+
const fkColType = getSqlColumnType(propName, prop, collection, collections);
|
|
382
|
+
|
|
383
|
+
const onUpdate = relInfo.onUpdate ? ` ON UPDATE ${relInfo.onUpdate.toUpperCase()}` : "";
|
|
384
|
+
const required = prop.validation?.required;
|
|
385
|
+
const onDeleteVal = relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL");
|
|
386
|
+
|
|
387
|
+
let colDef = ` "${relInfo.localKey}" ${fkColType}`;
|
|
388
|
+
if (required) colDef += " NOT NULL";
|
|
389
|
+
columns.push(colDef);
|
|
390
|
+
|
|
391
|
+
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};`);
|
|
392
|
+
} else if (prop.type === "reference") {
|
|
393
|
+
const refProp = prop as ReferenceProperty;
|
|
394
|
+
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
395
|
+
const colName = resolveColumnName(propName, prop);
|
|
396
|
+
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
397
|
+
const required = prop.validation?.required;
|
|
398
|
+
|
|
399
|
+
if (!targetCollection) {
|
|
400
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
401
|
+
if (required) colDef += " NOT NULL";
|
|
402
|
+
columns.push(colDef);
|
|
403
|
+
} else {
|
|
404
|
+
const targetTable = getTableName(targetCollection);
|
|
405
|
+
const targetSchema = isPostgresCollection(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
406
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
407
|
+
const onDelete = required ? "CASCADE" : "SET NULL";
|
|
408
|
+
|
|
409
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
410
|
+
if (required) colDef += " NOT NULL";
|
|
411
|
+
columns.push(colDef);
|
|
412
|
+
|
|
413
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
const colName = resolveColumnName(propName, prop);
|
|
417
|
+
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
418
|
+
let colDef = ` "${colName}" ${colType}`;
|
|
419
|
+
|
|
420
|
+
if (isIdProperty(propName, prop, collection)) {
|
|
421
|
+
colDef += " PRIMARY KEY";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true) {
|
|
425
|
+
if (prop.isId === "uuid") {
|
|
426
|
+
colDef += " DEFAULT gen_random_uuid()";
|
|
427
|
+
} else if (prop.isId === "cuid") {
|
|
428
|
+
colDef += " DEFAULT cuid()";
|
|
429
|
+
} else if (typeof prop.isId === "string") {
|
|
430
|
+
colDef += ` DEFAULT ${prop.isId}`;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!isIdProperty(propName, prop, collection) && prop.validation?.unique) {
|
|
435
|
+
colDef += " UNIQUE";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (prop.type === "date") {
|
|
439
|
+
const dateProp = prop as DateProperty;
|
|
440
|
+
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
|
|
441
|
+
colDef += " DEFAULT now()";
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (prop.validation?.required && !colDef.includes("PRIMARY KEY")) {
|
|
446
|
+
colDef += " NOT NULL";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
columns.push(colDef);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Backwards compatibility: add default id primary key if missing
|
|
454
|
+
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
455
|
+
if (!hasPk) {
|
|
456
|
+
columns.unshift(' "id" VARCHAR(255) PRIMARY KEY');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
ddl += columns.join(",\n");
|
|
460
|
+
ddl += `\n);\n\n`;
|
|
461
|
+
|
|
462
|
+
if (options.includePolicies) {
|
|
463
|
+
// Enable RLS and add Policies
|
|
464
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
465
|
+
if (collection.auth) {
|
|
466
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" FORCE ROW LEVEL SECURITY;\n`;
|
|
467
|
+
}
|
|
468
|
+
ddl += `\n`;
|
|
469
|
+
|
|
470
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
471
|
+
if (securityRules.length > 0) {
|
|
472
|
+
securityRules.forEach((rule: SecurityRule) => {
|
|
473
|
+
ddl += generatePolicyDdl(collection, rule);
|
|
474
|
+
});
|
|
475
|
+
ddl += "\n";
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (fkStatements.length > 0) {
|
|
482
|
+
ddl += "-- Foreign Key Constraints\n";
|
|
483
|
+
ddl += fkStatements.join("\n") + "\n\n";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return ddl;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
export const generatePostgresPoliciesDdl = (collections: EntityCollection[]): string => {
|
|
490
|
+
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
491
|
+
|
|
492
|
+
const allTablesToGenerate = new Map<string, {
|
|
493
|
+
collection: EntityCollection
|
|
494
|
+
}>();
|
|
495
|
+
|
|
496
|
+
for (const collection of collections) {
|
|
497
|
+
const tableName = getTableName(collection);
|
|
498
|
+
if (tableName) {
|
|
499
|
+
allTablesToGenerate.set(tableName, { collection });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
for (const [tableName, { collection }] of allTablesToGenerate.entries()) {
|
|
504
|
+
const schema = isPostgresCollection(collection) && collection.schema ? collection.schema : "public";
|
|
505
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
506
|
+
|
|
507
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
508
|
+
if (collection.auth) {
|
|
509
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" FORCE ROW LEVEL SECURITY;\n`;
|
|
510
|
+
}
|
|
511
|
+
ddl += `\n`;
|
|
512
|
+
|
|
513
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
514
|
+
if (securityRules.length > 0) {
|
|
515
|
+
securityRules.forEach((rule: SecurityRule) => {
|
|
516
|
+
ddl += generatePolicyDdl(collection, rule);
|
|
517
|
+
});
|
|
518
|
+
ddl += "\n";
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return ddl;
|
|
523
|
+
};
|
|
524
|
+
|