@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.
Files changed (91) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/backup-service-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/connection-BuZ97wsr.js +250 -0
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  14. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  16. package/dist/index.es.js +816 -9679
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/auth-schema.d.ts +83 -144
  21. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  22. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  23. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  24. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  25. package/dist/schema/introspect-db-logic.d.ts +94 -5
  26. package/dist/schema/introspect-db-queries.d.ts +119 -0
  27. package/dist/schema/introspect-db-structure.d.ts +263 -0
  28. package/dist/schema/introspect-db-types.d.ts +11 -0
  29. package/dist/services/FetchService.d.ts +4 -1
  30. package/dist/services/RelationService.d.ts +24 -1
  31. package/dist/services/channel-bus/index.d.ts +1 -7
  32. package/dist/services/collection-helpers.d.ts +24 -1
  33. package/dist/services/dataService.d.ts +3 -1
  34. package/dist/services/row-pipeline.d.ts +1 -1
  35. package/dist/{src-BbFOPJ1S.js → src-CzbghKwf.js} +271 -173
  36. package/dist/src-CzbghKwf.js.map +1 -0
  37. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  38. package/dist/src-DoU9yPqq.js.map +1 -0
  39. package/dist/utils/connection-string.d.ts +29 -0
  40. package/dist/utils/drizzle-conditions.d.ts +5 -4
  41. package/dist/utils/pg-error-utils.d.ts +19 -0
  42. package/dist/websocket-B2LsrINK.js +530 -0
  43. package/dist/websocket-B2LsrINK.js.map +1 -0
  44. package/package.json +14 -14
  45. package/src/PostgresAdapter.ts +21 -2
  46. package/src/PostgresBackendDriver.ts +4 -0
  47. package/src/PostgresBootstrapper.ts +192 -33
  48. package/src/auth/ensure-tables.ts +164 -9
  49. package/src/auth/services.ts +24 -2
  50. package/src/backup/backup-cli.ts +41 -2
  51. package/src/backup/backup-service.ts +38 -5
  52. package/src/backup/pg-tools.ts +96 -3
  53. package/src/cli-helpers.ts +70 -0
  54. package/src/cli.ts +44 -26
  55. package/src/collections/validate-relations.ts +15 -0
  56. package/src/connection.ts +73 -0
  57. package/src/data-transformer.ts +9 -3
  58. package/src/databasePoolManager.ts +5 -2
  59. package/src/schema/auth-schema.ts +30 -19
  60. package/src/schema/ensure-collection-policies.ts +105 -0
  61. package/src/schema/ensure-collection-tables.test.ts +105 -9
  62. package/src/schema/ensure-collection-tables.ts +220 -32
  63. package/src/schema/generate-drizzle-schema-logic.ts +23 -6
  64. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  65. package/src/schema/introspect-db-constraints.ts +385 -0
  66. package/src/schema/introspect-db-inference.ts +18 -8
  67. package/src/schema/introspect-db-logic.ts +385 -71
  68. package/src/schema/introspect-db-queries.ts +326 -0
  69. package/src/schema/introspect-db-structure.ts +670 -0
  70. package/src/schema/introspect-db-types.ts +56 -0
  71. package/src/schema/introspect-db.ts +37 -80
  72. package/src/schema/introspect-runtime.test.ts +56 -8
  73. package/src/schema/introspect-runtime.ts +31 -9
  74. package/src/security/policy-drift.test.ts +11 -3
  75. package/src/services/FetchService.ts +76 -14
  76. package/src/services/PersistService.ts +20 -6
  77. package/src/services/RelationService.ts +249 -48
  78. package/src/services/channel-bus/index.ts +0 -9
  79. package/src/services/collection-helpers.ts +40 -1
  80. package/src/services/dataService.ts +3 -1
  81. package/src/services/realtimeService.ts +3 -3
  82. package/src/services/row-pipeline.ts +1 -1
  83. package/src/utils/connection-string.ts +58 -0
  84. package/src/utils/drizzle-conditions.ts +31 -6
  85. package/src/utils/pg-error-utils.ts +46 -0
  86. package/src/websocket.ts +18 -9
  87. package/dist/chunk-DSJWtz9O.js +0 -40
  88. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  89. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  90. package/dist/src-BbFOPJ1S.js.map +0 -1
  91. 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
- // 1. Get Tables
93
- const { rows: tables } = await client.query<TableRow>(`
94
- SELECT table_name
95
- FROM information_schema.tables
96
- WHERE table_schema = $1 AND table_type = 'BASE TABLE'
97
- AND table_name NOT LIKE 'drizzle_%'
98
- AND table_name NOT LIKE 'rebase_%'
99
- ORDER BY table_name
100
- `, [pgSchema]);
101
-
102
- // 2. Get Columns
103
- const { rows: columns } = await client.query<TableColumn>(`
104
- SELECT
105
- c.table_name,
106
- c.column_name,
107
- c.data_type,
108
- c.udt_name,
109
- c.is_nullable,
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
- // 4. Get Foreign Keys
147
- const { rows: fks } = await client.query<ForeignKeyRow>(`
148
- SELECT
149
- tc.table_name,
150
- kcu.column_name,
151
- ccu.table_name AS foreign_table_name,
152
- ccu.column_name AS foreign_column_name
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 tablesMap = buildTablesMap(tables, columns, pks, fks);
165
- const joinTables = identifyJoinTables(tablesMap);
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 (including ${joinTables.size} detected join 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).toEqual({
125
- name: "Author",
126
- type: "relation",
127
- target: "authors",
128
- cardinality: "one",
129
- direction: "owning",
130
- localKey: "author_id"
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. Mirrors the shape
211
- * `generateCollectionFile` emits, except `target` uses the slug string form
212
- * rather than a thunk to a module import.
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
- target: targetSlug,
237
- cardinality: "one",
238
- direction: "owning",
239
- localKey: fk.column_name
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
- const flagged = drift.insecure.some((i) => /WITH CHECK/.test(i.reason));
168
- // Only assert when the fixture actually had a WITH CHECK policy to carry it.
169
- if (expected.some((p) => p.hasWithCheck)) expect(flagged).toBe(true);
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 { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
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
- if (collection) {
133
- const relation = resolveCollectionRelations(collection)[orderBy];
134
- if (relation?.kind === "belongsTo") {
135
- const foreignKey = columnAt(relation.localKey);
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
- return undefined;
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 = toCmsRow(row, collection, this.registry);
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
- toCmsRow(row, collection, this.registry)
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 `toCmsRow`, which maps
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 id onto the child's FK.
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
- const parsedParentId = parentIdForWrite();
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 !== parsedParentId) {
250
- logger.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
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] = parsedParentId;
266
+ (effectiveValues as Record<string, unknown>)[targetColumnName] = parentKeyValue;
253
267
  }
254
268
  }
255
269
  }