@rebasepro/server-postgres 0.12.1-canary.g7fff3b4 → 0.12.1-canary.g802486a

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 (71) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/auth/services.d.ts +6 -1
  3. package/dist/backup/backup-service.d.ts +10 -1
  4. package/dist/backup/pg-tools.d.ts +47 -0
  5. package/dist/{backup-service-vAKWJkYL.js → backup-service-CD8o_1Sl.js} +142 -10
  6. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  7. package/dist/cli-helpers.d.ts +15 -0
  8. package/dist/{ensure-collection-policies-BjSwj0FM.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  9. package/dist/{ensure-collection-policies-BjSwj0FM.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  10. package/dist/{ensure-collection-tables-D6-XhqnQ.js → ensure-collection-tables-CBQdOETu.js} +72 -12
  11. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  12. package/dist/index.es.js +969 -685
  13. package/dist/index.es.js.map +1 -1
  14. package/dist/schema/ensure-collection-tables.d.ts +21 -1
  15. package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -0
  16. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  17. package/dist/schema/introspect-db-logic.d.ts +94 -5
  18. package/dist/schema/introspect-db-queries.d.ts +119 -0
  19. package/dist/schema/introspect-db-structure.d.ts +263 -0
  20. package/dist/schema/introspect-db-types.d.ts +11 -0
  21. package/dist/services/FetchService.d.ts +4 -1
  22. package/dist/services/RelationService.d.ts +24 -1
  23. package/dist/services/channel-bus/index.d.ts +1 -7
  24. package/dist/services/collection-helpers.d.ts +36 -2
  25. package/dist/services/dataService.d.ts +3 -1
  26. package/dist/services/row-pipeline.d.ts +1 -1
  27. package/dist/{src-C_NHNVW2.js → src-DlPBctw_.js} +207 -22
  28. package/dist/src-DlPBctw_.js.map +1 -0
  29. package/dist/utils/connection-string.d.ts +29 -0
  30. package/dist/utils/drizzle-conditions.d.ts +5 -4
  31. package/dist/utils/pg-error-utils.d.ts +16 -0
  32. package/dist/{websocket-DB7TbFPT.js → websocket-B2LsrINK.js} +4 -3
  33. package/dist/websocket-B2LsrINK.js.map +1 -0
  34. package/package.json +6 -6
  35. package/src/PostgresAdapter.ts +7 -2
  36. package/src/PostgresBackendDriver.ts +4 -0
  37. package/src/auth/services.ts +6 -3
  38. package/src/backup/backup-cli.ts +41 -2
  39. package/src/backup/backup-service.ts +38 -5
  40. package/src/backup/pg-tools.ts +96 -3
  41. package/src/cli-helpers.ts +26 -0
  42. package/src/cli.ts +13 -23
  43. package/src/collections/validate-relations.ts +15 -0
  44. package/src/data-transformer.ts +9 -3
  45. package/src/schema/ensure-collection-tables.ts +81 -10
  46. package/src/schema/generate-drizzle-schema-logic.ts +26 -1
  47. package/src/schema/generate-postgres-ddl-logic.ts +50 -6
  48. package/src/schema/introspect-db-constraints.ts +385 -0
  49. package/src/schema/introspect-db-inference.ts +18 -8
  50. package/src/schema/introspect-db-logic.ts +385 -71
  51. package/src/schema/introspect-db-queries.ts +326 -0
  52. package/src/schema/introspect-db-structure.ts +670 -0
  53. package/src/schema/introspect-db-types.ts +56 -0
  54. package/src/schema/introspect-db.ts +37 -80
  55. package/src/security/policy-drift.test.ts +11 -3
  56. package/src/services/BranchService.ts +66 -28
  57. package/src/services/FetchService.ts +90 -14
  58. package/src/services/PersistService.ts +20 -6
  59. package/src/services/RelationService.ts +211 -45
  60. package/src/services/channel-bus/index.ts +0 -9
  61. package/src/services/collection-helpers.ts +69 -3
  62. package/src/services/dataService.ts +3 -1
  63. package/src/services/row-pipeline.ts +1 -1
  64. package/src/utils/connection-string.ts +58 -0
  65. package/src/utils/drizzle-conditions.ts +31 -6
  66. package/src/utils/pg-error-utils.ts +19 -0
  67. package/src/websocket.ts +15 -6
  68. package/dist/backup-service-vAKWJkYL.js.map +0 -1
  69. package/dist/ensure-collection-tables-D6-XhqnQ.js.map +0 -1
  70. package/dist/src-C_NHNVW2.js.map +0 -1
  71. package/dist/websocket-DB7TbFPT.js.map +0 -1
@@ -141,7 +141,7 @@ export declare class FetchService {
141
141
  /**
142
142
  * Fallback path used when db.query is unavailable.
143
143
  *
144
- * The primary path runs the results through `toCmsRow`, which maps
144
+ * The primary path runs the results through `toFlatRow`, which maps
145
145
  * relations from what drizzle already nested — no query per row. This one
146
146
  * has no nesting to read, so it resolves relations itself, in batches.
147
147
  *
@@ -177,6 +177,7 @@ export declare class FetchService {
177
177
  */
178
178
  count<M extends Record<string, unknown>>(collectionPath: string, options?: {
179
179
  filter?: FilterValues<Extract<keyof M, string>>;
180
+ logical?: LogicalCondition;
180
181
  searchString?: string;
181
182
  databaseId?: string;
182
183
  }): Promise<number>;
@@ -198,6 +199,8 @@ export declare class FetchService {
198
199
  */
199
200
  fetchCollectionForRest<M extends Record<string, unknown>>(collectionPath: string, options?: {
200
201
  filter?: FilterValues<Extract<keyof M, string>>;
202
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
203
+ logical?: LogicalCondition;
201
204
  orderBy?: string;
202
205
  order?: "desc" | "asc";
203
206
  limit?: number;
@@ -1,5 +1,5 @@
1
1
  import { DrizzleClient } from "../interfaces";
2
- import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
2
+ import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
3
  import { type ResolvedVia } from "@rebasepro/types";
4
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
5
5
  import type { NestedPathHop } from "./nested-path";
@@ -56,6 +56,29 @@ export declare class RelationService {
56
56
  * hand a tenant's rows to its neighbour — say so instead.
57
57
  */
58
58
  private assertSingleKeyAddressable;
59
+ /**
60
+ * What the target's foreign key holds, for each of these parent rows.
61
+ *
62
+ * Ordinarily the parent's id, and then this is free. When the relation
63
+ * declares a `sourceKey` the two are different values, and the mapping
64
+ * between them lives in the source table — so it costs one SELECT, issued
65
+ * once for the whole batch rather than per parent.
66
+ *
67
+ * Both directions come back because both are needed and deriving one from
68
+ * the other by hand is how a batch loader ends up attributing a child to the
69
+ * wrong parent: reads translate id → key to build the WHERE, and then
70
+ * translate key → id to attribute each row that comes back.
71
+ */
72
+ /**
73
+ * The value a related row's foreign key must hold to belong to this parent.
74
+ *
75
+ * `undefined` when the parent's source key is null — which is not an error
76
+ * here, only in the callers that were about to write it. Exposed for
77
+ * {@link PersistService}, which stamps this onto a child created under a
78
+ * nested path and would otherwise write the id and lose the row.
79
+ */
80
+ parentKeyValue(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentId: string | number, db?: DrizzleClient): Promise<string | number | undefined>;
81
+ private resolveSourceKeys;
59
82
  /**
60
83
  * Fetch rows related to a parent row through a specific relation
61
84
  */
@@ -15,7 +15,7 @@
15
15
  * `types/channel_bus.ts` for the contract such a package implements.
16
16
  */
17
17
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
18
- import { type ChannelBus, type ChannelBusConfig, type ChannelBusSetting } from "@rebasepro/types";
18
+ import { type ChannelBus, type ChannelBusSetting } from "@rebasepro/types";
19
19
  export * from "./ChannelBus";
20
20
  export { PostgresChannelBus, CHANNEL_BUS_NOTIFY_CHANNEL, PG_NOTIFY_MAX_PAYLOAD_BYTES, DEFAULT_BATCH_WINDOW_MS, parseChannelBusFrame, parseChannelBusPayload } from "./PostgresChannelBus";
21
21
  export interface ChannelBusDeps {
@@ -37,12 +37,6 @@ export interface ChannelBusDeps {
37
37
  * mean silently discarding the object the application handed us.
38
38
  */
39
39
  export declare function resolveChannelBusSetting(configured?: ChannelBusSetting): ChannelBusSetting;
40
- /**
41
- * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
42
- * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
43
- * config-only callers keep their exact types.
44
- */
45
- export declare function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig;
46
40
  /**
47
41
  * Produce the bus a setting asks for.
48
42
  *
@@ -1,7 +1,7 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
- import { CollectionConfig } from "@rebasepro/types";
2
+ import { CollectionConfig, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
- export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
4
+ export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
5
5
  export type { PrimaryKeyInfo } from "@rebasepro/common";
6
6
  import type { PrimaryKeyInfo } from "@rebasepro/common";
7
7
  /**
@@ -22,6 +22,17 @@ export interface DrizzleColumnMeta {
22
22
  }
23
23
  /** Safely extract Drizzle column metadata from a column object. */
24
24
  export declare function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta;
25
+ /**
26
+ * Whether an address could name a row in this table, judged by the columns.
27
+ *
28
+ * {@link getPrimaryKeys} lets a config's `isId: "uuid"` win over the schema, so
29
+ * its `isUUID` is a claim rather than a fact — right for deriving addresses,
30
+ * wrong for refusing a query. Here the Drizzle column type decides, because it
31
+ * is what Postgres will enforce: a `uuid` column meets `/c/products/new` with
32
+ * `22P02`, which aborts the surrounding transaction and turns every later
33
+ * statement into an unrelated-looking `25P02`.
34
+ */
35
+ export declare function idCanAddressTable(id: string | number, table: PgTable, idInfoArray: PrimaryKeyInfo[]): boolean;
25
36
  export declare function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig;
26
37
  export declare function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any>;
27
38
  /**
@@ -55,6 +66,29 @@ export declare function getPrimaryKeys(collection: CollectionConfig, registry: P
55
66
  * wrong and which collection it is wrong about.
56
67
  */
57
68
  export declare function requirePrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[];
69
+ /**
70
+ * The column on the *source* table that a `hasOne`/`hasMany` link points at.
71
+ *
72
+ * `sourceKey` is authored when the two sides join on a natural key — an
73
+ * external identity id, a SKU — and left off when they join on the row id,
74
+ * which is the overwhelming majority. That makes `undefined` the only optional
75
+ * field on a resolved relation, so it gets exactly one reader: this function.
76
+ * Every consumer that needs the column asks here, and none of them re-derives
77
+ * "or else the primary key" for itself. That is the whole point — the fallback
78
+ * chains this codebase removed from relation resolution were dangerous because
79
+ * they were *duplicated* and could disagree, not because they existed.
80
+ */
81
+ export declare function sourceKeyField(relation: ResolvedHasOne | ResolvedHasMany, sourceCollection: CollectionConfig, registry: PostgresCollectionRegistry): string;
82
+ /**
83
+ * Whether this link joins on something other than the source's primary key.
84
+ *
85
+ * Callers that hold a parent *id* — which is most of them, since an id is what
86
+ * a URL carries — must translate it to the source key's value before it can be
87
+ * compared with the target's foreign key. Those that hold the parent *row*, or
88
+ * that build a correlated subquery over the source table, can read the column
89
+ * directly and skip the lookup.
90
+ */
91
+ export declare function joinsOnNaturalKey(relation: ResolvedHasOne | ResolvedHasMany, sourceCollection: CollectionConfig, registry: PostgresCollectionRegistry): boolean;
58
92
  /**
59
93
  * Collections whose key the *browser* cannot resolve, and what it will do
60
94
  * instead.
@@ -1,4 +1,4 @@
1
- import { FilterValues } from "@rebasepro/types";
1
+ import { FilterValues, LogicalCondition } from "@rebasepro/types";
2
2
  import type { VectorSearchParams } from "@rebasepro/types";
3
3
  import { FetchService } from "./FetchService";
4
4
  import { PersistService } from "./PersistService";
@@ -60,6 +60,8 @@ export declare class DataService implements DataRepository {
60
60
  */
61
61
  count<M extends Record<string, unknown>>(collectionPath: string, options?: {
62
62
  filter?: FilterValues<Extract<keyof M, string>>;
63
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
64
+ logical?: LogicalCondition;
63
65
  searchString?: string;
64
66
  databaseId?: string;
65
67
  }): Promise<number>;
@@ -47,7 +47,7 @@ export declare function relationTargetAddress(targetRow: Record<string, unknown>
47
47
  * expects real types. The row's own address is *not* among the columns — it is
48
48
  * derived by the consumer from the collection's primary keys.
49
49
  */
50
- export declare function toCmsRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
50
+ export declare function toFlatRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
51
51
  /**
52
52
  * The row REST serves: every column under its own name, with the value Postgres
53
53
  * returned, and relations inlined as the target's columns.
@@ -1067,16 +1067,12 @@ function removeFunctions(o) {
1067
1067
  if (o === void 0) return void 0;
1068
1068
  if (o === null) return null;
1069
1069
  if (typeof o === "object") {
1070
- if (Array.isArray(o)) return o.map((v) => removeFunctions(v));
1070
+ if (Array.isArray(o)) return o.filter((v) => typeof v !== "function").map((v) => removeFunctions(v));
1071
1071
  if (!isPlainObject(o)) return o;
1072
- return Object.entries(o).filter(([_, value]) => typeof value !== "function").map(([key, value]) => {
1073
- if (Array.isArray(value)) return { [key]: value.map((v) => removeFunctions(v)) };
1074
- else if (typeof value === "object") return { [key]: removeFunctions(value) };
1075
- else return { [key]: value };
1076
- }).reduce((a, b) => ({
1077
- ...a,
1078
- ...b
1079
- }), {});
1072
+ return Object.entries(o).filter(([_, value]) => typeof value !== "function").reduce((acc, [key, value]) => {
1073
+ acc[key] = removeFunctions(value);
1074
+ return acc;
1075
+ }, {});
1080
1076
  }
1081
1077
  return o;
1082
1078
  }
@@ -1215,11 +1211,101 @@ function getPolicyNamesForRule(rule, tableName) {
1215
1211
  return ops.map((op, opIdx) => rule.name ? ops.length > 1 ? `${rule.name}_${op}` : rule.name : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`);
1216
1212
  }
1217
1213
  //#endregion
1214
+ //#region ../utils/src/plurals.ts
1215
+ /**
1216
+ * Returns the singular of an English word.
1217
+ *
1218
+ * @param {string} word
1219
+ * @param {number} [amount]
1220
+ * @returns {string}
1221
+ */
1222
+ function singular(word, amount) {
1223
+ if (amount !== void 0 && amount !== 1) return word;
1224
+ const singulars = {
1225
+ "(quiz)zes$": "$1",
1226
+ "(matr)ices$": "$1ix",
1227
+ "(vert|ind)ices$": "$1ex",
1228
+ "^(ox)en$": "$1",
1229
+ "(alias)es$": "$1",
1230
+ "(octop|vir)i$": "$1us",
1231
+ "(cris|ax|test)es$": "$1is",
1232
+ "(shoe)s$": "$1",
1233
+ "(o)es$": "$1",
1234
+ "(bus)es$": "$1",
1235
+ "([m|l])ice$": "$1ouse",
1236
+ "(x|ch|ss|sh)es$": "$1",
1237
+ "(m)ovies$": "$1ovie",
1238
+ "(s)eries$": "$1eries",
1239
+ "([^aeiouy]|qu)ies$": "$1y",
1240
+ "([lr])ves$": "$1f",
1241
+ "(tive)s$": "$1",
1242
+ "(hive)s$": "$1",
1243
+ "(li|wi|kni)ves$": "$1fe",
1244
+ "(shea|loa|lea|thie)ves$": "$1f",
1245
+ "(^analy)ses$": "$1sis",
1246
+ "((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$": "$1$2sis",
1247
+ "([ti])a$": "$1um",
1248
+ "(n)ews$": "$1ews",
1249
+ "(h|bl)ouses$": "$1ouse",
1250
+ "(corpse)s$": "$1",
1251
+ "(us)es$": "$1",
1252
+ s$: ""
1253
+ };
1254
+ const irregular = {
1255
+ move: "moves",
1256
+ foot: "feet",
1257
+ goose: "geese",
1258
+ sex: "sexes",
1259
+ child: "children",
1260
+ man: "men",
1261
+ tooth: "teeth",
1262
+ person: "people"
1263
+ };
1264
+ if ([
1265
+ "sheep",
1266
+ "fish",
1267
+ "deer",
1268
+ "moose",
1269
+ "series",
1270
+ "species",
1271
+ "money",
1272
+ "rice",
1273
+ "information",
1274
+ "equipment",
1275
+ "bison",
1276
+ "cod",
1277
+ "offspring",
1278
+ "pike",
1279
+ "salmon",
1280
+ "shrimp",
1281
+ "swine",
1282
+ "trout",
1283
+ "aircraft",
1284
+ "hovercraft",
1285
+ "spacecraft",
1286
+ "sugar",
1287
+ "tuna",
1288
+ "you",
1289
+ "wood"
1290
+ ].indexOf(word.toLowerCase()) >= 0) return word;
1291
+ for (const w in irregular) {
1292
+ const pattern = new RegExp(`${irregular[w]}$`, "i");
1293
+ if (pattern.test(word)) return word.replace(pattern, w);
1294
+ }
1295
+ for (const reg in singulars) {
1296
+ const pattern = new RegExp(reg, "i");
1297
+ if (pattern.test(word)) return word.replace(pattern, singulars[reg]);
1298
+ }
1299
+ return word;
1300
+ }
1301
+ //#endregion
1218
1302
  //#region ../utils/src/names.ts
1219
1303
  /**
1220
1304
  * Generates a foreign key column name from a given string, typically a collection slug or name.
1221
- * It converts the name to snake_case, attempts to singularize it by removing a trailing 's'
1222
- * (a common convention for collection names), and appends '_id'.
1305
+ * It singularizes the name, converts it to snake_case and appends '_id'.
1306
+ *
1307
+ * Singularization runs *before* snake-casing so that acronyms survive: `toSnakeCase`
1308
+ * splits on every capital, which turned "URLs" into "ur_ls" and then "ur_l_id".
1223
1309
  *
1224
1310
  * @param name The base name to convert to a foreign key.
1225
1311
  * @returns A foreign key name in the format 'singular_name_id'.
@@ -1229,8 +1315,8 @@ function getPolicyNamesForRule(rule, tableName) {
1229
1315
  * generateForeignKeyName("users")
1230
1316
  *
1231
1317
  * @example
1232
- * // returns "post_id"
1233
- * generateForeignKeyName("posts")
1318
+ * // returns "category_id"
1319
+ * generateForeignKeyName("categories")
1234
1320
  *
1235
1321
  * @example
1236
1322
  * // returns "product_id"
@@ -1238,8 +1324,41 @@ function getPolicyNamesForRule(rule, tableName) {
1238
1324
  *
1239
1325
  */
1240
1326
  function generateForeignKeyName(name) {
1241
- const snakeCaseName = toSnakeCase(name);
1242
- return `${snakeCaseName.endsWith("s") ? snakeCaseName.slice(0, -1) : snakeCaseName}_id`;
1327
+ return `${toSnakeCase(singularizeForKey(name))}_id`;
1328
+ }
1329
+ /**
1330
+ * `singular()` handles real English plurals, but its final catch-all rule strips
1331
+ * any trailing "s", which mangles words that only look plural. Guard the two
1332
+ * cases that produce a column name nobody would recognise:
1333
+ *
1334
+ * - a double "s" ending is never a plural marker ("address", "class", "process"),
1335
+ * so stripping it yields "addres";
1336
+ * - a name that singularizes to nothing (the literal "s") would yield "_id".
1337
+ */
1338
+ function singularizeForKey(name) {
1339
+ if (/ss$/i.test(name)) return name;
1340
+ const result = singular(name);
1341
+ return result.length > 0 ? result : name;
1342
+ }
1343
+ /**
1344
+ * What `generateForeignKeyName` returned before it learned to singularize:
1345
+ * snake-case the name, then chop one trailing "s".
1346
+ *
1347
+ * This is here to be *detected*, never to be generated. A database provisioned
1348
+ * under the old rule carries `categorie_id`, `addresse_id`, `children_id` or
1349
+ * `ur_l_id` where the current rule expects `category_id`, `address_id`,
1350
+ * `child_id` and `url_id` — and the boot-time schema ensure is additive, so it
1351
+ * would create the new column empty beside the populated old one and leave the
1352
+ * relation reading nothing. No error, no missing table: the failure is silent,
1353
+ * which is the only reason this function still exists.
1354
+ *
1355
+ * `ensureCollectionSchema` calls it to recognise that shape and say so.
1356
+ * Returns the same string as `generateForeignKeyName` for every regular plural,
1357
+ * so a caller can compare the two and act only when they differ.
1358
+ */
1359
+ function legacyForeignKeyName(name) {
1360
+ const snake = toSnakeCase(name);
1361
+ return `${snake.endsWith("s") ? snake.slice(0, -1) : snake}_id`;
1243
1362
  }
1244
1363
  //#endregion
1245
1364
  //#region ../common/src/util/entities.ts
@@ -1339,6 +1458,34 @@ function createRelationRefWithData(id, path, data) {
1339
1458
  data
1340
1459
  };
1341
1460
  }
1461
+ /** The eight-four-four-four-twelve shape of a UUID, any version. */
1462
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1463
+ /** Whether one address part can be a value of the column it addresses. */
1464
+ function partIsAddressable(part, pk) {
1465
+ if (pk.isUUID) return UUID_PATTERN.test(String(part));
1466
+ if (pk.type === "number") return typeof part === "number" ? Number.isFinite(part) : !isNaN(parseInt(String(part), 10));
1467
+ return true;
1468
+ }
1469
+ /**
1470
+ * Whether an address could name a row at all, before asking the database.
1471
+ *
1472
+ * A `uuid` column cannot hold `"new"`, and an `integer` column cannot hold
1473
+ * `"abc"` — so the answer to "which row is this" is "none", and that is a 404,
1474
+ * not a failure. Postgres cannot say so politely: the comparison never runs, it
1475
+ * raises `22P02` and aborts the enclosing transaction, after which every
1476
+ * further statement returns the far less helpful `25P02`.
1477
+ *
1478
+ * `isUUID` must come from the column, not from `isId: "uuid"` in a config: the
1479
+ * config is a claim about a key, and a `text` column that holds ids of some
1480
+ * other shape is a working app this must not start rejecting.
1481
+ */
1482
+ function isAddressableId(idValue, primaryKeys) {
1483
+ if (primaryKeys.length === 0) return false;
1484
+ if (primaryKeys.length === 1) return partIsAddressable(idValue, primaryKeys[0]);
1485
+ const parts = String(idValue).split(":::");
1486
+ if (parts.length !== primaryKeys.length) return false;
1487
+ return parts.every((part, i) => partIsAddressable(part, primaryKeys[i]));
1488
+ }
1342
1489
  /**
1343
1490
  * Derive a row's address from its key columns.
1344
1491
  *
@@ -1479,8 +1626,7 @@ function enumToObjectEntries(enumValues) {
1479
1626
  function resolveRelation(relation, sourceCollection, propertyKey) {
1480
1627
  const target = relation.target;
1481
1628
  if (typeof target !== "function") throw new Error(`Relation${relation.relationName ? ` '${relation.relationName}'` : ""} on '${sourceCollection.slug}' has no \`target\`. Give it a thunk: \`target: () => otherCollection\`.`);
1482
- const targetCollection = target();
1483
- if (!targetCollection?.slug) throw new Error(`Relation${relation.relationName ? ` '${relation.relationName}'` : ""} on '${sourceCollection.slug}' has a \`target\` that did not resolve to a collection.`);
1629
+ const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);
1484
1630
  const relationName = relation.relationName ?? propertyKey ?? toSnakeCase(targetCollection.slug);
1485
1631
  const shared = {
1486
1632
  relationName,
@@ -1507,7 +1653,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
1507
1653
  cardinality: "one",
1508
1654
  writable: true,
1509
1655
  shared: false,
1510
- foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName)
1656
+ foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),
1657
+ sourceKey: relation.sourceKey
1511
1658
  };
1512
1659
  case "hasMany": return {
1513
1660
  ...shared,
@@ -1515,7 +1662,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
1515
1662
  cardinality: "many",
1516
1663
  writable: true,
1517
1664
  shared: false,
1518
- foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName)
1665
+ foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),
1666
+ sourceKey: relation.sourceKey
1519
1667
  };
1520
1668
  case "manyToMany": {
1521
1669
  const sourceTable = getTableName(sourceCollection);
@@ -1544,6 +1692,43 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
1544
1692
  default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
1545
1693
  }
1546
1694
  }
1695
+ /** How this relation is addressed in an error message, before it has a resolved name. */
1696
+ function describe(relation, sourceCollection, propertyKey) {
1697
+ const name = relation.relationName ?? propertyKey;
1698
+ return `Relation${name ? ` '${name}'` : ""} on '${sourceCollection.slug}'`;
1699
+ }
1700
+ /**
1701
+ * Call the `target` thunk, and translate the two ways an import cycle breaks it
1702
+ * into an error that names the cause.
1703
+ *
1704
+ * The thunk exists to defer the reference until every module has finished
1705
+ * evaluating, and for a cycle that closes at import time it does. What it cannot
1706
+ * defer is a cycle that leaves the binding permanently unusable, and there are
1707
+ * two shapes of that:
1708
+ *
1709
+ * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are
1710
+ * in the temporal dead zone, so reading one throws `ReferenceError: x is not
1711
+ * defined`. The stack points at the thunk — a one-line arrow function that is
1712
+ * obviously fine — and says nothing about the cycle that made it throw.
1713
+ * - **CJS interop.** The half-initialised module object has no `default` yet,
1714
+ * the import resolves to `undefined`, and the thunk returns it without
1715
+ * complaint. That one used to surface here as "did not resolve to a
1716
+ * collection", which is true and unhelpful.
1717
+ *
1718
+ * Both mean the same thing, and the fix for both is the same: break the cycle,
1719
+ * or move the relation into the collection that does not close it.
1720
+ */
1721
+ function callTarget(relation, sourceCollection, propertyKey, target) {
1722
+ let targetCollection;
1723
+ try {
1724
+ targetCollection = target();
1725
+ } catch (error) {
1726
+ if (error instanceof ReferenceError) throw new Error(`${describe(relation, sourceCollection, propertyKey)} targets a collection that is not initialized yet — almost always an import cycle between the two collection files. Break the cycle (move the shared piece into a third module, or import the target lazily) so the target's module finishes evaluating before the registry is built.`, { cause: error });
1727
+ throw error;
1728
+ }
1729
+ if (!targetCollection?.slug) throw new Error(`${describe(relation, sourceCollection, propertyKey)} has a \`target\` that resolved to ${targetCollection === void 0 ? "`undefined`" : "something that is not a collection"}. ` + (targetCollection === void 0 ? "Under CommonJS interop an import cycle resolves the default import to `undefined`, so check whether this collection and its target import each other. Otherwise the thunk is returning the wrong value — it must return the collection itself, not a promise or a module." : "The thunk must return a collection config with a `slug`."));
1730
+ return targetCollection;
1731
+ }
1547
1732
  //#endregion
1548
1733
  //#region ../common/src/util/relations.ts
1549
1734
  /**
@@ -3731,7 +3916,7 @@ function rowToEntity(row, slug, primaryKeys = []) {
3731
3916
  };
3732
3917
  }
3733
3918
  /**
3734
- * The relation envelope `toCmsRow` writes where a relation was:
3919
+ * The relation envelope `toFlatRow` writes where a relation was:
3735
3920
  * `{ id, path, __type: "relation", data: { id, path, values } }`. It is the
3736
3921
  * admin's view-model, and the only pipeline that produces one is postgres'.
3737
3922
  */
@@ -4109,6 +4294,6 @@ function buildSdkData(driver) {
4109
4294
  return wrapAsSdkData(buildRebaseData(driver));
4110
4295
  }
4111
4296
  //#endregion
4112
- export { DEFAULT_ONE_OF_VALUE as A, updateDateAutoValues as C, camelCase as D, mergeDeep as E, isManyToMany as M, Vector as N, toSnakeCase as O, normalizeToEntityRelation as S, getPolicyNamesForRule as T, buildCompositeId as _, getJunctionSecurityRules as a, createRelationRef as b, policyToPostgres as c, findRelation as d, getColumnName as f, resolveCollectionRelations as g, getTableVarName as h, getJunctionCollectionConfig as i, hasForeignKeyOnTarget as j, DEFAULT_ONE_OF_TYPE as k, securityRuleToConditions as l, getTableName as m, CollectionRegistry as n, resolveJunctionSpecs as o, getEnumVarName as p, resolveStringColumnLength as r, getEffectiveSecurityRules as s, buildSdkData as t, findAnonymousGrants as u, getDeclaredPrimaryKeys as v, generateForeignKeyName as w, createRelationRefWithData as x, parseIdValues as y };
4297
+ export { toSnakeCase as A, normalizeToEntityRelation as C, getPolicyNamesForRule as D, legacyForeignKeyName as E, Vector as F, DEFAULT_ONE_OF_VALUE as M, hasForeignKeyOnTarget as N, mergeDeep as O, isManyToMany as P, createRelationRefWithData as S, generateForeignKeyName as T, buildCompositeId as _, getJunctionSecurityRules as a, parseIdValues as b, policyToPostgres as c, findRelation as d, getColumnName as f, resolveCollectionRelations as g, getTableVarName as h, getJunctionCollectionConfig as i, DEFAULT_ONE_OF_TYPE as j, camelCase as k, securityRuleToConditions as l, getTableName as m, CollectionRegistry as n, resolveJunctionSpecs as o, getEnumVarName as p, resolveStringColumnLength as r, getEffectiveSecurityRules as s, buildSdkData as t, findAnonymousGrants as u, getDeclaredPrimaryKeys as v, updateDateAutoValues as w, createRelationRef as x, isAddressableId as y };
4113
4298
 
4114
- //# sourceMappingURL=src-C_NHNVW2.js.map
4299
+ //# sourceMappingURL=src-DlPBctw_.js.map