@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-CD8o_1Sl.js +8999 -0
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
- package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
- package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
- package/dist/index.es.js +1231 -10031
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/FetchService.d.ts +4 -1
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-B2LsrINK.js +530 -0
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +24 -2
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +42 -6
- package/src/schema/generate-postgres-ddl-logic.ts +382 -19
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +385 -71
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +90 -14
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PostgreSQL type → Rebase property type mapping.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `introspect-db-logic` so that the structural analysis can use it
|
|
5
|
+
* without importing the generator, which imports the analysis. Re-exported from
|
|
6
|
+
* `introspect-db-logic` so existing callers keep their import path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map a PostgreSQL data type to a Rebase property type.
|
|
11
|
+
*/
|
|
12
|
+
export function mapPgType(dataType: string): string {
|
|
13
|
+
const dt = dataType.toLowerCase();
|
|
14
|
+
|
|
15
|
+
// Interval MUST be checked before numeric ("interval" contains "int")
|
|
16
|
+
if (dt === "interval") return "string";
|
|
17
|
+
|
|
18
|
+
// Array types MUST be checked before numeric ("_int4" contains "int")
|
|
19
|
+
if (dt === "array" || dt.startsWith("_")) return "array";
|
|
20
|
+
|
|
21
|
+
// Numeric types
|
|
22
|
+
if (
|
|
23
|
+
dt.includes("int") || // integer, smallint, bigint
|
|
24
|
+
dt.includes("numeric") ||
|
|
25
|
+
dt.includes("decimal") ||
|
|
26
|
+
dt.includes("serial") || // serial, bigserial
|
|
27
|
+
dt === "real" ||
|
|
28
|
+
dt === "float4" ||
|
|
29
|
+
dt === "float8" ||
|
|
30
|
+
dt === "double precision" ||
|
|
31
|
+
dt === "money"
|
|
32
|
+
) {
|
|
33
|
+
return "number";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Boolean
|
|
37
|
+
if (dt.includes("bool")) return "boolean";
|
|
38
|
+
|
|
39
|
+
// Date / Time
|
|
40
|
+
if (dt.includes("time") || dt.includes("date")) return "date";
|
|
41
|
+
|
|
42
|
+
// JSON
|
|
43
|
+
if (dt === "json" || dt === "jsonb") return "map";
|
|
44
|
+
|
|
45
|
+
// Binary
|
|
46
|
+
if (dt === "bytea") return "binary";
|
|
47
|
+
|
|
48
|
+
// Network types
|
|
49
|
+
if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
|
|
50
|
+
|
|
51
|
+
// UUID
|
|
52
|
+
if (dt === "uuid") return "string";
|
|
53
|
+
|
|
54
|
+
// Text/varchar/char — default to string
|
|
55
|
+
return "string";
|
|
56
|
+
}
|
|
@@ -7,19 +7,16 @@ import * as dotenv from "dotenv";
|
|
|
7
7
|
import readline from "readline";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
TableRow,
|
|
11
|
-
TableColumn,
|
|
12
|
-
EnumValue,
|
|
13
|
-
PrimaryKeyRow,
|
|
14
|
-
ForeignKeyRow,
|
|
15
10
|
buildTablesMap,
|
|
16
11
|
buildEnumMap,
|
|
17
|
-
identifyJoinTables,
|
|
18
12
|
generateCollectionFile,
|
|
19
13
|
generateIndexContent,
|
|
20
14
|
mergeIndexContent,
|
|
21
15
|
safeHostFromUrl
|
|
22
16
|
} from "./introspect-db-logic";
|
|
17
|
+
import { countRowsUpTo, readSchemaMetadata } from "./introspect-db-queries";
|
|
18
|
+
import { classifyTables, lookupCandidates, LOOKUP_MAX_ROWS } from "./introspect-db-structure";
|
|
19
|
+
import { parseCheckConstraints } from "./introspect-db-constraints";
|
|
23
20
|
import { logger } from "@rebasepro/server";
|
|
24
21
|
|
|
25
22
|
async function main() {
|
|
@@ -89,82 +86,41 @@ async function main() {
|
|
|
89
86
|
logger.info(chalk.gray(`Introspecting schema '${pgSchema}'...`));
|
|
90
87
|
|
|
91
88
|
try {
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
c.column_default,
|
|
111
|
-
(SELECT a.atttypmod FROM pg_attribute a
|
|
112
|
-
JOIN pg_class pc ON a.attrelid = pc.oid
|
|
113
|
-
WHERE pc.relname = c.table_name
|
|
114
|
-
AND a.attname = c.column_name
|
|
115
|
-
AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod
|
|
116
|
-
FROM information_schema.columns c
|
|
117
|
-
WHERE c.table_schema = $1
|
|
118
|
-
`, [pgSchema]);
|
|
119
|
-
|
|
120
|
-
// 2b. Get Enum Types and their values
|
|
121
|
-
const { rows: enumValues } = await client.query<EnumValue>(`
|
|
122
|
-
SELECT t.typname AS enum_name,
|
|
123
|
-
e.enumlabel AS enum_value,
|
|
124
|
-
e.enumsortorder AS sort_order
|
|
125
|
-
FROM pg_type t
|
|
126
|
-
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
127
|
-
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
128
|
-
WHERE n.nspname = $1
|
|
129
|
-
ORDER BY t.typname, e.enumsortorder
|
|
130
|
-
`, [pgSchema]);
|
|
131
|
-
|
|
132
|
-
// Build a map: enum_name -> ordered list of values
|
|
133
|
-
const enumMap = buildEnumMap(enumValues);
|
|
134
|
-
|
|
135
|
-
// 3. Get Primary Keys
|
|
136
|
-
const { rows: pks } = await client.query<PrimaryKeyRow>(`
|
|
137
|
-
SELECT t.relname as table_name, a.attname as column_name
|
|
138
|
-
FROM pg_index i
|
|
139
|
-
JOIN pg_attribute a ON a.attrelid = i.indrelid
|
|
140
|
-
AND a.attnum = ANY(i.indkey)
|
|
141
|
-
JOIN pg_class t ON t.oid = i.indrelid
|
|
142
|
-
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
143
|
-
WHERE i.indisprimary AND n.nspname = $1
|
|
144
|
-
`, [pgSchema]);
|
|
89
|
+
const metadata = await readSchemaMetadata(client, pgSchema);
|
|
90
|
+
const enumMap = buildEnumMap(metadata.enumValues);
|
|
91
|
+
const tablesMap = buildTablesMap(metadata.tables, metadata.columns, metadata.pks, metadata.fks);
|
|
92
|
+
const fks = metadata.fks;
|
|
93
|
+
|
|
94
|
+
// Only tables that could structurally be a code list are counted, and
|
|
95
|
+
// each count stops at the threshold — see `countRowsUpTo`. Introspection
|
|
96
|
+
// runs against a database it does not own, so "cheap on a table of any
|
|
97
|
+
// size" is a requirement, not an optimization.
|
|
98
|
+
for (const table of lookupCandidates(metadata, tablesMap)) {
|
|
99
|
+
try {
|
|
100
|
+
metadata.rowCounts[table] = await countRowsUpTo(client, pgSchema, table, LOOKUP_MAX_ROWS);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// A table this run cannot read is simply not classified as a code
|
|
103
|
+
// list; everything else about it still generates.
|
|
104
|
+
logger.info(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
145
107
|
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
FROM
|
|
154
|
-
information_schema.table_constraints AS tc
|
|
155
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
156
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
157
|
-
AND tc.table_schema = kcu.table_schema
|
|
158
|
-
JOIN information_schema.constraint_column_usage AS ccu
|
|
159
|
-
ON ccu.constraint_name = tc.constraint_name
|
|
160
|
-
AND ccu.table_schema = tc.table_schema
|
|
161
|
-
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1
|
|
162
|
-
`, [pgSchema]);
|
|
108
|
+
const classifications = classifyTables(metadata, tablesMap);
|
|
109
|
+
const checkFacts = parseCheckConstraints(metadata.checks);
|
|
110
|
+
const joinTables = new Set(
|
|
111
|
+
Array.from(classifications.values())
|
|
112
|
+
.filter((c) => c.role === "junction")
|
|
113
|
+
.map((c) => c.table)
|
|
114
|
+
);
|
|
163
115
|
|
|
164
|
-
const
|
|
165
|
-
|
|
116
|
+
const roleCount = (role: string) =>
|
|
117
|
+
Array.from(classifications.values()).filter((c) => c.role === role).length;
|
|
166
118
|
|
|
167
|
-
logger.info(chalk.blue(`Found ${tablesMap.size} tables
|
|
119
|
+
logger.info(chalk.blue(`Found ${tablesMap.size} tables.`));
|
|
120
|
+
logger.info(chalk.gray(
|
|
121
|
+
` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ` +
|
|
122
|
+
`${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`
|
|
123
|
+
));
|
|
168
124
|
|
|
169
125
|
let runDataInference = false;
|
|
170
126
|
if (args["--no-data-inference"]) {
|
|
@@ -224,7 +180,8 @@ async function main() {
|
|
|
224
180
|
joinTables,
|
|
225
181
|
tablesMap,
|
|
226
182
|
enumMap,
|
|
227
|
-
sampleData
|
|
183
|
+
sampleData,
|
|
184
|
+
{ metadata, classifications, checkFacts }
|
|
228
185
|
);
|
|
229
186
|
|
|
230
187
|
fs.writeFileSync(filePath, fileContent, "utf-8");
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { resolveCollectionRelations } from "@rebasepro/common";
|
|
3
|
+
import type { CollectionConfig } from "@rebasepro/types";
|
|
2
4
|
|
|
3
5
|
import { buildCollectionsFromSchema, introspectSchema, readRlsStatus, IntrospectedSchema, Queryable } from "./introspect-runtime";
|
|
4
6
|
import { buildTablesMap, identifyJoinTables, TableColumn, ForeignKeyRow, PrimaryKeyRow } from "./introspect-db-logic";
|
|
@@ -121,14 +123,60 @@ describe("buildCollectionsFromSchema", () => {
|
|
|
121
123
|
const props = posts.properties as any;
|
|
122
124
|
|
|
123
125
|
expect(props.author_id).toBeUndefined();
|
|
124
|
-
expect(props.author).
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
expect(props.author.name).toBe("Author");
|
|
127
|
+
expect(props.author.type).toBe("relation");
|
|
128
|
+
// The descriptor is nested under `relation`, carries `kind`, and its
|
|
129
|
+
// `target` is a thunk — see the resolvability test below for why none of
|
|
130
|
+
// those three are stylistic.
|
|
131
|
+
expect(props.author.relation).toMatchObject({ kind: "belongsTo", localKey: "author_id" });
|
|
132
|
+
expect(typeof props.author.relation.target).toBe("function");
|
|
133
|
+
expect(props.author.relation.target().slug).toBe("authors");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The shape has to be one the resolver actually reads.
|
|
138
|
+
*
|
|
139
|
+
* This path emitted `cardinality` / `direction` / a bare-string `target`
|
|
140
|
+
* flat on the property — the shape `Relation` stopped accepting, and the
|
|
141
|
+
* same drift `introspect-emits-valid-relations.test.ts` caught in the
|
|
142
|
+
* generated-file path and this one was missed by. Nothing threw:
|
|
143
|
+
* `resolveCollectionRelations` reads `property.relation`, found none, and
|
|
144
|
+
* reported a collection with no relations at all.
|
|
145
|
+
*
|
|
146
|
+
* The visible cost was writes. `assertKnownWriteFields` learns an owning
|
|
147
|
+
* relation's FK column from the *resolved* relation's `localKey`, and the FK
|
|
148
|
+
* column is deliberately absent from `properties` ("surfaces as a
|
|
149
|
+
* relation"), so with nothing resolving, `POST /api/data/orders` with
|
|
150
|
+
* `product_id` came back 400 `has no field 'product_id'` — the column was
|
|
151
|
+
* simultaneously the only way to set the relation and not a known field.
|
|
152
|
+
*
|
|
153
|
+
* Asserting the literal object is what let it drift, so this asserts
|
|
154
|
+
* through the resolver instead: a future reshape has to keep it resolvable,
|
|
155
|
+
* not merely keep the keys someone once wrote down.
|
|
156
|
+
*/
|
|
157
|
+
it("emits a relation the resolver can read, so the fk column is writable", () => {
|
|
158
|
+
const schema = schemaOf(
|
|
159
|
+
[
|
|
160
|
+
column({ table_name: "products", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
|
|
161
|
+
column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
|
|
162
|
+
column({ table_name: "orders", column_name: "product_id", data_type: "integer", udt_name: "int4" })
|
|
163
|
+
],
|
|
164
|
+
[
|
|
165
|
+
{ table_name: "products", column_name: "id" },
|
|
166
|
+
{ table_name: "orders", column_name: "id" }
|
|
167
|
+
],
|
|
168
|
+
[{ table_name: "orders", column_name: "product_id", foreign_table_name: "products", foreign_column_name: "id" }]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const orders = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "orders")!;
|
|
172
|
+
const resolved = resolveCollectionRelations(orders as unknown as CollectionConfig);
|
|
173
|
+
|
|
174
|
+
expect(Object.keys(resolved)).toEqual(["product"]);
|
|
175
|
+
expect(resolved.product.kind).toBe("belongsTo");
|
|
176
|
+
// `localKey` is the field `assertKnownWriteFields` adds to the known set,
|
|
177
|
+
// which is what makes `product_id` writable. The write itself is covered
|
|
178
|
+
// end-to-end by `scripts/smoke-baas.ts` against a real database.
|
|
179
|
+
expect((resolved.product as { localKey: string }).localKey).toBe("product_id");
|
|
132
180
|
});
|
|
133
181
|
|
|
134
182
|
it("skips join tables — they are an edge between collections, not a collection", () => {
|
|
@@ -207,13 +207,30 @@ function buildProperties(
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
* Owning relations, derived from this table's foreign keys
|
|
211
|
-
* `generateCollectionFile`
|
|
212
|
-
*
|
|
210
|
+
* Owning relations, derived from this table's foreign keys — the same shape
|
|
211
|
+
* `generateCollectionFile` writes into a collection file: a `relation` property
|
|
212
|
+
* whose nested descriptor carries `kind`, a `target` thunk and the `localKey`.
|
|
213
|
+
*
|
|
214
|
+
* The shape is load-bearing, not cosmetic. `resolveCollectionRelations` reads
|
|
215
|
+
* relations from `property.relation` and `resolveRelation` requires `target` to
|
|
216
|
+
* be a thunk; this used to emit `target`/`cardinality`/`localKey` flat on the
|
|
217
|
+
* property with the slug as a bare string, which satisfies neither. Nothing
|
|
218
|
+
* threw — the resolver simply skipped every such property and reported that the
|
|
219
|
+
* collection had no relations. So an introspected BaaS collection had its FK
|
|
220
|
+
* columns removed from `properties` (they "surface as relations") and then no
|
|
221
|
+
* resolvable relation to surface as, which is why writing the FK column
|
|
222
|
+
* directly came back as `has no field 'product_id'`: `assertKnownWriteFields`
|
|
223
|
+
* learns that column from the resolved relation's `localKey`.
|
|
224
|
+
*
|
|
225
|
+
* The thunk closes over the collections being built in this same pass rather
|
|
226
|
+
* than importing a module, which is what a runtime introspection has instead of
|
|
227
|
+
* generated files. It is called lazily, after the map is fully populated, so a
|
|
228
|
+
* table may reference one introspected later.
|
|
213
229
|
*/
|
|
214
230
|
function buildRelations(
|
|
215
231
|
meta: TableMeta,
|
|
216
|
-
slugByTable: Map<string, string
|
|
232
|
+
slugByTable: Map<string, string>,
|
|
233
|
+
collectionBySlug: Map<string, PostgresCollectionConfig>
|
|
217
234
|
): Record<string, Record<string, unknown>> {
|
|
218
235
|
const relations: Record<string, Record<string, unknown>> = {};
|
|
219
236
|
|
|
@@ -233,10 +250,11 @@ function buildRelations(
|
|
|
233
250
|
relations[key] = {
|
|
234
251
|
name: humanize(key),
|
|
235
252
|
type: "relation",
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
253
|
+
relation: {
|
|
254
|
+
kind: "belongsTo",
|
|
255
|
+
target: () => collectionBySlug.get(targetSlug),
|
|
256
|
+
localKey: fk.column_name
|
|
257
|
+
}
|
|
240
258
|
};
|
|
241
259
|
}
|
|
242
260
|
|
|
@@ -259,6 +277,9 @@ export function buildCollectionsFromSchema(
|
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
const collections: PostgresCollectionConfig[] = [];
|
|
280
|
+
// Filled as we go; the relation thunks read it lazily, so a table may point
|
|
281
|
+
// at one that has not been built yet at the moment its relation is created.
|
|
282
|
+
const collectionBySlug = new Map<string, PostgresCollectionConfig>();
|
|
262
283
|
|
|
263
284
|
for (const [tableName, meta] of tablesMap) {
|
|
264
285
|
if (joinTables.has(tableName)) continue;
|
|
@@ -273,11 +294,12 @@ export function buildCollectionsFromSchema(
|
|
|
273
294
|
icon: getIconForTable(tableName),
|
|
274
295
|
properties: {
|
|
275
296
|
...buildProperties(meta, enumMap),
|
|
276
|
-
...buildRelations(meta, slugByTable)
|
|
297
|
+
...buildRelations(meta, slugByTable, collectionBySlug)
|
|
277
298
|
}
|
|
278
299
|
} as unknown as PostgresCollectionConfig;
|
|
279
300
|
|
|
280
301
|
collections.push(collection);
|
|
302
|
+
collectionBySlug.set(tableName, collection);
|
|
281
303
|
}
|
|
282
304
|
|
|
283
305
|
return collections;
|
|
@@ -158,15 +158,23 @@ describe("checkPolicyDrift", () => {
|
|
|
158
158
|
it("also flags the tautology in a WITH CHECK clause", async () => {
|
|
159
159
|
const cols = [collection("posts")];
|
|
160
160
|
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
161
|
+
const withCheck = expected.filter((p) => p.hasWithCheck);
|
|
162
|
+
// Asserted rather than assumed: if the generator ever stopped emitting a
|
|
163
|
+
// WITH CHECK for these rules the fixture would carry no tautology at
|
|
164
|
+
// all, and every assertion below would be vacuously satisfied.
|
|
165
|
+
expect(withCheck.length).toBeGreaterThan(0);
|
|
166
|
+
|
|
161
167
|
const live = expected.map((p) => liveRow(p, {
|
|
162
168
|
with_check: p.hasWithCheck ? "(auth.uid() IS NOT NULL)" : null
|
|
163
169
|
}));
|
|
164
170
|
|
|
165
171
|
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
173
|
+
// Every WITH CHECK policy carries the tautology and no USING clause
|
|
174
|
+
// does, so anything flagged here came from the WITH CHECK scan — the
|
|
175
|
+
// half that a check reading only `qual` would miss entirely.
|
|
176
|
+
expect(drift.insecure.map((i) => i.policy.name)).toEqual(withCheck.map((p) => p.name));
|
|
177
|
+
expect(drift.insecure.every((i) => /WITH CHECK/.test(i.reason))).toBe(true);
|
|
170
178
|
});
|
|
171
179
|
|
|
172
180
|
it("parses roles when the driver returns the raw {a,b} text form", async () => {
|
|
@@ -64,20 +64,41 @@ function validateIdentifier(value: string, label: string): void {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* Postgres truncates identifiers at NAMEDATALEN-1 = 63 bytes, and does it
|
|
68
|
+
* silently. The prefix comes out of the same budget.
|
|
69
69
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const MAX_BRANCH_NAME_LENGTH = 63 - BRANCH_DB_PREFIX.length;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check a user-provided branch name, and otherwise leave it exactly as given.
|
|
74
|
+
*
|
|
75
|
+
* This used to strip everything outside [a-zA-Z0-9_], so `my-feature` was
|
|
76
|
+
* quietly created as `myfeature`: the name you typed was not the name `list`
|
|
77
|
+
* gave back. Nothing ever needed that. Every identifier this service builds is
|
|
78
|
+
* double-quoted (see `CREATE DATABASE` below), which is what makes a hyphen
|
|
79
|
+
* safe, and `validateIdentifier` has always accepted hyphens for the `--from`
|
|
80
|
+
* source database — the two disagreed about the same character class.
|
|
81
|
+
*
|
|
82
|
+
* Refusing a name we cannot represent is better than representing a different
|
|
83
|
+
* one, which is also why the length is checked here rather than left to
|
|
84
|
+
* Postgres, whose answer to an over-long identifier is a silent rename.
|
|
85
|
+
*/
|
|
86
|
+
function assertValidBranchName(name: string): void {
|
|
87
|
+
validateIdentifier(name, "branch name");
|
|
88
|
+
if (name.length > MAX_BRANCH_NAME_LENGTH) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Branch name "${name}" is too long: ${name.length} characters, maximum ${MAX_BRANCH_NAME_LENGTH}. ` +
|
|
91
|
+
"Postgres truncates identifiers past 63 bytes, which would give the branch a name you did not choose."
|
|
92
|
+
);
|
|
93
|
+
}
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
/**
|
|
75
97
|
* Convert a user-facing branch name to the actual PostgreSQL database name.
|
|
76
98
|
*/
|
|
77
99
|
function toBranchDbName(name: string): string {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return `${BRANCH_DB_PREFIX}${sanitized}`;
|
|
100
|
+
assertValidBranchName(name);
|
|
101
|
+
return `${BRANCH_DB_PREFIX}${name}`;
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
export class BranchService {
|
|
@@ -120,15 +141,14 @@ export class BranchService {
|
|
|
120
141
|
}
|
|
121
142
|
|
|
122
143
|
const dbName = toBranchDbName(name);
|
|
123
|
-
const sanitizedName = sanitizeBranchName(name);
|
|
124
144
|
const sourceDb = options?.source || this.poolManager.defaultDatabaseName;
|
|
125
145
|
|
|
126
146
|
// Check if branch already exists
|
|
127
147
|
const existing = await this.db.execute(
|
|
128
|
-
sql`SELECT name FROM rebase.branches WHERE name = ${
|
|
148
|
+
sql`SELECT name FROM rebase.branches WHERE name = ${name} OR db_name = ${dbName}`
|
|
129
149
|
);
|
|
130
150
|
if ((existing.rows as unknown[]).length > 0) {
|
|
131
|
-
throw new Error(`Branch "${
|
|
151
|
+
throw new Error(`Branch "${name}" already exists.`);
|
|
132
152
|
}
|
|
133
153
|
|
|
134
154
|
// Disconnect any idle pools to the source DB so TEMPLATE works.
|
|
@@ -158,12 +178,12 @@ export class BranchService {
|
|
|
158
178
|
// Record metadata in the default database
|
|
159
179
|
const now = new Date();
|
|
160
180
|
await this.db.execute(
|
|
161
|
-
sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
162
|
-
VALUES (${
|
|
181
|
+
sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
182
|
+
VALUES (${name}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
|
|
163
183
|
);
|
|
164
184
|
|
|
165
185
|
return {
|
|
166
|
-
name
|
|
186
|
+
name,
|
|
167
187
|
parentDatabase: sourceDb,
|
|
168
188
|
createdAt: now
|
|
169
189
|
};
|
|
@@ -174,20 +194,35 @@ export class BranchService {
|
|
|
174
194
|
* Cannot delete the main/default database.
|
|
175
195
|
*/
|
|
176
196
|
async deleteBranch(name: string): Promise<void> {
|
|
177
|
-
|
|
178
|
-
const dbName = toBranchDbName(name);
|
|
197
|
+
assertValidBranchName(name);
|
|
179
198
|
|
|
180
|
-
// Safety:
|
|
181
|
-
|
|
199
|
+
// Safety, first pass: a request that would target the default database
|
|
200
|
+
// is refused before any metadata is read, so the answer costs nothing
|
|
201
|
+
// and cannot depend on what a row happens to say.
|
|
202
|
+
if (toBranchDbName(name) === this.poolManager.defaultDatabaseName) {
|
|
182
203
|
throw new Error("Cannot delete the main database.");
|
|
183
204
|
}
|
|
184
205
|
|
|
185
|
-
// Verify the branch exists
|
|
206
|
+
// Verify the branch exists, and take the database name from the row
|
|
207
|
+
// rather than deriving it again. A branch created before names stopped
|
|
208
|
+
// being stripped is recorded as `rb_myfeature` while `my-feature` now
|
|
209
|
+
// derives `rb_my-feature`; re-deriving would drop a database this row
|
|
210
|
+
// never named — or, if that name happened to exist, somebody else's.
|
|
211
|
+
// The stored value is the only one that is true by construction.
|
|
186
212
|
const existing = await this.db.execute(
|
|
187
|
-
sql`SELECT db_name FROM rebase.branches WHERE name = ${
|
|
213
|
+
sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
|
|
188
214
|
);
|
|
189
|
-
|
|
190
|
-
|
|
215
|
+
const existingRows = existing.rows as Record<string, unknown>[];
|
|
216
|
+
if (existingRows.length === 0) {
|
|
217
|
+
throw new Error(`Branch "${name}" not found.`);
|
|
218
|
+
}
|
|
219
|
+
const dbName = existingRows[0].db_name as string;
|
|
220
|
+
|
|
221
|
+
// Safety, second pass: the first checked a name we derived, this checks
|
|
222
|
+
// the one we are about to drop. They differ for any row written under
|
|
223
|
+
// the old scheme, and it is this one that governs.
|
|
224
|
+
if (dbName === this.poolManager.defaultDatabaseName) {
|
|
225
|
+
throw new Error("Cannot delete the main database.");
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
// Disconnect any pools to this branch before dropping
|
|
@@ -201,7 +236,7 @@ export class BranchService {
|
|
|
201
236
|
const pgError = extractPgError(err);
|
|
202
237
|
if (pgError?.code === PG_OBJECT_IN_USE) {
|
|
203
238
|
throw new Error(
|
|
204
|
-
`Cannot delete branch "${
|
|
239
|
+
`Cannot delete branch "${name}": the database has active connections. ` +
|
|
205
240
|
"Close other clients and try again."
|
|
206
241
|
);
|
|
207
242
|
}
|
|
@@ -210,7 +245,7 @@ export class BranchService {
|
|
|
210
245
|
|
|
211
246
|
// Remove metadata
|
|
212
247
|
await this.db.execute(
|
|
213
|
-
sql`DELETE FROM rebase.branches WHERE name = ${
|
|
248
|
+
sql`DELETE FROM rebase.branches WHERE name = ${name}`
|
|
214
249
|
);
|
|
215
250
|
}
|
|
216
251
|
|
|
@@ -242,15 +277,16 @@ export class BranchService {
|
|
|
242
277
|
* Get info about a specific branch.
|
|
243
278
|
*/
|
|
244
279
|
async getBranchInfo(name: string): Promise<BranchInfo | undefined> {
|
|
245
|
-
|
|
280
|
+
assertValidBranchName(name);
|
|
246
281
|
|
|
247
282
|
const result = await this.db.execute(sql`
|
|
248
|
-
SELECT
|
|
283
|
+
SELECT
|
|
249
284
|
b.name,
|
|
285
|
+
b.db_name,
|
|
250
286
|
b.parent_db,
|
|
251
287
|
b.created_at
|
|
252
288
|
FROM rebase.branches b
|
|
253
|
-
WHERE b.name = ${
|
|
289
|
+
WHERE b.name = ${name}
|
|
254
290
|
`);
|
|
255
291
|
|
|
256
292
|
const rows = result.rows as Record<string, unknown>[];
|
|
@@ -258,10 +294,12 @@ export class BranchService {
|
|
|
258
294
|
|
|
259
295
|
const row = rows[0];
|
|
260
296
|
|
|
261
|
-
// Attempt to get size — may fail if the DB was externally dropped
|
|
297
|
+
// Attempt to get size — may fail if the DB was externally dropped.
|
|
298
|
+
// Same reason as `deleteBranch`: the size of a re-derived name is the
|
|
299
|
+
// size of some other database, or of nothing.
|
|
262
300
|
let sizeBytes: number | undefined;
|
|
263
301
|
try {
|
|
264
|
-
const dbName =
|
|
302
|
+
const dbName = row.db_name as string;
|
|
265
303
|
const sizeResult = await this.db.execute(
|
|
266
304
|
sql`SELECT pg_database_size(${dbName}) as size_bytes`
|
|
267
305
|
);
|