@rebasepro/server-postgres 0.12.1-canary.gdd7172e → 0.12.1-canary.gddbd489

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.
@@ -24,6 +24,22 @@ export interface PostgresError extends Error {
24
24
  * error code (5-char alphanumeric, e.g. `42P01`).
25
25
  */
26
26
  export declare function extractPgError(error: unknown): PostgresError | null;
27
+ /**
28
+ * Whether the failure came back from Postgres rather than from building the
29
+ * query — which decides whether a fallback query is worth issuing.
30
+ *
31
+ * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
32
+ * RLS). Once a statement raises, that transaction is aborted, and every later
33
+ * statement on it returns `25P02` — "current transaction is aborted, commands
34
+ * ignored until end of transaction block". So a retry after a database error
35
+ * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
36
+ * for type uuid") with a generic one. Rethrow instead.
37
+ *
38
+ * A query the driver could not even build — a missing reciprocal relation, say
39
+ * — never reached Postgres, leaves the transaction usable, and is exactly what
40
+ * the fallback paths exist for.
41
+ */
42
+ export declare function reachedDatabase(error: unknown): boolean;
27
43
  /**
28
44
  * Walk the error cause chain and return the deepest meaningful message.
29
45
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.12.1-canary.gdd7172e",
4
+ "version": "0.12.1-canary.gddbd489",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/common": "0.12.1-canary.gdd7172e",
51
- "@rebasepro/server": "0.12.1-canary.gdd7172e",
52
- "@rebasepro/types": "0.12.1-canary.gdd7172e",
53
- "@rebasepro/utils": "0.12.1-canary.gdd7172e",
54
- "@rebasepro/codegen": "0.12.1-canary.gdd7172e"
50
+ "@rebasepro/codegen": "0.12.1-canary.gddbd489",
51
+ "@rebasepro/server": "0.12.1-canary.gddbd489",
52
+ "@rebasepro/common": "0.12.1-canary.gddbd489",
53
+ "@rebasepro/utils": "0.12.1-canary.gddbd489",
54
+ "@rebasepro/types": "0.12.1-canary.gddbd489"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -137,6 +137,25 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
137
137
  let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
138
138
  if (numProp.columnType) {
139
139
  if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
140
+ // `bigint` and `bigserial` are the only pg-core builders that
141
+ // *require* a config argument: without `mode`, drizzle cannot
142
+ // know whether to hand back a `number` or a `bigint`, and the
143
+ // emitted call does not typecheck.
144
+ //
145
+ // This is why `schema.generated.ts` drifted. Regenerating it
146
+ // produced a file that would not compile, so the bigint lines
147
+ // were hand-patched — and every regeneration after that looked
148
+ // like a large, alarming diff nobody wanted to ship. The file
149
+ // then sat stale for long enough that a security fix to two RLS
150
+ // policies never reached production.
151
+ //
152
+ // `number` rather than `bigint`: these are counters and byte
153
+ // totals that every caller already treats as numbers, and
154
+ // switching the runtime type would be a breaking change to
155
+ // every consumer of the generated schema.
156
+ else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") {
157
+ baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
158
+ }
140
159
  else baseType = `${numProp.columnType}("${colName}")`;
141
160
  }
142
161
 
@@ -12,6 +12,7 @@ import {
12
12
  requirePrimaryKeys,
13
13
  deriveRowAddress,
14
14
  parseIdValues,
15
+ idCanAddressTable,
15
16
  buildCompositeId,
16
17
  COMPOSITE_ID_SEPARATOR
17
18
  } from "./collection-helpers";
@@ -23,6 +24,7 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
23
24
  import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
24
25
  import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
25
26
  import { ApiError, logger } from "@rebasepro/server";
27
+ import { reachedDatabase } from "../utils/pg-error-utils";
26
28
 
27
29
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
28
30
  type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
@@ -588,6 +590,11 @@ idColumn };
588
590
  throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
589
591
  }
590
592
 
593
+ // An address the key columns cannot hold names no row — the same answer
594
+ // as a well-formed id nobody has. Asking Postgres instead raises 22P02
595
+ // and aborts the transaction around this read.
596
+ if (!idCanAddressTable(id, table, idInfoArray)) return undefined;
597
+
591
598
  const parsedIdObj = parseIdValues(id, idInfoArray);
592
599
  const parsedId = parsedIdObj[idInfo.fieldName];
593
600
 
@@ -618,6 +625,7 @@ idColumn };
618
625
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
619
626
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
620
627
  }
628
+ if (reachedDatabase(e)) throw e;
621
629
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
622
630
  }
623
631
  }
@@ -743,6 +751,7 @@ idColumn };
743
751
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
744
752
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
745
753
  }
754
+ if (reachedDatabase(e)) throw e;
746
755
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
747
756
  }
748
757
  }
@@ -1173,6 +1182,7 @@ relatedTo: hop }, include
1173
1182
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1174
1183
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1175
1184
  }
1185
+ if (reachedDatabase(e)) throw e;
1176
1186
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
1177
1187
  }
1178
1188
  }
@@ -1246,6 +1256,9 @@ relatedTo: hop }, include
1246
1256
  const idInfo = idInfoArray[0];
1247
1257
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1248
1258
 
1259
+ // See `fetchOne`: an unaddressable id is a 404, not a database error.
1260
+ if (!idCanAddressTable(id, table, idInfoArray)) return null;
1261
+
1249
1262
  const parsedIdObj = parseIdValues(id, idInfoArray);
1250
1263
  const parsedId = parsedIdObj[idInfo.fieldName];
1251
1264
 
@@ -1279,6 +1292,7 @@ relatedTo: hop }, include
1279
1292
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1280
1293
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1281
1294
  }
1295
+ if (reachedDatabase(e)) throw e;
1282
1296
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
1283
1297
  }
1284
1298
  }
@@ -7,9 +7,9 @@ import { logger } from "@rebasepro/server";
7
7
  // Row identity is derived on both sides of the wire — the driver parses an
8
8
  // incoming address into key columns, the admin derives one from a served row —
9
9
  // so the implementation lives in `common` and both agree by construction.
10
- export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
10
+ export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
11
11
  export type { PrimaryKeyInfo } from "@rebasepro/common";
12
- import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
12
+ import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys, isAddressableId } from "@rebasepro/common";
13
13
  import type { PrimaryKeyInfo } from "@rebasepro/common";
14
14
 
15
15
  /**
@@ -40,6 +40,33 @@ export function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta {
40
40
  };
41
41
  }
42
42
 
43
+ /**
44
+ * Whether an address could name a row in this table, judged by the columns.
45
+ *
46
+ * {@link getPrimaryKeys} lets a config's `isId: "uuid"` win over the schema, so
47
+ * its `isUUID` is a claim rather than a fact — right for deriving addresses,
48
+ * wrong for refusing a query. Here the Drizzle column type decides, because it
49
+ * is what Postgres will enforce: a `uuid` column meets `/c/products/new` with
50
+ * `22P02`, which aborts the surrounding transaction and turns every later
51
+ * statement into an unrelated-looking `25P02`.
52
+ */
53
+ export function idCanAddressTable(
54
+ id: string | number,
55
+ table: PgTable,
56
+ idInfoArray: PrimaryKeyInfo[]
57
+ ): boolean {
58
+ const columnBacked = idInfoArray.map(info => {
59
+ const col = table[info.fieldName as keyof typeof table] as AnyPgColumn | undefined;
60
+ const meta = col ? getColumnMeta(col) : undefined;
61
+ // No column to ask (a key the schema does not carry) leaves the id
62
+ // addressable: refusing on a guess would 404 rows that do exist.
63
+ if (!meta?.columnType) return info;
64
+ return { ...info,
65
+ isUUID: meta.columnType === "PgUUID" };
66
+ });
67
+ return isAddressableId(id, columnBacked);
68
+ }
69
+
43
70
  export function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig {
44
71
  const collection = registry.getCollectionByPath(collectionPath);
45
72
  if (!collection) {
@@ -79,6 +79,25 @@ export function extractPgError(error: unknown): PostgresError | null {
79
79
  return null;
80
80
  }
81
81
 
82
+ /**
83
+ * Whether the failure came back from Postgres rather than from building the
84
+ * query — which decides whether a fallback query is worth issuing.
85
+ *
86
+ * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
87
+ * RLS). Once a statement raises, that transaction is aborted, and every later
88
+ * statement on it returns `25P02` — "current transaction is aborted, commands
89
+ * ignored until end of transaction block". So a retry after a database error
90
+ * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
91
+ * for type uuid") with a generic one. Rethrow instead.
92
+ *
93
+ * A query the driver could not even build — a missing reciprocal relation, say
94
+ * — never reached Postgres, leaves the transaction usable, and is exactly what
95
+ * the fallback paths exist for.
96
+ */
97
+ export function reachedDatabase(error: unknown): boolean {
98
+ return extractPgError(error) !== null;
99
+ }
100
+
82
101
  /**
83
102
  * Walk the error cause chain and return the deepest meaningful message.
84
103
  */