@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g009ed95
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-BrUVgjz3.js +57 -0
- package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
- package/dist/index.es.js +816 -9679
- 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 +24 -1
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-CzbghKwf.js} +271 -173
- package/dist/src-CzbghKwf.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 +19 -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 +23 -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/FetchService.ts +76 -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 +40 -1
- 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 +46 -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 () => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
1
|
+
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
|
|
4
4
|
import type { VectorSearchParams } from "@rebasepro/types";
|
|
5
5
|
import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
|
|
6
6
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
7
|
-
import { DrizzleConditionBuilder, type FilterCompilationOptions } from "../utils/drizzle-conditions";
|
|
7
|
+
import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
|
|
8
8
|
import {
|
|
9
9
|
getCollectionByPath,
|
|
10
10
|
getTableForCollection,
|
|
@@ -20,9 +20,9 @@ import { RelationService } from "./RelationService";
|
|
|
20
20
|
import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
|
|
21
21
|
import { DrizzleClient } from "../interfaces";
|
|
22
22
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
23
|
-
import {
|
|
23
|
+
import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
24
24
|
import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
|
|
25
|
-
import { logger } from "@rebasepro/server";
|
|
25
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
26
26
|
|
|
27
27
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
28
28
|
type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
|
|
@@ -129,12 +129,10 @@ export class FetchService {
|
|
|
129
129
|
if (direct) return direct;
|
|
130
130
|
|
|
131
131
|
// Owning relation, resolved: the relation names its own local key.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (foreignKey) return foreignKey;
|
|
137
|
-
}
|
|
132
|
+
const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
|
|
133
|
+
if (declaredRelation?.kind === "belongsTo") {
|
|
134
|
+
const foreignKey = columnAt(declaredRelation.localKey);
|
|
135
|
+
if (foreignKey) return foreignKey;
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
// No collection in hand — the two shapes an owning relation's key takes
|
|
@@ -145,7 +143,61 @@ export class FetchService {
|
|
|
145
143
|
if (foreignKey) return foreignKey;
|
|
146
144
|
}
|
|
147
145
|
|
|
148
|
-
|
|
146
|
+
// Nothing resolved. Returning `undefined` is exactly what the docblock
|
|
147
|
+
// above describes: the caller drops the ORDER BY and hands back rows in
|
|
148
|
+
// whatever order Postgres pleases, while the requester believes they
|
|
149
|
+
// are sorted. `?orderBy=titel` answered 200 with unsorted data and no
|
|
150
|
+
// hint that the sort had been ignored.
|
|
151
|
+
//
|
|
152
|
+
// A *filter* naming a field that does not exist is already refused for
|
|
153
|
+
// precisely this reason — it "used to widen results silently". An
|
|
154
|
+
// unresolvable sort field is the same drift between a query and the
|
|
155
|
+
// schema, so it answers the same way and honours the same switch: one
|
|
156
|
+
// knob, because a deployment that wants the lenient behaviour wants it
|
|
157
|
+
// for both.
|
|
158
|
+
const collectionName = collection?.slug ?? collection?.name;
|
|
159
|
+
const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
|
|
160
|
+
|
|
161
|
+
// A declared to-many relation is a different mistake from a typo, and
|
|
162
|
+
// saying "unknown field" about a field the collection plainly declares
|
|
163
|
+
// sends the reader looking for a spelling error that is not there.
|
|
164
|
+
// There is simply no single value per row to order by — `posts.tags` is
|
|
165
|
+
// a set — so no ORDER BY exists to write, with or without a typo.
|
|
166
|
+
if (declaredRelation && declaredRelation.kind !== "belongsTo") {
|
|
167
|
+
throw ApiError.badRequest(
|
|
168
|
+
`Cannot sort by '${orderBy}'${onCollection}: it is a to-many relation ` +
|
|
169
|
+
`(${declaredRelation.kind}), which has no single value per row to order by.`,
|
|
170
|
+
"ORDER_BY_FIELD_NOT_SORTABLE",
|
|
171
|
+
{ field: orderBy, kind: declaredRelation.kind, ...(collectionName && { collection: collectionName }) }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (getUnknownFilterFieldsMode() === "warn") {
|
|
176
|
+
logger.warn(
|
|
177
|
+
`Sorting by field '${orderBy}'${onCollection}, but it does not exist in the table — ` +
|
|
178
|
+
"the ORDER BY was dropped and these rows are unsorted."
|
|
179
|
+
);
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let validFields: string[] = [];
|
|
184
|
+
try {
|
|
185
|
+
validFields = Object.keys(getTableColumns(table)).sort();
|
|
186
|
+
} catch {
|
|
187
|
+
// A table stand-in without Drizzle's column symbols — the message
|
|
188
|
+
// is worth less without the list, but not worth failing over.
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw ApiError.badRequest(
|
|
192
|
+
`Unknown orderBy field '${orderBy}'${onCollection}` +
|
|
193
|
+
(validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
|
|
194
|
+
"UNKNOWN_ORDER_BY_FIELD",
|
|
195
|
+
{
|
|
196
|
+
field: orderBy,
|
|
197
|
+
...(collectionName && { collection: collectionName }),
|
|
198
|
+
...(validFields.length > 0 && { validFields })
|
|
199
|
+
}
|
|
200
|
+
);
|
|
149
201
|
}
|
|
150
202
|
|
|
151
203
|
/**
|
|
@@ -555,7 +607,7 @@ idColumn };
|
|
|
555
607
|
|
|
556
608
|
if (!row) return undefined;
|
|
557
609
|
|
|
558
|
-
const flatRow =
|
|
610
|
+
const flatRow = toFlatRow(row, collection, this.registry);
|
|
559
611
|
|
|
560
612
|
// Post-fetch joinPath relations that Drizzle's `with` can't express
|
|
561
613
|
await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
|
|
@@ -682,7 +734,7 @@ idColumn };
|
|
|
682
734
|
const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
|
|
683
735
|
|
|
684
736
|
const rows = (results as Record<string, unknown>[]).map(row =>
|
|
685
|
-
|
|
737
|
+
toFlatRow(row, collection, this.registry)
|
|
686
738
|
);
|
|
687
739
|
|
|
688
740
|
return rows;
|
|
@@ -785,7 +837,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
785
837
|
/**
|
|
786
838
|
* Fallback path used when db.query is unavailable.
|
|
787
839
|
*
|
|
788
|
-
* The primary path runs the results through `
|
|
840
|
+
* The primary path runs the results through `toFlatRow`, which maps
|
|
789
841
|
* relations from what drizzle already nested — no query per row. This one
|
|
790
842
|
* has no nesting to read, so it resolves relations itself, in batches.
|
|
791
843
|
*
|
|
@@ -942,6 +994,7 @@ relatedTo: hop });
|
|
|
942
994
|
collectionPath: string,
|
|
943
995
|
options: {
|
|
944
996
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
997
|
+
logical?: LogicalCondition;
|
|
945
998
|
searchString?: string;
|
|
946
999
|
databaseId?: string;
|
|
947
1000
|
} = {}
|
|
@@ -973,6 +1026,13 @@ relatedTo: hop });
|
|
|
973
1026
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
974
1027
|
}
|
|
975
1028
|
|
|
1029
|
+
if (options.logical) {
|
|
1030
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
|
|
1031
|
+
options.logical, table, effectivePath, this.filterContext(effectivePath, table)
|
|
1032
|
+
);
|
|
1033
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
976
1036
|
if (allConditions.length > 0) {
|
|
977
1037
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
978
1038
|
if (finalCondition) query = query.where(finalCondition);
|
|
@@ -1043,6 +1103,8 @@ relatedTo: hop });
|
|
|
1043
1103
|
collectionPath: string,
|
|
1044
1104
|
options: {
|
|
1045
1105
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1106
|
+
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
1107
|
+
logical?: LogicalCondition;
|
|
1046
1108
|
orderBy?: string;
|
|
1047
1109
|
order?: "desc" | "asc";
|
|
1048
1110
|
limit?: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eq, and, sql, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
-
import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany } from "@rebasepro/types";
|
|
4
|
+
import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
@@ -240,16 +240,30 @@ export class PersistService {
|
|
|
240
240
|
throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
|
|
241
241
|
}
|
|
242
242
|
} else {
|
|
243
|
-
// One-to-many create: stamp the parent's
|
|
243
|
+
// One-to-many create: stamp the parent's key onto the child's FK.
|
|
244
244
|
const targetColumnName = this.resolveParentForeignKeyColumn(hop);
|
|
245
245
|
|
|
246
246
|
if (targetColumnName) {
|
|
247
|
-
|
|
247
|
+
// Not necessarily the id in the path: a link with a
|
|
248
|
+
// `sourceKey` is pointed at a column on the parent row, and
|
|
249
|
+
// stamping the id would create a child that belongs to
|
|
250
|
+
// nobody — a row the read path would never return again.
|
|
251
|
+
const parentKeyValue = hasForeignKeyOnTarget(hop.relation)
|
|
252
|
+
? await this.relationService.parentKeyValue(hop.parentCollection, hop.relation, hop.parentId)
|
|
253
|
+
: parentIdForWrite();
|
|
254
|
+
|
|
255
|
+
if (parentKeyValue === undefined) {
|
|
256
|
+
throw ApiError.badRequest(
|
|
257
|
+
`Cannot create under "${collectionPath}": the parent row has no value in ` +
|
|
258
|
+
`\`sourceKey\`, so the new row has nothing to point at.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
248
262
|
const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
|
|
249
|
-
if (existingValue !== undefined && existingValue !== null && existingValue !==
|
|
250
|
-
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent
|
|
263
|
+
if (existingValue !== undefined && existingValue !== null && existingValue !== parentKeyValue) {
|
|
264
|
+
logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent key '${parentKeyValue}'.`);
|
|
251
265
|
}
|
|
252
|
-
(effectiveValues as Record<string, unknown>)[targetColumnName] =
|
|
266
|
+
(effectiveValues as Record<string, unknown>)[targetColumnName] = parentKeyValue;
|
|
253
267
|
}
|
|
254
268
|
}
|
|
255
269
|
}
|