@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.5809e39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +25 -2
- package/dist/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/collections/buildRegistry.d.ts +27 -0
- package/dist/connection.d.ts +21 -0
- package/dist/data-transformer.d.ts +9 -2
- package/dist/index.es.js +1329 -548
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +4 -24
- package/dist/services/PersistService.d.ts +9 -1
- package/dist/services/RelationService.d.ts +34 -1
- package/dist/services/collection-helpers.d.ts +79 -14
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +7 -0
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +10 -8
- package/src/PostgresBackendDriver.ts +127 -13
- package/src/PostgresBootstrapper.ts +62 -25
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/collections/buildRegistry.ts +59 -0
- package/src/connection.ts +61 -1
- package/src/data-transformer.ts +11 -9
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/doctor.ts +45 -20
- package/src/schema/generate-drizzle-schema-logic.ts +24 -29
- package/src/schema/generate-postgres-ddl-logic.ts +76 -28
- package/src/schema/introspect-db.ts +19 -2
- package/src/services/BranchService.ts +42 -10
- package/src/services/FetchService.ts +65 -270
- package/src/services/PersistService.ts +62 -9
- package/src/services/RelationService.ts +153 -94
- package/src/services/collection-helpers.ts +164 -47
- package/src/services/dataService.ts +3 -2
- package/src/services/index.ts +1 -0
- package/src/services/realtimeService.ts +40 -19
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
- package/src/websocket.ts +4 -1
- package/dist/schema/auth-default-policies.d.ts +0 -10
- package/src/schema/auth-default-policies.ts +0 -132
package/src/schema/doctor.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type IssueSeverity = "error" | "warning" | "info";
|
|
|
37
37
|
|
|
38
38
|
export interface DoctorIssue {
|
|
39
39
|
severity: IssueSeverity;
|
|
40
|
-
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
|
|
40
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
|
|
41
41
|
table?: string;
|
|
42
42
|
column?: string;
|
|
43
43
|
expected?: string;
|
|
@@ -61,19 +61,29 @@ export function getExpectedColumnType(prop: Property): string | null {
|
|
|
61
61
|
const sp = prop as StringProperty;
|
|
62
62
|
if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
|
|
63
63
|
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
64
|
-
if (sp.columnType === "
|
|
64
|
+
if (sp.columnType === "uuid") return "uuid";
|
|
65
|
+
// A markdown/multiline string compiles to `text`, not varchar — the
|
|
66
|
+
// generator treats those UI hints as column-type signals, so the
|
|
67
|
+
// expectation here must too or every such column reads as drift.
|
|
68
|
+
if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
|
|
65
69
|
if (sp.columnType === "char") return "character";
|
|
66
70
|
return "character varying";
|
|
67
71
|
}
|
|
68
72
|
case "number": {
|
|
69
73
|
const np = prop as NumberProperty;
|
|
70
|
-
if (np.columnType
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
if (np.columnType) {
|
|
75
|
+
// The generator passes any columnType straight through to drizzle,
|
|
76
|
+
// so mirror that rather than enumerating a subset (which reported
|
|
77
|
+
// drift for anything unlisted, e.g. smallint). Serial types are
|
|
78
|
+
// integers with a sequence default; information_schema reports the
|
|
79
|
+
// underlying width.
|
|
80
|
+
const serialWidths: Record<string, string> = {
|
|
81
|
+
serial: "integer",
|
|
82
|
+
bigserial: "bigint",
|
|
83
|
+
smallserial: "smallint"
|
|
84
|
+
};
|
|
85
|
+
return serialWidths[np.columnType] ?? np.columnType;
|
|
86
|
+
}
|
|
77
87
|
if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
|
|
78
88
|
return "numeric";
|
|
79
89
|
}
|
|
@@ -201,15 +211,18 @@ export async function checkCollectionsVsSdk(
|
|
|
201
211
|
): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
|
|
202
212
|
const issues: DoctorIssue[] = [];
|
|
203
213
|
|
|
204
|
-
//
|
|
214
|
+
// The typed SDK is opt-in — nothing in a scaffolded project imports it until
|
|
215
|
+
// you choose to. A project that never generated one isn't drifting, so report
|
|
216
|
+
// it as information rather than a warning; otherwise `doctor` can never come
|
|
217
|
+
// back clean on a fresh project and users learn to ignore its output.
|
|
205
218
|
if (!fs.existsSync(sdkFilePath)) {
|
|
206
219
|
issues.push({
|
|
207
|
-
severity: "
|
|
208
|
-
category: "
|
|
209
|
-
message:
|
|
210
|
-
fix: "Run `rebase generate-sdk`"
|
|
220
|
+
severity: "info",
|
|
221
|
+
category: "sdk_not_generated",
|
|
222
|
+
message: "Typed SDK not generated (optional).",
|
|
223
|
+
fix: "Run `rebase generate-sdk` if you want typed collection access"
|
|
211
224
|
});
|
|
212
|
-
return { passed:
|
|
225
|
+
return { passed: true,
|
|
213
226
|
issues };
|
|
214
227
|
}
|
|
215
228
|
|
|
@@ -631,11 +644,15 @@ export function renderReport(report: DoctorReport): void {
|
|
|
631
644
|
}
|
|
632
645
|
|
|
633
646
|
function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
|
|
634
|
-
|
|
647
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
648
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
649
|
+
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
650
|
+
|
|
651
|
+
// Informational notes don't make a phase unhealthy, so key the header off
|
|
652
|
+
// real problems rather than `passed` alone.
|
|
653
|
+
if (errorCount === 0 && warnCount === 0) {
|
|
635
654
|
logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
636
655
|
} else {
|
|
637
|
-
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
638
|
-
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
639
656
|
const parts: string[] = [];
|
|
640
657
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
641
658
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
@@ -643,7 +660,14 @@ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): voi
|
|
|
643
660
|
}
|
|
644
661
|
logger.info("");
|
|
645
662
|
|
|
646
|
-
|
|
663
|
+
// Notes render as a quiet one-liner, not a full drift box.
|
|
664
|
+
for (const issue of infoIssues) {
|
|
665
|
+
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
666
|
+
logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
667
|
+
logger.info("");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
647
671
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
648
672
|
const categoryLabel = formatCategory(issue.category);
|
|
649
673
|
logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
@@ -674,7 +698,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
674
698
|
missing_enum: "Missing Enum",
|
|
675
699
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
676
700
|
missing_foreign_key: "Missing Foreign Key",
|
|
677
|
-
sdk_stale: "Stale SDK Types"
|
|
701
|
+
sdk_stale: "Stale SDK Types",
|
|
702
|
+
sdk_not_generated: "SDK Types Not Generated"
|
|
678
703
|
};
|
|
679
704
|
return labels[cat];
|
|
680
705
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
|
|
4
|
-
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
|
-
import { createHash } from "crypto";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
4
|
+
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
6
5
|
import { logger } from "@rebasepro/server";
|
|
7
|
-
import { getEffectiveSecurityRules } from "./auth-default-policies";
|
|
8
6
|
// --- Helper Functions ---
|
|
9
7
|
|
|
10
8
|
/**
|
|
@@ -315,22 +313,6 @@ const wrapSql = (clause: string): string => `sql\`${clause}\``;
|
|
|
315
313
|
/**
|
|
316
314
|
* Generates a deterministic hash based on the rule configuration.
|
|
317
315
|
*/
|
|
318
|
-
const getPolicyNameHash = (rule: SecurityRule): string => {
|
|
319
|
-
const data = JSON.stringify({
|
|
320
|
-
a: rule.access,
|
|
321
|
-
m: rule.mode,
|
|
322
|
-
op: rule.operation,
|
|
323
|
-
ops: rule.operations?.slice().sort(),
|
|
324
|
-
own: rule.ownerField,
|
|
325
|
-
rol: rule.roles?.slice().sort(),
|
|
326
|
-
pg: rule.pgRoles?.slice().sort(),
|
|
327
|
-
u: rule.using,
|
|
328
|
-
w: rule.withCheck,
|
|
329
|
-
c: rule.condition,
|
|
330
|
-
ch: rule.check
|
|
331
|
-
});
|
|
332
|
-
return createHash("sha1").update(data).digest("hex").substring(0, 7);
|
|
333
|
-
};
|
|
334
316
|
|
|
335
317
|
/**
|
|
336
318
|
* Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
|
|
@@ -351,15 +333,11 @@ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, in
|
|
|
351
333
|
? rule.operations
|
|
352
334
|
: [rule.operation ?? "all"];
|
|
353
335
|
|
|
354
|
-
const
|
|
336
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
355
337
|
|
|
356
338
|
// Generate one pgPolicy per operation
|
|
357
339
|
return ops.map((op, opIdx) => {
|
|
358
|
-
|
|
359
|
-
? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
|
|
360
|
-
: `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
|
|
361
|
-
|
|
362
|
-
return generateSinglePolicyCode(collection, rule, op, policyName, resolveCollection);
|
|
340
|
+
return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
363
341
|
}).join("");
|
|
364
342
|
};
|
|
365
343
|
|
|
@@ -567,6 +545,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
567
545
|
});
|
|
568
546
|
schemaContent += "\n";
|
|
569
547
|
|
|
548
|
+
// Junction policy derivation needs every declaring side of each junction,
|
|
549
|
+
// not just the first relation that reached it in the walk below.
|
|
550
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
551
|
+
|
|
570
552
|
// 2. Identify all tables (collections and junction tables only)
|
|
571
553
|
for (const collection of collections) {
|
|
572
554
|
const tableName = getTableName(collection);
|
|
@@ -623,9 +605,22 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
623
605
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
624
606
|
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
625
607
|
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
626
|
-
schemaContent += "}, (table) => (
|
|
627
|
-
schemaContent += `
|
|
628
|
-
|
|
608
|
+
schemaContent += "}, (table) => ([\n";
|
|
609
|
+
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
|
|
610
|
+
|
|
611
|
+
// Junctions are generated tables like any other: locked by default,
|
|
612
|
+
// with derived policies (reads follow the endpoints, writes follow
|
|
613
|
+
// the declaring side's update rules). RLS is enabled regardless of
|
|
614
|
+
// policy stripping — a bare junction must default-deny, not fail open.
|
|
615
|
+
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
616
|
+
if (!stripPolicies && junctionSpec) {
|
|
617
|
+
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
618
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
619
|
+
getJunctionSecurityRules(junctionSpec).forEach((rule: SecurityRule, idx: number) => {
|
|
620
|
+
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
629
624
|
} else if (!isJunction) {
|
|
630
625
|
const schema = isPostgresCollectionConfig(collection) ? collection.schema : undefined;
|
|
631
626
|
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
@@ -1,8 +1,6 @@
|
|
|
1
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";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
3
|
+
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
6
4
|
|
|
7
5
|
// --- Helper Functions ---
|
|
8
6
|
|
|
@@ -44,22 +42,6 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
|
|
|
44
42
|
return !hasExplicitId && propName === "id";
|
|
45
43
|
};
|
|
46
44
|
|
|
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
45
|
|
|
64
46
|
type ResolveCollection = (slug: string) => CollectionConfig | undefined;
|
|
65
47
|
|
|
@@ -69,14 +51,10 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
|
|
|
69
51
|
? rule.operations
|
|
70
52
|
: [rule.operation ?? "all"];
|
|
71
53
|
|
|
72
|
-
const
|
|
54
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
73
55
|
|
|
74
56
|
return ops.map((op, opIdx) => {
|
|
75
|
-
|
|
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);
|
|
57
|
+
return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
80
58
|
}).join("");
|
|
81
59
|
};
|
|
82
60
|
|
|
@@ -248,6 +226,10 @@ export const generatePostgresDdl = async (
|
|
|
248
226
|
});
|
|
249
227
|
if (ddl.endsWith(";\n")) ddl += "\n";
|
|
250
228
|
|
|
229
|
+
// Junction policy derivation needs every declaring side of each junction,
|
|
230
|
+
// not just the first relation that reached it in the walk below.
|
|
231
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
232
|
+
|
|
251
233
|
const allTablesToGenerate = new Map<string, {
|
|
252
234
|
collection: CollectionConfig,
|
|
253
235
|
isJunction?: boolean,
|
|
@@ -283,6 +265,11 @@ export const generatePostgresDdl = async (
|
|
|
283
265
|
|
|
284
266
|
// 3. Generate tables
|
|
285
267
|
const fkStatements: string[] = [];
|
|
268
|
+
// Policies are emitted after every CREATE TABLE, like the FK constraints:
|
|
269
|
+
// a policy may reference other tables (a junction's derived policies always
|
|
270
|
+
// reference both endpoints; `policy.existsIn` references a join table), and
|
|
271
|
+
// CREATE POLICY validates those relations at creation time.
|
|
272
|
+
const policyStatements: string[] = [];
|
|
286
273
|
for (const [tableName, {
|
|
287
274
|
collection,
|
|
288
275
|
isJunction,
|
|
@@ -315,6 +302,25 @@ export const generatePostgresDdl = async (
|
|
|
315
302
|
|
|
316
303
|
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
317
304
|
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
305
|
+
|
|
306
|
+
if (options.includePolicies) {
|
|
307
|
+
// Junction tables are generated tables like any other: locked by
|
|
308
|
+
// default, with derived policies — reads follow the endpoints'
|
|
309
|
+
// visibility, writes follow the declaring side's update rules.
|
|
310
|
+
// Without this they were the one kind of generated table with no
|
|
311
|
+
// RLS at all, readable and writable by every signed-in user.
|
|
312
|
+
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
313
|
+
ddl += `\n`;
|
|
314
|
+
|
|
315
|
+
const spec = junctionSpecs.get(baseTableName);
|
|
316
|
+
if (spec) {
|
|
317
|
+
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
318
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
319
|
+
getJunctionSecurityRules(spec).forEach((rule: SecurityRule) => {
|
|
320
|
+
policyStatements.push(generatePolicyDdl(junctionCollection, rule, resolveCollection));
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
318
324
|
} else if (!isJunction) {
|
|
319
325
|
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
320
326
|
const columns: string[] = [];
|
|
@@ -436,9 +442,8 @@ export const generatePostgresDdl = async (
|
|
|
436
442
|
if (securityRules.length > 0) {
|
|
437
443
|
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
438
444
|
securityRules.forEach((rule: SecurityRule) => {
|
|
439
|
-
|
|
445
|
+
policyStatements.push(generatePolicyDdl(collection, rule, resolveCollection));
|
|
440
446
|
});
|
|
441
|
-
ddl += "\n";
|
|
442
447
|
}
|
|
443
448
|
}
|
|
444
449
|
}
|
|
@@ -449,6 +454,12 @@ export const generatePostgresDdl = async (
|
|
|
449
454
|
ddl += fkStatements.join("\n") + "\n\n";
|
|
450
455
|
}
|
|
451
456
|
|
|
457
|
+
if (policyStatements.length > 0) {
|
|
458
|
+
ddl += "-- Row Level Security Policies\n";
|
|
459
|
+
ddl += policyStatements.join("");
|
|
460
|
+
ddl += "\n";
|
|
461
|
+
}
|
|
462
|
+
|
|
452
463
|
return ddl;
|
|
453
464
|
};
|
|
454
465
|
|
|
@@ -478,13 +489,50 @@ export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): st
|
|
|
478
489
|
const securityRules = getEffectiveSecurityRules(collection);
|
|
479
490
|
if (securityRules.length > 0) {
|
|
480
491
|
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
492
|
+
const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
|
|
493
|
+
|
|
481
494
|
securityRules.forEach((rule: SecurityRule) => {
|
|
495
|
+
// Say which policies the author did not write. They are permissive,
|
|
496
|
+
// so they OR with the declared rules and widen the final ACL beyond
|
|
497
|
+
// what `securityRules` reads like — and re-appear after any manual
|
|
498
|
+
// DROP, because a push asserts the declared state.
|
|
499
|
+
if (rule.name && injectedNames.has(rule.name)) {
|
|
500
|
+
ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
|
|
501
|
+
ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
|
|
502
|
+
}
|
|
482
503
|
ddl += generatePolicyDdl(collection, rule, resolveCollection);
|
|
483
504
|
});
|
|
484
505
|
ddl += "\n";
|
|
485
506
|
}
|
|
486
507
|
}
|
|
487
508
|
|
|
509
|
+
// Junction tables are generated from `through` relations, not declared as
|
|
510
|
+
// collections, so the walk above never sees them. They get the same
|
|
511
|
+
// treatment as any generated table: locked by default, with derived
|
|
512
|
+
// policies — reads follow the endpoints, writes follow the declaring
|
|
513
|
+
// side's update rules.
|
|
514
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
515
|
+
for (const spec of junctionSpecs.values()) {
|
|
516
|
+
ddl += `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
517
|
+
ddl += `\n`;
|
|
518
|
+
|
|
519
|
+
const junctionRules = getJunctionSecurityRules(spec);
|
|
520
|
+
if (junctionRules.length === 0) continue;
|
|
521
|
+
|
|
522
|
+
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
523
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
524
|
+
const declaringSlugs = spec.declaringSides.map(s => s.collection.slug).join('", "');
|
|
525
|
+
|
|
526
|
+
ddl += `-- Derived by Rebase for the junction "${spec.table}" (no collection declares it).\n`;
|
|
527
|
+
ddl += `-- Reads require both endpoint rows to be visible; writes follow the update\n`;
|
|
528
|
+
ddl += `-- rules of "${declaringSlugs}". Set \`disableDefaultPolicies: true\` on the\n`;
|
|
529
|
+
ddl += `-- declaring collection(s) to drop these and police the junction yourself.\n`;
|
|
530
|
+
junctionRules.forEach((rule: SecurityRule) => {
|
|
531
|
+
ddl += generatePolicyDdl(junctionCollection, rule, resolveCollection);
|
|
532
|
+
});
|
|
533
|
+
ddl += "\n";
|
|
534
|
+
}
|
|
535
|
+
|
|
488
536
|
return ddl;
|
|
489
537
|
};
|
|
490
538
|
|
|
@@ -30,6 +30,7 @@ async function main() {
|
|
|
30
30
|
"--force": Boolean,
|
|
31
31
|
"--schema": String,
|
|
32
32
|
"--data-inference": Boolean,
|
|
33
|
+
"--no-data-inference": Boolean,
|
|
33
34
|
"-o": "--output",
|
|
34
35
|
"-c": "--collections",
|
|
35
36
|
"-f": "--force"
|
|
@@ -166,8 +167,14 @@ async function main() {
|
|
|
166
167
|
logger.info(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
|
|
167
168
|
|
|
168
169
|
let runDataInference = false;
|
|
169
|
-
if (args["--data-inference"]
|
|
170
|
+
if (args["--no-data-inference"]) {
|
|
171
|
+
runDataInference = false;
|
|
172
|
+
} else if (args["--data-inference"] !== undefined) {
|
|
170
173
|
runDataInference = args["--data-inference"];
|
|
174
|
+
} else if (!process.stdin.isTTY) {
|
|
175
|
+
// No terminal to answer the question below (scaffolding scripts, CI,
|
|
176
|
+
// `rebase init --introspect`) — asking would hang forever.
|
|
177
|
+
logger.info(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
|
|
171
178
|
} else {
|
|
172
179
|
const rl = readline.createInterface({
|
|
173
180
|
input: process.stdin,
|
|
@@ -236,7 +243,17 @@ async function main() {
|
|
|
236
243
|
const merged = mergeIndexContent(existing, generatedFiles);
|
|
237
244
|
fs.writeFileSync(indexPath, merged, "utf-8");
|
|
238
245
|
} else {
|
|
239
|
-
|
|
246
|
+
// --force replaces collections derived from the database, but the
|
|
247
|
+
// directory can also hold hand-written ones with no table in the
|
|
248
|
+
// introspected schema (the auth users collection lives in
|
|
249
|
+
// "rebase"). The backend discovers the whole directory, so an
|
|
250
|
+
// index listing only introspected tables would silently drop them
|
|
251
|
+
// from the admin UI while the API still served them.
|
|
252
|
+
const siblings = fs.readdirSync(outDir)
|
|
253
|
+
.filter(f => f.endsWith(".ts") && f !== "index.ts")
|
|
254
|
+
.map(f => f.replace(/\.ts$/, ""));
|
|
255
|
+
const allFiles = [...new Set([...generatedFiles, ...siblings])];
|
|
256
|
+
const indexContent = generateIndexContent(allFiles);
|
|
240
257
|
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
241
258
|
}
|
|
242
259
|
logger.info(chalk.green(` ✓ ${indexPath}`));
|
|
@@ -11,10 +11,45 @@ import { sql } from "drizzle-orm";
|
|
|
11
11
|
import { BranchInfo } from "@rebasepro/types";
|
|
12
12
|
import { DrizzleClient } from "../interfaces";
|
|
13
13
|
import { DatabasePoolManager } from "../databasePoolManager";
|
|
14
|
+
import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
|
|
14
15
|
|
|
15
16
|
/** Internal prefix applied to branch database names to avoid collisions. */
|
|
16
17
|
const BRANCH_DB_PREFIX = "rb_";
|
|
17
18
|
|
|
19
|
+
/** `duplicate_database` — the target database name is already taken. */
|
|
20
|
+
const PG_DUPLICATE_DATABASE = "42P04";
|
|
21
|
+
|
|
22
|
+
/** `object_in_use` — the database still has connections attached. */
|
|
23
|
+
const PG_OBJECT_IN_USE = "55006";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Describe a failed branch DDL statement in terms a user can act on.
|
|
27
|
+
*
|
|
28
|
+
* Drizzle reports failures as `Failed query: <sql> params:` and hides the real
|
|
29
|
+
* PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
|
|
30
|
+
* the actual problem. Match on the PG error code instead — it survives wrapping
|
|
31
|
+
* and, unlike the message text, is not locale-dependent.
|
|
32
|
+
*/
|
|
33
|
+
function describeBranchDdlError(err: unknown, fallbackContext: string): Error {
|
|
34
|
+
const pgError = extractPgError(err);
|
|
35
|
+
|
|
36
|
+
if (pgError?.code === PG_DUPLICATE_DATABASE) {
|
|
37
|
+
return new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
|
|
38
|
+
}
|
|
39
|
+
if (pgError?.code === PG_OBJECT_IN_USE) {
|
|
40
|
+
return new Error(
|
|
41
|
+
`Cannot complete the operation: the database "${fallbackContext}" has active connections. ` +
|
|
42
|
+
"Close other clients or connections and try again."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Unknown failure: surface the real PG message rather than the Drizzle
|
|
47
|
+
// wrapper, which would otherwise show the raw SQL and no reason at all.
|
|
48
|
+
const detail = pgError?.message ?? extractCauseMessage(err);
|
|
49
|
+
if (detail) return new Error(detail);
|
|
50
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
51
|
+
}
|
|
52
|
+
|
|
18
53
|
/** Fully-qualified metadata table in the rebase schema. */
|
|
19
54
|
const BRANCHES_TABLE = "rebase.branches";
|
|
20
55
|
|
|
@@ -109,18 +144,15 @@ export class BranchService {
|
|
|
109
144
|
sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`)
|
|
110
145
|
);
|
|
111
146
|
} catch (err) {
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
// If template fails due to active connections, provide a helpful error
|
|
117
|
-
if (msg.includes("being accessed by other users")) {
|
|
147
|
+
const pgError = extractPgError(err);
|
|
148
|
+
if (pgError?.code === PG_OBJECT_IN_USE) {
|
|
149
|
+
// The template — not the new database — is the one still in use.
|
|
118
150
|
throw new Error(
|
|
119
151
|
`Cannot create branch: the source database "${sourceDb}" has active connections. ` +
|
|
120
152
|
"Close other clients or connections and try again."
|
|
121
153
|
);
|
|
122
154
|
}
|
|
123
|
-
throw err;
|
|
155
|
+
throw describeBranchDdlError(err, dbName);
|
|
124
156
|
}
|
|
125
157
|
|
|
126
158
|
// Record metadata in the default database
|
|
@@ -166,14 +198,14 @@ export class BranchService {
|
|
|
166
198
|
try {
|
|
167
199
|
await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
|
|
168
200
|
} catch (err) {
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
201
|
+
const pgError = extractPgError(err);
|
|
202
|
+
if (pgError?.code === PG_OBJECT_IN_USE) {
|
|
171
203
|
throw new Error(
|
|
172
204
|
`Cannot delete branch "${sanitizedName}": the database has active connections. ` +
|
|
173
205
|
"Close other clients and try again."
|
|
174
206
|
);
|
|
175
207
|
}
|
|
176
|
-
throw err;
|
|
208
|
+
throw describeBranchDdlError(err, dbName);
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
// Remove metadata
|