@rebasepro/server-postgres 0.9.1-canary.a639738 → 0.9.1-canary.ad25bc0

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 (39) hide show
  1. package/dist/PostgresBootstrapper.d.ts +0 -10
  2. package/dist/auth/services.d.ts +0 -16
  3. package/dist/connection.d.ts +0 -21
  4. package/dist/data-transformer.d.ts +2 -9
  5. package/dist/index.es.js +329 -841
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/schema/doctor.d.ts +1 -1
  8. package/dist/security/policy-drift.d.ts +0 -30
  9. package/dist/services/FetchService.d.ts +32 -4
  10. package/dist/services/RelationService.d.ts +1 -34
  11. package/dist/services/collection-helpers.d.ts +0 -76
  12. package/dist/services/index.d.ts +1 -1
  13. package/dist/services/realtimeService.d.ts +0 -7
  14. package/package.json +7 -11
  15. package/src/PostgresBackendDriver.ts +11 -39
  16. package/src/PostgresBootstrapper.ts +25 -62
  17. package/src/auth/ensure-tables.ts +11 -73
  18. package/src/auth/services.ts +19 -49
  19. package/src/cli.ts +0 -60
  20. package/src/connection.ts +1 -61
  21. package/src/data-transformer.ts +9 -11
  22. package/src/databasePoolManager.ts +0 -2
  23. package/src/schema/doctor.ts +20 -45
  24. package/src/schema/generate-drizzle-schema-logic.ts +4 -21
  25. package/src/schema/generate-postgres-ddl-logic.ts +3 -63
  26. package/src/schema/introspect-db.ts +2 -19
  27. package/src/security/policy-drift.test.ts +1 -106
  28. package/src/security/policy-drift.ts +0 -56
  29. package/src/services/FetchService.ts +229 -50
  30. package/src/services/PersistService.ts +2 -9
  31. package/src/services/RelationService.ts +94 -153
  32. package/src/services/collection-helpers.ts +3 -166
  33. package/src/services/index.ts +0 -1
  34. package/src/services/realtimeService.ts +19 -40
  35. package/src/utils/drizzle-conditions.ts +0 -13
  36. package/dist/collections/buildRegistry.d.ts +0 -27
  37. package/dist/services/row-pipeline.d.ts +0 -63
  38. package/src/collections/buildRegistry.ts +0 -59
  39. package/src/services/row-pipeline.ts +0 -239
package/dist/index.es.js CHANGED
@@ -71,51 +71,16 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
71
71
  var connection_exports = /* @__PURE__ */ __exportAll({
72
72
  createDirectDatabaseConnection: () => createDirectDatabaseConnection,
73
73
  createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
74
- createReadReplicaConnection: () => createReadReplicaConnection,
75
- guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
74
+ createReadReplicaConnection: () => createReadReplicaConnection
76
75
  });
77
76
  var DEFAULT_POOL = {
78
77
  max: 20,
79
78
  idleTimeoutMillis: 3e4,
80
79
  connectionTimeoutMillis: 1e4,
81
- queryTimeout: 6e4,
80
+ queryTimeout: 3e4,
82
81
  statementTimeout: 3e4,
83
82
  keepAlive: true
84
83
  };
85
- /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
86
- var TX_IDLE = "I";
87
- /**
88
- * Destroy pool clients that are released while still inside a transaction.
89
- *
90
- * pg-pool returns a client to the idle list whenever `release()` is called
91
- * without an error — even if the connection is still mid-transaction (status
92
- * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
93
- * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
94
- * (e.g. it was queued behind a statement that hit the client-side
95
- * query_timeout), the client goes back dirty. The next checkout then runs
96
- * its statements inside the zombie transaction — with the previous request's
97
- * `app.*` RLS GUCs still applied, which turns unrelated queries into
98
- * RLS-scoped ones (observed in production as registration failing with
99
- * SQLSTATE 42501 under a leaked anonymous context).
100
- *
101
- * pg-pool emits `release` before it consults its private `_expired` set, so
102
- * marking the client expired here makes `_release()` destroy it instead of
103
- * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
104
- * private APIs — feature-detect and fall back to loud logging so an upstream
105
- * change degrades to observability, never to silent corruption.
106
- */
107
- function guardPoolAgainstDirtyRelease(pool, label) {
108
- pool.on("release", (err, client) => {
109
- if (err) return;
110
- const txStatus = client?._txStatus;
111
- if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
112
- const expired = pool._expired;
113
- if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
114
- expired.add(client);
115
- logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
116
- } else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
117
- });
118
- }
119
84
  /**
120
85
  * Create a Drizzle-backed Postgres connection with a production-grade
121
86
  * connection pool.
@@ -147,7 +112,6 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
147
112
  logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
148
113
  if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
149
114
  });
150
- guardPoolAgainstDirtyRelease(pool, "pg-pool");
151
115
  return {
152
116
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
153
117
  pool,
@@ -180,7 +144,6 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
180
144
  pool.on("error", (err) => {
181
145
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
182
146
  });
183
- guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
184
147
  return {
185
148
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
186
149
  pool,
@@ -210,7 +173,6 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
210
173
  pool.on("error", (err) => {
211
174
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
212
175
  });
213
- guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
214
176
  return {
215
177
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
216
178
  pool,
@@ -1846,17 +1808,12 @@ function parseIdValues(idValue, primaryKeys) {
1846
1808
  /**
1847
1809
  * The primary keys of a collection, as declared by its properties.
1848
1810
  *
1849
- * This is the only tier both sides can read, because it is the only one written
1850
- * in the config: the postgres driver can also infer keys from the Drizzle
1851
- * schema, which the browser never sees and is never sent the admin compiles
1852
- * the collection files into its own bundle rather than being served them. A key
1853
- * that lives only in the Drizzle schema is therefore invisible here, and the
1854
- * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1855
- * to add.
1856
- *
1857
- * Returns an empty array when a collection declares none, which callers must
1858
- * treat as "not addressable" rather than defaulting to `id`: guessing a key
1859
- * that is not the real one produces confidently wrong addresses.
1811
+ * The driver can also infer keys from the Drizzle schema, which the browser
1812
+ * cannot see so the server normalizes what it resolved onto the config it
1813
+ * serves (see `stampPrimaryKeys`), and this reads that back. Returns an empty
1814
+ * array when a collection declares none, which callers must treat as "not
1815
+ * addressable" rather than defaulting to `id`: guessing a key that is not the
1816
+ * real one produces confidently wrong addresses.
1860
1817
  */
1861
1818
  function getDeclaredPrimaryKeys(collection) {
1862
1819
  const properties = collection.properties;
@@ -1881,15 +1838,9 @@ function getDeclaredPrimaryKeys(collection) {
1881
1838
  * The postgres driver tries, in order: properties marked `isId`; the primary
1882
1839
  * keys of the Drizzle schema; and finally a column literally named `id`. Only
1883
1840
  * the first and last are visible in a `CollectionConfig`, which is what both
1884
- * sides share.
1885
- *
1886
- * So the two agree except on a collection that declares no `isId` and whose key
1887
- * is known only to Drizzle. There, the driver reads the real key, and this
1888
- * either resolves nothing (reported to the console by the caller) or — if the
1889
- * table happens to have an unrelated `id` property — resolves `id`, which is
1890
- * the wrong key and cannot be detected from here: the addresses look right and
1891
- * route wrong. Only the config can settle it, so the server names both cases
1892
- * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1841
+ * sides share — so a collection whose key is *only* known to Drizzle, and is
1842
+ * not named `id`, resolves to nothing here. That is reported rather than
1843
+ * guessed: inventing a key produces addresses that look right and route wrong.
1893
1844
  */
1894
1845
  function resolvePrimaryKeys(collection) {
1895
1846
  const declared = getDeclaredPrimaryKeys(collection);
@@ -2601,7 +2552,7 @@ var buildPropertyCallbacks = (properties) => {
2601
2552
  * Opt out with `disableDefaultPolicies: true` to take full responsibility for
2602
2553
  * the collection's RLS.
2603
2554
  */
2604
- var SERVER_OR_ADMIN_EXPR$1 = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2555
+ var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2605
2556
  /** Write operations that must be admin-gated by default on auth collections. */
2606
2557
  var DEFAULT_GUARDED_OPS = [
2607
2558
  "insert",
@@ -2614,7 +2565,7 @@ function isAuthCollection(collection) {
2614
2565
  return auth === true || typeof auth === "object" && auth?.enabled === true;
2615
2566
  }
2616
2567
  /** The property marked as the row id (falls back to `id`). */
2617
- function getIdPropertyName$1(collection) {
2568
+ function getIdPropertyName(collection) {
2618
2569
  for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2619
2570
  return "id";
2620
2571
  }
@@ -2634,232 +2585,30 @@ function getEffectiveSecurityRules(collection) {
2634
2585
  injected.push({
2635
2586
  name: `${tableName}_default_admin_read`,
2636
2587
  operations: ["select"],
2637
- condition: SERVER_OR_ADMIN_EXPR$1
2588
+ condition: SERVER_OR_ADMIN_EXPR
2638
2589
  });
2639
2590
  injected.push({
2640
2591
  name: `${tableName}_default_admin_write`,
2641
2592
  operations: [...DEFAULT_GUARDED_OPS],
2642
- condition: SERVER_OR_ADMIN_EXPR$1,
2643
- check: SERVER_OR_ADMIN_EXPR$1
2593
+ condition: SERVER_OR_ADMIN_EXPR,
2594
+ check: SERVER_OR_ADMIN_EXPR
2644
2595
  });
2645
2596
  if (isAuthCollection(collection)) {
2646
2597
  injected.push({
2647
2598
  name: `${tableName}_default_self_read`,
2648
2599
  operations: ["select"],
2649
- condition: policy.compare(policy.field(getIdPropertyName$1(collection)), "eq", policy.authUid())
2600
+ condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
2650
2601
  });
2651
2602
  injected.push({
2652
2603
  name: `${tableName}_require_admin_write`,
2653
2604
  mode: "restrictive",
2654
2605
  operations: [...DEFAULT_GUARDED_OPS],
2655
- condition: SERVER_OR_ADMIN_EXPR$1,
2656
- check: SERVER_OR_ADMIN_EXPR$1
2606
+ condition: SERVER_OR_ADMIN_EXPR,
2607
+ check: SERVER_OR_ADMIN_EXPR
2657
2608
  });
2658
2609
  }
2659
2610
  return [...explicit, ...injected];
2660
2611
  }
2661
- //#endregion
2662
- //#region ../common/src/util/junction-policies.ts
2663
- var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
2664
- /**
2665
- * Walk every collection's resolved relations and aggregate the junction tables
2666
- * they declare. Two collections may declare the same junction from opposite
2667
- * sides (posts→tags and tags→posts through `posts_tags`); both become
2668
- * `declaringSides` of one spec, so derived write grants consider both.
2669
- */
2670
- function resolveJunctionSpecs(collections) {
2671
- const specs = /* @__PURE__ */ new Map();
2672
- for (const collection of collections) {
2673
- const resolved = resolveCollectionRelations(collection);
2674
- for (const relation of Object.values(resolved)) {
2675
- if (!relation.through) continue;
2676
- const targetCollection = typeof relation.target === "function" ? relation.target() : void 0;
2677
- if (!targetCollection) continue;
2678
- const rawName = relation.through.table;
2679
- const table = rawName.includes(".") ? rawName.split(".").pop() : rawName;
2680
- const schema = "public";
2681
- const source = {
2682
- collection,
2683
- junctionColumn: relation.through.sourceColumn,
2684
- relation
2685
- };
2686
- const target = {
2687
- collection: targetCollection,
2688
- junctionColumn: relation.through.targetColumn
2689
- };
2690
- const existing = specs.get(table);
2691
- if (!existing) specs.set(table, {
2692
- table,
2693
- schema,
2694
- endpoints: [source, target],
2695
- declaringSides: [source]
2696
- });
2697
- else if (!existing.declaringSides.some((s) => s.collection === collection)) existing.declaringSides.push(source);
2698
- }
2699
- }
2700
- return specs;
2701
- }
2702
- /**
2703
- * A synthetic CollectionConfig standing in for the junction during policy
2704
- * compilation and naming. Its two FK columns carry explicit `columnName`s so
2705
- * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
2706
- * whatever their casing.
2707
- */
2708
- function getJunctionCollectionConfig(spec) {
2709
- const properties = {};
2710
- for (const endpoint of spec.endpoints) properties[endpoint.junctionColumn] = {
2711
- type: "string",
2712
- columnName: endpoint.junctionColumn
2713
- };
2714
- return {
2715
- slug: spec.table,
2716
- name: spec.table,
2717
- table: spec.table,
2718
- schema: spec.schema,
2719
- properties
2720
- };
2721
- }
2722
- /** The property marked as the row id (falls back to `id`). */
2723
- function getIdPropertyName(collection) {
2724
- for (const [name, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && prop.isId) return name;
2725
- return "id";
2726
- }
2727
- /** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */
2728
- function existsEndpoint(endpoint, extra) {
2729
- const correlation = policy.compare(policy.field(getIdPropertyName(endpoint.collection)), "eq", policy.outerField(endpoint.junctionColumn));
2730
- return policy.existsIn({
2731
- collection: endpoint.collection.slug,
2732
- where: extra ? policy.and(correlation, extra) : correlation
2733
- });
2734
- }
2735
- /**
2736
- * Whether a parent-rule expression keeps its meaning when moved inside the
2737
- * junction's `EXISTS` subquery — and the re-scoped copy if it does.
2738
- *
2739
- * Returns `null` when the rule cannot be embedded faithfully: `raw` SQL
2740
- * anywhere (its `{column}` placeholders would bind to the junction), or an
2741
- * `outerField` inside a nested `existsIn` (it would bind to the junction while
2742
- * the author meant the parent, and no operand can express "the middle scope").
2743
- * Top-level `outerField`s are rewritten to `field`, which is what they meant.
2744
- */
2745
- function embedParentExpression(expr, depth = 0) {
2746
- switch (expr.kind) {
2747
- case "raw": return null;
2748
- case "and":
2749
- case "or": {
2750
- const parts = [];
2751
- for (const child of expr.operands) {
2752
- const embedded = embedParentExpression(child, depth);
2753
- if (!embedded) return null;
2754
- parts.push(embedded);
2755
- }
2756
- return expr.kind === "and" ? policy.and(...parts) : policy.or(...parts);
2757
- }
2758
- case "not": {
2759
- const embedded = embedParentExpression(expr.operand, depth);
2760
- return embedded ? policy.not(embedded) : null;
2761
- }
2762
- case "existsIn": {
2763
- const where = embedParentExpression(expr.where, depth + 1);
2764
- return where ? policy.existsIn({
2765
- collection: expr.collection,
2766
- where
2767
- }) : null;
2768
- }
2769
- case "compare": {
2770
- const left = embedOperand(expr.left, depth);
2771
- const right = embedOperand(expr.right, depth);
2772
- if (!left || !right) return null;
2773
- return {
2774
- ...expr,
2775
- left,
2776
- right
2777
- };
2778
- }
2779
- default: return expr;
2780
- }
2781
- }
2782
- /** Re-scope an operand, or return `null` if its binding cannot be preserved. */
2783
- function embedOperand(operand, depth) {
2784
- if (operand.kind === "outerField") {
2785
- if (depth === 0) return policy.field(operand.name);
2786
- return null;
2787
- }
2788
- return operand;
2789
- }
2790
- /** Does the rule cover the `update` operation? */
2791
- function coversUpdate(rule) {
2792
- return getPolicyOperations(rule).some((op) => op === "update" || op === "all");
2793
- }
2794
- /**
2795
- * The full derived policy set for a junction table: the locked server/admin
2796
- * baseline, the endpoint-visibility read grant, inherited write grants, and
2797
- * inherited restrictive gates. Returns `[]` when every declaring collection set
2798
- * `disableDefaultPolicies` — the junction is then the author's to police, and
2799
- * stays locked (RLS is still enabled) until they write policies for it.
2800
- */
2801
- function getJunctionSecurityRules(spec) {
2802
- if (spec.declaringSides.every((side) => side.collection.disableDefaultPolicies)) return [];
2803
- const rules = [];
2804
- rules.push({
2805
- name: `${spec.table}_default_admin_read`,
2806
- operations: ["select"],
2807
- condition: SERVER_OR_ADMIN_EXPR
2808
- });
2809
- rules.push({
2810
- name: `${spec.table}_default_admin_write`,
2811
- operations: [
2812
- "insert",
2813
- "update",
2814
- "delete"
2815
- ],
2816
- condition: SERVER_OR_ADMIN_EXPR,
2817
- check: SERVER_OR_ADMIN_EXPR
2818
- });
2819
- rules.push({
2820
- name: `${spec.table}_default_edge_read`,
2821
- operations: ["select"],
2822
- condition: policy.and(existsEndpoint(spec.endpoints[0]), existsEndpoint(spec.endpoints[1]))
2823
- });
2824
- const writeGrants = [];
2825
- for (const side of spec.declaringSides) {
2826
- const updateRules = ((isPostgresCollectionConfig(side.collection) ? side.collection.securityRules : void 0) ?? []).filter(coversUpdate);
2827
- const permissive = updateRules.filter((r) => r.mode !== "restrictive");
2828
- const restrictive = updateRules.filter((r) => r.mode === "restrictive");
2829
- const embeddedGates = [];
2830
- let gatesEmbeddable = true;
2831
- for (const gate of restrictive) {
2832
- const using = securityRuleToConditions(gate).usingExpr;
2833
- const embedded = using ? embedParentExpression(using) : null;
2834
- if (!embedded) {
2835
- gatesEmbeddable = false;
2836
- break;
2837
- }
2838
- embeddedGates.push(embedded);
2839
- }
2840
- if (!gatesEmbeddable) continue;
2841
- const grants = [];
2842
- for (const rule of permissive) {
2843
- const using = securityRuleToConditions(rule).usingExpr;
2844
- const embedded = using ? embedParentExpression(using) : null;
2845
- if (embedded) grants.push(embedded);
2846
- }
2847
- if (grants.length === 0) continue;
2848
- const condition = embeddedGates.length > 0 ? policy.and(policy.or(...grants), ...embeddedGates) : policy.or(...grants);
2849
- writeGrants.push(existsEndpoint(side, condition));
2850
- }
2851
- if (writeGrants.length > 0) rules.push({
2852
- name: `${spec.table}_default_edge_write`,
2853
- operations: [
2854
- "insert",
2855
- "update",
2856
- "delete"
2857
- ],
2858
- condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),
2859
- check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)
2860
- });
2861
- return rules;
2862
- }
2863
2612
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
2864
2613
  (function(root, factory) {
2865
2614
  if (typeof define === "function" && define.amd) define(factory);
@@ -4144,7 +3893,7 @@ function createPrimaryKeyResolver(options) {
4144
3893
  }
4145
3894
  if (!warned.has(slug)) {
4146
3895
  warned.add(slug);
4147
- console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
3896
+ console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config.`);
4148
3897
  }
4149
3898
  return keys;
4150
3899
  };
@@ -4587,26 +4336,8 @@ function getTableForCollection(collection, registry) {
4587
4336
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
4588
4337
  return table;
4589
4338
  }
4590
- /**
4591
- * The key columns a collection's rows are addressed by.
4592
- *
4593
- * Three tiers, in order: properties marked `isId`, the primary keys of the
4594
- * drizzle schema, and finally a column literally named `id`. Only the first is
4595
- * visible to the browser, which is why a key known only to drizzle is reported
4596
- * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
4597
- *
4598
- * Returns `[]` when nothing resolves, rather than throwing. It used to open by
4599
- * resolving the table, which throws when there is none — so the `isId` tier,
4600
- * which needs no table at all, was unreachable for exactly the collections
4601
- * most likely to have no table registered. Every caller that wanted "no keys"
4602
- * to mean "no keys" had to spell that out in a try/catch.
4603
- *
4604
- * Callers that cannot proceed without a key must say so themselves, naming the
4605
- * collection: an empty array here means "this collection has no address", which
4606
- * is a different answer in a notification (broadcast a wildcard) than in a save
4607
- * (fail).
4608
- */
4609
4339
  function getPrimaryKeys(collection, registry) {
4340
+ const table = getTableForCollection(collection, registry);
4610
4341
  if (collection.properties) {
4611
4342
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
4612
4343
  fieldName: key,
@@ -4615,8 +4346,6 @@ function getPrimaryKeys(collection, registry) {
4615
4346
  }));
4616
4347
  if (idProps.length > 0) return idProps;
4617
4348
  }
4618
- const table = registry.getTable(getTableName$1(collection));
4619
- if (!table) return [];
4620
4349
  const keys = [];
4621
4350
  for (const [key, colRaw] of Object.entries(table)) {
4622
4351
  const col = colRaw;
@@ -4644,90 +4373,6 @@ function getPrimaryKeys(collection, registry) {
4644
4373
  }
4645
4374
  return keys;
4646
4375
  }
4647
- /**
4648
- * The key columns, for callers that cannot do their job without one.
4649
- *
4650
- * {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
4651
- * collection with no address. Most of this driver, though, is building a WHERE
4652
- * clause and has no meaning without a key — for those, an empty array is not an
4653
- * answer, and indexing `[0]` into it produces `Cannot read properties of
4654
- * undefined` three frames from where the real problem is. This says what is
4655
- * wrong and which collection it is wrong about.
4656
- */
4657
- function requirePrimaryKeys(collection, registry) {
4658
- const keys = getPrimaryKeys(collection, registry);
4659
- if (keys.length === 0) throw new Error(`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`);
4660
- return keys;
4661
- }
4662
- /**
4663
- * Collections whose key the *browser* cannot resolve, and what it will do
4664
- * instead.
4665
- *
4666
- * The two sides resolve keys from different evidence. This driver reads, in
4667
- * order: properties marked `isId`, the primary keys of the Drizzle schema, then
4668
- * a column literally named `id`. The admin shares the `CollectionConfig` — it
4669
- * compiles the same collection files into its bundle — but never the Drizzle
4670
- * schema, so the middle tier is invisible to it.
4671
- *
4672
- * Nothing can normalize this at runtime: the server does not serve the admin
4673
- * its collections, so a key resolved here cannot be handed over there. The
4674
- * config files are the only thing both sides read, so the fix is an edit to
4675
- * them, and the most this can do is say exactly which edit.
4676
- *
4677
- * Two shapes, and the second is the dangerous one:
4678
- *
4679
- * - No `isId`, no `id` property → the admin resolves no address, warns in the
4680
- * console, and rows cannot be opened or linked.
4681
- * - No `isId`, but an `id` property that is *not* the key → the admin addresses
4682
- * rows by `id` while this driver reads the address as the real key. Nothing
4683
- * errors: the addresses look right and route wrong.
4684
- */
4685
- function findUnresolvableKeyCollections(collections, registry) {
4686
- const findings = [];
4687
- for (const collection of collections) {
4688
- if (getDeclaredPrimaryKeys(collection).length > 0) continue;
4689
- const keys = getPrimaryKeys(collection, registry);
4690
- if (keys.length === 0) continue;
4691
- if (keys.length === 1 && keys[0].fieldName === "id") continue;
4692
- findings.push({
4693
- collection,
4694
- keys,
4695
- shadowedByIdProperty: Boolean(collection.properties?.id)
4696
- });
4697
- }
4698
- return findings;
4699
- }
4700
- /**
4701
- * Report the collections from {@link findUnresolvableKeyCollections} at boot,
4702
- * with the edit that fixes each one.
4703
- *
4704
- * Grouped by failure, not by collection: the shadowed case is a routing bug and
4705
- * the silent case is a missing feature, and they deserve different urgency.
4706
- */
4707
- function warnOnKeysTheAdminCannotResolve(collections, registry) {
4708
- const findings = findUnresolvableKeyCollections(collections, registry);
4709
- if (findings.length === 0) return;
4710
- const edit = (f) => `${f.collection.slug}: mark ${f.keys.map((k) => `\`${k.fieldName}\``).join(" and ")} with \`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
4711
- const shadowed = findings.filter((f) => f.shadowedByIdProperty);
4712
- const silent = findings.filter((f) => !f.shadowedByIdProperty);
4713
- if (shadowed.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema — but they do have a property called `id`. The admin has no way to know `id` is not the key, so it will address rows by it while this server reads the address as the real key: the links look right and route wrong. Nothing will error.\n\n" + shadowed.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
4714
- if (silent.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema, which the admin never sees. It will resolve no address for their rows, so detail links, caching and relations will not work for them:\n\n" + silent.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
4715
- }
4716
- /**
4717
- * The address of a row: derived from the collection's primary keys, because a
4718
- * row does not carry one — it is exactly its columns.
4719
- *
4720
- * Falls back to a literal `id` column, for a row that reached us from somewhere
4721
- * other than this driver. Returns `""` when there is no key and no `id` —
4722
- * callers decide what that means, since "unaddressable" is a different answer
4723
- * in a notification (broadcast a wildcard) than in a save (fail).
4724
- */
4725
- function deriveRowAddress(row, collection, registry) {
4726
- const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4727
- if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4728
- if (row.id !== void 0 && row.id !== null) return String(row.id);
4729
- return "";
4730
- }
4731
4376
  //#endregion
4732
4377
  //#region src/utils/drizzle-conditions.ts
4733
4378
  /**
@@ -5266,7 +4911,6 @@ var DrizzleConditionBuilder = class {
5266
4911
  static buildVectorSearchConditions(table, vectorSearch) {
5267
4912
  const column = table[vectorSearch.property];
5268
4913
  if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
5269
- if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
5270
4914
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
5271
4915
  const distanceFn = vectorSearch.distance || "cosine";
5272
4916
  let operator;
@@ -5350,10 +4994,12 @@ function serializeDataToServer(row, properties, collection, registry) {
5350
4994
  continue;
5351
4995
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5352
4996
  const serializedValue = serializePropertyToServer(effectiveValue, property);
4997
+ const pks = getPrimaryKeys(collection, registry);
5353
4998
  inverseRelationUpdates.push({
5354
4999
  relationKey: key,
5355
5000
  relation,
5356
- newValue: serializedValue
5001
+ newValue: serializedValue,
5002
+ currentId: row.id || buildCompositeId(row, pks)
5357
5003
  });
5358
5004
  continue;
5359
5005
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -5363,11 +5009,15 @@ function serializeDataToServer(row, properties, collection, registry) {
5363
5009
  relation,
5364
5010
  newTargetId: serializedValue
5365
5011
  });
5366
- else inverseRelationUpdates.push({
5367
- relationKey: key,
5368
- relation,
5369
- newValue: serializedValue
5370
- });
5012
+ else {
5013
+ const pks = getPrimaryKeys(collection, registry);
5014
+ inverseRelationUpdates.push({
5015
+ relationKey: key,
5016
+ relation,
5017
+ newValue: serializedValue,
5018
+ currentId: row.id || buildCompositeId(row, pks)
5019
+ });
5020
+ }
5371
5021
  continue;
5372
5022
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
5373
5023
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5767,63 +5417,6 @@ var RelationService = class {
5767
5417
  this.registry = registry;
5768
5418
  }
5769
5419
  /**
5770
- * One target row, as the {@link RelatedRow} everything here returns.
5771
- *
5772
- * Eight sites built this by hand, which is how the address came to be the
5773
- * target's first key column in all eight — one edit, eight places to miss.
5774
- *
5775
- * `resolveNested` is the one thing they did not agree on, and the
5776
- * disagreement was invisible: the single-parent fetches pass `db` and
5777
- * `registry` to `parseDataFromServer`, so the target's *own* relations get
5778
- * resolved too, while the batch paths deliberately do not — a query per
5779
- * target row is the N+1 the batching exists to avoid. Naming the parameter
5780
- * makes that a decision rather than a difference between two call sites
5781
- * nobody was comparing.
5782
- */
5783
- async toRelatedRow(targetRow, targetCollection, targetPks, options) {
5784
- const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
5785
- return {
5786
- id: buildCompositeId(targetRow, targetPks),
5787
- path: targetCollection.slug,
5788
- values
5789
- };
5790
- }
5791
- /**
5792
- * A WHERE matching any of `parentIds`, by the whole key.
5793
- *
5794
- * A single key is an `IN (…)`. A composite one cannot be: matching
5795
- * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
5796
- * share their first column each receive the other's relations. It becomes
5797
- * an OR of ANDs — one exact address per parent — which Postgres indexes the
5798
- * same way it would a multi-column key lookup.
5799
- */
5800
- parentKeyCondition(parentTable, parentPks, parentIds) {
5801
- const columnFor = (fieldName) => {
5802
- const col = parentTable[fieldName];
5803
- if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
5804
- return col;
5805
- };
5806
- if (parentPks.length === 1) {
5807
- const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
5808
- return inArray(columnFor(parentPks[0].fieldName), values);
5809
- }
5810
- return or(...parentIds.map((id) => {
5811
- const values = parseIdValues(id, parentPks);
5812
- return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
5813
- }));
5814
- }
5815
- /**
5816
- * Reject a relation that cannot express a composite-keyed parent.
5817
- *
5818
- * `localKey` and `foreignKeyOnTarget` are single column names: one column
5819
- * cannot reference a two-column key, so such a relation has no correct
5820
- * reading. Left alone it would silently match on the first key column and
5821
- * hand a tenant's rows to its neighbour — say so instead.
5822
- */
5823
- assertSingleKeyAddressable(parentCollection, parentPks, via) {
5824
- if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
5825
- }
5826
- /**
5827
5420
  * Fetch rows related to a parent row through a specific relation
5828
5421
  */
5829
5422
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5842,9 +5435,9 @@ var RelationService = class {
5842
5435
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5843
5436
  const targetCollection = relation.target();
5844
5437
  const targetTable = getTableForCollection(targetCollection, this.registry);
5845
- const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5438
+ const idInfo = getPrimaryKeys(targetCollection, this.registry);
5846
5439
  const idField = targetTable[idInfo[0].fieldName];
5847
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5440
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
5848
5441
  const parentIdInfo = parentPks[0];
5849
5442
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5850
5443
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5868,7 +5461,7 @@ var RelationService = class {
5868
5461
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5869
5462
  currentTable = joinTable;
5870
5463
  }
5871
- const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5464
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5872
5465
  query = query.where(eq(parentIdField, parsedParentId));
5873
5466
  if (options.limit) query = query.limit(options.limit);
5874
5467
  const results = await query;
@@ -5876,7 +5469,13 @@ var RelationService = class {
5876
5469
  const rows = [];
5877
5470
  for (const row of results) {
5878
5471
  const targetRow = row[targetTableName] || row;
5879
- rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5472
+ const id = targetRow[idInfo[0].fieldName];
5473
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5474
+ rows.push({
5475
+ id: id?.toString() || "",
5476
+ path: targetCollection.slug,
5477
+ values: parsedValues
5478
+ });
5880
5479
  }
5881
5480
  return rows;
5882
5481
  }
@@ -5895,7 +5494,13 @@ var RelationService = class {
5895
5494
  const rows = [];
5896
5495
  for (const row of results) {
5897
5496
  const targetRow = row[getTableName$1(targetCollection)] || row;
5898
- rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5497
+ const id = targetRow[idInfo[0].fieldName];
5498
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5499
+ rows.push({
5500
+ id: id?.toString() || "",
5501
+ path: targetCollection.slug,
5502
+ values: parsedValues
5503
+ });
5899
5504
  }
5900
5505
  return rows;
5901
5506
  }
@@ -5912,8 +5517,8 @@ var RelationService = class {
5912
5517
  }
5913
5518
  const targetCollection = relation.target();
5914
5519
  const targetTable = getTableForCollection(targetCollection, this.registry);
5915
- const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5916
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5520
+ const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5521
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
5917
5522
  const parentIdInfo = parentPks[0];
5918
5523
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5919
5524
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5932,10 +5537,9 @@ var RelationService = class {
5932
5537
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5933
5538
  const targetCollection = relation.target();
5934
5539
  const targetTable = getTableForCollection(targetCollection, this.registry);
5935
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5936
- const targetIdInfo = targetPks[0];
5540
+ const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5937
5541
  const targetIdField = targetTable[targetIdInfo.fieldName];
5938
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5542
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
5939
5543
  const parentIdInfo = parentPks[0];
5940
5544
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5941
5545
  if (!parentTable) throw new Error("Parent table not found");
@@ -5959,19 +5563,25 @@ var RelationService = class {
5959
5563
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5960
5564
  currentTable = joinTable;
5961
5565
  }
5962
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5566
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5567
+ query = query.where(inArray(parentIdField, parsedParentIds));
5963
5568
  const results = await query;
5964
5569
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5965
5570
  const resultMap = /* @__PURE__ */ new Map();
5966
5571
  for (const row of results) {
5967
5572
  const parentRow = row[getTableName$1(parentCollection)] || row;
5968
5573
  const targetRow = row[targetTableName] || row;
5969
- resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5574
+ const parentId = parentRow[parentIdInfo.fieldName];
5575
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5576
+ resultMap.set(String(parentId), {
5577
+ id: String(targetRow[targetIdInfo.fieldName]),
5578
+ path: targetCollection.slug,
5579
+ values: parsedValues
5580
+ });
5970
5581
  }
5971
5582
  return resultMap;
5972
5583
  }
5973
5584
  if (relation.direction === "owning" && relation.localKey) {
5974
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5975
5585
  const localKeyCol = parentTable[relation.localKey];
5976
5586
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5977
5587
  const fkRows = await this.db.select({
@@ -6000,11 +5610,17 @@ var RelationService = class {
6000
5610
  const resultMap = /* @__PURE__ */ new Map();
6001
5611
  for (const [parentIdStr, fkValue] of parentToFk) {
6002
5612
  const targetRow = targetById.get(String(fkValue));
6003
- if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
5613
+ if (targetRow) {
5614
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5615
+ resultMap.set(parentIdStr, {
5616
+ id: String(targetRow[targetIdInfo.fieldName]),
5617
+ path: targetCollection.slug,
5618
+ values: parsedValues
5619
+ });
5620
+ }
6004
5621
  }
6005
5622
  return resultMap;
6006
5623
  }
6007
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
6008
5624
  let query = this.db.select().from(targetTable).$dynamic();
6009
5625
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
6010
5626
  const results = await query;
@@ -6015,7 +5631,14 @@ var RelationService = class {
6015
5631
  let parentId;
6016
5632
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
6017
5633
  else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
6018
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5634
+ if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5635
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5636
+ resultMap.set(String(parentId), {
5637
+ id: String(targetRow[targetIdInfo.fieldName]),
5638
+ path: targetCollection.slug,
5639
+ values: parsedValues
5640
+ });
5641
+ }
6019
5642
  }
6020
5643
  return resultMap;
6021
5644
  }
@@ -6029,9 +5652,9 @@ var RelationService = class {
6029
5652
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
6030
5653
  const targetCollection = relation.target();
6031
5654
  const targetTable = getTableForCollection(targetCollection, this.registry);
6032
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6033
- const targetIdField = targetTable[targetPks[0].fieldName];
6034
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5655
+ const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5656
+ const targetIdField = targetTable[targetIdInfo.fieldName];
5657
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
6035
5658
  const parentIdInfo = parentPks[0];
6036
5659
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
6037
5660
  if (!parentTable) throw new Error("Parent table not found");
@@ -6053,22 +5676,27 @@ var RelationService = class {
6053
5676
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
6054
5677
  currentTable = joinTable;
6055
5678
  }
6056
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5679
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5680
+ query = query.where(inArray(parentIdField, parsedParentIds));
6057
5681
  const results = await query;
6058
5682
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
6059
5683
  const resultMap = /* @__PURE__ */ new Map();
6060
5684
  for (const row of results) {
6061
5685
  const parentRow = row[getTableName$1(parentCollection)] || row;
6062
5686
  const targetRow = row[targetTableName] || row;
6063
- const parentId = buildCompositeId(parentRow, parentPks);
5687
+ const parentId = String(parentRow[parentIdInfo.fieldName]);
5688
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6064
5689
  const arr = resultMap.get(parentId) || [];
6065
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5690
+ arr.push({
5691
+ id: String(targetRow[targetIdInfo.fieldName]),
5692
+ path: targetCollection.slug,
5693
+ values: parsedValues
5694
+ });
6066
5695
  resultMap.set(parentId, arr);
6067
5696
  }
6068
5697
  return resultMap;
6069
5698
  }
6070
5699
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6071
- this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
6072
5700
  const junctionTable = this.registry.getTable(relation.through.table);
6073
5701
  if (!junctionTable) {
6074
5702
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -6087,13 +5715,17 @@ var RelationService = class {
6087
5715
  const junctionData = row[relation.through.table] || row;
6088
5716
  const targetData = row[targetTableName] || row;
6089
5717
  const parentId = String(junctionData[relation.through.sourceColumn]);
5718
+ const parsedValues = await parseDataFromServer(targetData, targetCollection);
6090
5719
  const arr = resultMap.get(parentId) || [];
6091
- arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
5720
+ arr.push({
5721
+ id: String(targetData[targetIdInfo.fieldName]),
5722
+ path: targetCollection.slug,
5723
+ values: parsedValues
5724
+ });
6092
5725
  resultMap.set(parentId, arr);
6093
5726
  }
6094
5727
  return resultMap;
6095
5728
  }
6096
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
6097
5729
  let query = this.db.select().from(targetTable).$dynamic();
6098
5730
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
6099
5731
  const results = await query;
@@ -6106,9 +5738,14 @@ var RelationService = class {
6106
5738
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
6107
5739
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
6108
5740
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5741
+ const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6109
5742
  const key = String(parentId);
6110
5743
  const arr = resultMap.get(key) || [];
6111
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5744
+ arr.push({
5745
+ id: String(targetRow[targetIdInfo.fieldName]),
5746
+ path: targetCollection.slug,
5747
+ values: parsedValues
5748
+ });
6112
5749
  resultMap.set(key, arr);
6113
5750
  }
6114
5751
  }
@@ -6156,12 +5793,12 @@ var RelationService = class {
6156
5793
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
6157
5794
  continue;
6158
5795
  }
6159
- const parentPks = requirePrimaryKeys(collection, this.registry);
5796
+ const parentPks = getPrimaryKeys(collection, this.registry);
6160
5797
  const parentIdInfo = parentPks[0];
6161
5798
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6162
5799
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6163
5800
  if (targetEntityIds.length > 0) {
6164
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5801
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6165
5802
  const targetIdInfo = targetPks[0];
6166
5803
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6167
5804
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6181,12 +5818,12 @@ var RelationService = class {
6181
5818
  logger.warn(`Junction columns not found for relation '${key}'`);
6182
5819
  continue;
6183
5820
  }
6184
- const parentPks = requirePrimaryKeys(collection, this.registry);
5821
+ const parentPks = getPrimaryKeys(collection, this.registry);
6185
5822
  const parentIdInfo = parentPks[0];
6186
5823
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6187
5824
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6188
5825
  if (targetEntityIds.length > 0) {
6189
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5826
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6190
5827
  const targetIdInfo = targetPks[0];
6191
5828
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6192
5829
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6197,7 +5834,7 @@ var RelationService = class {
6197
5834
  } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
6198
5835
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
6199
5836
  const targetTable = getTableForCollection(targetCollection, this.registry);
6200
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5837
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6201
5838
  const targetIdInfo = targetPks[0];
6202
5839
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6203
5840
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -6205,7 +5842,7 @@ var RelationService = class {
6205
5842
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
6206
5843
  continue;
6207
5844
  }
6208
- const parentPks = requirePrimaryKeys(collection, this.registry);
5845
+ const parentPks = getPrimaryKeys(collection, this.registry);
6209
5846
  const parentIdInfo = parentPks[0];
6210
5847
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6211
5848
  if (targetEntityIds.length > 0) {
@@ -6225,9 +5862,9 @@ var RelationService = class {
6225
5862
  try {
6226
5863
  const targetCollection = relation.target();
6227
5864
  const targetTable = getTableForCollection(targetCollection, this.registry);
6228
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5865
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6229
5866
  const targetIdInfo = targetPks[0];
6230
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5867
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6231
5868
  const sourceIdInfo = sourcePks[0];
6232
5869
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
6233
5870
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -6308,12 +5945,12 @@ var RelationService = class {
6308
5945
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
6309
5946
  return;
6310
5947
  }
6311
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5948
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6312
5949
  const sourceIdInfo = sourcePks[0];
6313
5950
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6314
5951
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6315
5952
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6316
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5953
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6317
5954
  const targetIdInfo = targetPks[0];
6318
5955
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6319
5956
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6321,7 +5958,7 @@ var RelationService = class {
6321
5958
  }));
6322
5959
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
6323
5960
  } else if (newValue && !Array.isArray(newValue)) {
6324
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5961
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6325
5962
  const targetIdInfo = targetPks[0];
6326
5963
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
6327
5964
  const newLink = {
@@ -6352,12 +5989,12 @@ var RelationService = class {
6352
5989
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
6353
5990
  return;
6354
5991
  }
6355
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
5992
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6356
5993
  const sourceIdInfo = sourcePks[0];
6357
5994
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6358
5995
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6359
5996
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6360
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5997
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6361
5998
  const targetIdInfo = targetPks[0];
6362
5999
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6363
6000
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6378,12 +6015,12 @@ var RelationService = class {
6378
6015
  const { relation, newTargetId } = upd;
6379
6016
  const targetCollection = relation.target();
6380
6017
  const targetTable = getTableForCollection(targetCollection, this.registry);
6381
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6018
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6382
6019
  const targetIdInfo = targetPks[0];
6383
6020
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6384
6021
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
6385
6022
  const parentTable = getTableForCollection(parentCollection, this.registry);
6386
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
6023
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
6387
6024
  const parentIdInfo = parentPks[0];
6388
6025
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
6389
6026
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -6454,7 +6091,7 @@ var RelationService = class {
6454
6091
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
6455
6092
  return;
6456
6093
  }
6457
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6094
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
6458
6095
  const targetIdInfo = targetPks[0];
6459
6096
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
6460
6097
  const junctionData = {
@@ -6470,150 +6107,6 @@ var RelationService = class {
6470
6107
  }
6471
6108
  };
6472
6109
  //#endregion
6473
- //#region src/services/row-pipeline.ts
6474
- /**
6475
- * Whether a many-relation reaches its target through a junction table.
6476
- *
6477
- * Also used to build the drizzle `with` config, which is why it is exported:
6478
- * the query has to nest one level deeper for a junction, and the row walk has
6479
- * to unwrap that same level back out.
6480
- */
6481
- function isJunctionRelation(relation) {
6482
- if (relation.through) return true;
6483
- if (relation.joinPath && relation.joinPath.length > 1) return true;
6484
- return false;
6485
- }
6486
- /**
6487
- * Reach the target row inside a junction row.
6488
- *
6489
- * A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
6490
- * the foreign keys, and the target nested under one of them. The target is the
6491
- * only object among them, so that is how it is found. A junction row that has
6492
- * not been nested (no `with` on the join) has no object and is returned as-is.
6493
- */
6494
- function unwrapJunctionRow(item) {
6495
- const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
6496
- return nestedKey ? item[nestedKey] : item;
6497
- }
6498
- /**
6499
- * Give back the number a `number` property was declared to be.
6500
- *
6501
- * Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
6502
- * numeric can hold more precision than a double. But the collection declared
6503
- * this property `number` and the OpenAPI spec this server publishes says
6504
- * `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
6505
- * asymmetrically, because a create answers with the number and the read that
6506
- * follows answers with the string. Any client that multiplies a price works
6507
- * until the first refresh.
6508
- *
6509
- * Declaring a property `number` already accepts double precision — that is what
6510
- * the admin has always parsed it to. Columns REST serves that no property
6511
- * declares are left exactly as the database returned them.
6512
- */
6513
- function coerceDeclaredNumber(value, property) {
6514
- if (property?.type !== "number" || typeof value !== "string") return value;
6515
- const parsed = parseFloat(value);
6516
- return isNaN(parsed) ? null : parsed;
6517
- }
6518
- /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
6519
- function coerceDeclaredNumbers(row, collection) {
6520
- const properties = collection.properties;
6521
- if (!properties) return row;
6522
- const out = {};
6523
- for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
6524
- return out;
6525
- }
6526
- /** Render one target row in the requested style. */
6527
- /**
6528
- * Drop every column the collection marked `excludeFromApi`.
6529
- *
6530
- * Password hashes and verification tokens have to be readable server-side but
6531
- * must never reach a client — and "never" has to mean every exit from this
6532
- * pipeline, including relation targets, or a secret leaks through whichever
6533
- * path was overlooked. Keyed by both the property name and its column name,
6534
- * since a row can arrive keyed either way depending on the caller.
6535
- */
6536
- function stripExcluded(row, collection) {
6537
- const properties = collection.properties;
6538
- if (!properties) return row;
6539
- for (const [key, property] of Object.entries(properties)) {
6540
- if (!property?.excludeFromApi) continue;
6541
- delete row[key];
6542
- if (property.columnName) delete row[property.columnName];
6543
- }
6544
- return row;
6545
- }
6546
- function renderTarget(targetRow, targetCollection, style, registry) {
6547
- if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
6548
- const address = relationTargetAddress(targetRow, targetCollection, registry);
6549
- const path = targetCollection.slug;
6550
- return createRelationRefWithData(address, path, {
6551
- id: address,
6552
- path,
6553
- values: normalizeDbValues(targetRow, targetCollection)
6554
- });
6555
- }
6556
- /**
6557
- * The address a relation ref points at.
6558
- *
6559
- * The whole key, not its first column: a composite-keyed target addressed by
6560
- * `tenant_id` alone points at every row that shares it. A target whose key
6561
- * cannot be resolved at all used to throw here — reading `[0]` of an empty
6562
- * array — taking down the parent's fetch over a relation it may not even have
6563
- * asked for. The first column is a guess, but a ref that resolves to nothing
6564
- * beats no rows at all.
6565
- */
6566
- function relationTargetAddress(targetRow, targetCollection, registry) {
6567
- const address = deriveRowAddress(targetRow, targetCollection, registry);
6568
- if (address) return address;
6569
- return String(targetRow[Object.keys(targetRow)[0]] ?? "");
6570
- }
6571
- /**
6572
- * The row the admin renders: every column, with relations as references.
6573
- *
6574
- * Values are normalized (dates, numbers, NaN) because the admin's view-model
6575
- * expects real types. The row's own address is *not* among the columns — it is
6576
- * derived by the consumer from the collection's primary keys.
6577
- */
6578
- function toCmsRow(row, collection, registry) {
6579
- const resolvedRelations = resolveCollectionRelations(collection);
6580
- const normalized = normalizeDbValues(row, collection);
6581
- for (const [key, relation] of Object.entries(resolvedRelations)) {
6582
- const relData = row[relation.relationName || key];
6583
- if (relData === void 0 || relData === null) continue;
6584
- if (relation.cardinality === "many" && Array.isArray(relData)) {
6585
- const targetCollection = relation.target();
6586
- normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
6587
- } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
6588
- }
6589
- return stripExcluded(normalized, collection);
6590
- }
6591
- /**
6592
- * The row REST serves: every column under its own name, with the value Postgres
6593
- * returned, and relations inlined as the target's columns.
6594
- *
6595
- * Values are the ones the database returned, except where that contradicts the
6596
- * declared type: a `number` property is served as a number (see
6597
- * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
6598
- * JSON has its own opinions about dates that the admin's view-model does not
6599
- * share.
6600
- *
6601
- * Keyed by the row rather than by the relation list — a REST fetch only loads
6602
- * the relations `include` asked for, so the row is the authority on which are
6603
- * actually there.
6604
- */
6605
- function toRestRow(row, collection, registry) {
6606
- const resolvedRelations = resolveCollectionRelations(collection);
6607
- const flat = {};
6608
- for (const [key, value] of Object.entries(row)) {
6609
- const relation = findRelation(resolvedRelations, key);
6610
- if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
6611
- else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
6612
- else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
6613
- }
6614
- return stripExcluded(flat, collection);
6615
- }
6616
- //#endregion
6617
6110
  //#region src/services/FetchService.ts
6618
6111
  /**
6619
6112
  * Service for handling all row read operations.
@@ -6672,7 +6165,7 @@ var FetchService = class {
6672
6165
  if (!shouldInclude(key)) continue;
6673
6166
  const drizzleRelName = relation.relationName || key;
6674
6167
  if (relation.joinPath && relation.joinPath.length > 0) continue;
6675
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
6168
+ if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6676
6169
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
6677
6170
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
6678
6171
  else withConfig[drizzleRelName] = true;
@@ -6681,6 +6174,14 @@ var FetchService = class {
6681
6174
  return withConfig;
6682
6175
  }
6683
6176
  /**
6177
+ * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
6178
+ */
6179
+ isJunctionRelation(relation, _collection) {
6180
+ if (relation.through) return true;
6181
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
6182
+ return false;
6183
+ }
6184
+ /**
6684
6185
  * Get the Drizzle relation name on the junction table that points to the actual target row.
6685
6186
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
6686
6187
  */
@@ -6689,6 +6190,54 @@ var FetchService = class {
6689
6190
  return null;
6690
6191
  }
6691
6192
  /**
6193
+ * Convert a db.query result row (with nested relation objects) to a flat row.
6194
+ * Handles:
6195
+ * - Type normalization (dates, numbers, NaN) via normalizeDbValues
6196
+ * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
6197
+ * - Flattening junction-table many-to-many results
6198
+ *
6199
+ * The row's own address is not among them: it is derived by the consumer
6200
+ * from the collection's primary keys.
6201
+ */
6202
+ drizzleResultToRow(row, collection) {
6203
+ const resolvedRelations = resolveCollectionRelations(collection);
6204
+ const normalizedValues = normalizeDbValues(row, collection);
6205
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
6206
+ const relData = row[relation.relationName || key];
6207
+ if (relData === void 0 || relData === null) continue;
6208
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
6209
+ const targetCollection = relation.target();
6210
+ const targetPath = targetCollection.slug;
6211
+ const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
6212
+ normalizedValues[key] = relData.map((item) => {
6213
+ let targetRow = item;
6214
+ if (this.isJunctionRelation(relation, collection)) {
6215
+ const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6216
+ if (nestedKey) targetRow = item[nestedKey];
6217
+ }
6218
+ const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
6219
+ return createRelationRefWithData(relId, targetPath, {
6220
+ id: relId,
6221
+ path: targetPath,
6222
+ values: normalizeDbValues(targetRow, targetCollection)
6223
+ });
6224
+ });
6225
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
6226
+ const targetCollection = relation.target();
6227
+ const targetPath = targetCollection.slug;
6228
+ const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
6229
+ const relObj = relData;
6230
+ const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
6231
+ normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
6232
+ id: relId,
6233
+ path: targetPath,
6234
+ values: normalizeDbValues(relObj, targetCollection)
6235
+ });
6236
+ }
6237
+ }
6238
+ return normalizedValues;
6239
+ }
6240
+ /**
6692
6241
  * Post-fetch joinPath relations for a single flat row.
6693
6242
  * joinPath relations cannot be expressed via Drizzle's `with` config,
6694
6243
  * so they must be loaded separately after the primary query.
@@ -6709,6 +6258,31 @@ var FetchService = class {
6709
6258
  await Promise.all(promises);
6710
6259
  }
6711
6260
  /**
6261
+ * Post-fetch joinPath relations for a batch of flat rows.
6262
+ * Uses batch fetching to avoid N+1 queries for list views.
6263
+ */
6264
+ async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
6265
+ if (rows.length === 0) return;
6266
+ const resolvedRelations = resolveCollectionRelations(collection);
6267
+ const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
6268
+ if (joinPathRelations.length === 0) return;
6269
+ for (const [key, relation] of joinPathRelations) try {
6270
+ const rowIds = rows.map((r) => {
6271
+ return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
6272
+ });
6273
+ const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
6274
+ for (const row of rows) {
6275
+ const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
6276
+ const relatedRow = resultMap.get(String(id));
6277
+ if (relatedRow) {
6278
+ if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
6279
+ }
6280
+ }
6281
+ } catch (e) {
6282
+ logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
6283
+ }
6284
+ }
6285
+ /**
6712
6286
  * Resolves joinPath relations for raw REST rows and directly injects them.
6713
6287
  * Uses RelationService to query the database and maps results back to the flattened objects.
6714
6288
  */
@@ -6719,29 +6293,63 @@ var FetchService = class {
6719
6293
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
6720
6294
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
6721
6295
  if (joinPathRelations.length === 0) return;
6722
- const parentIdOf = (row) => {
6723
- const address = buildCompositeId(row, idInfoArray);
6724
- return address && address.split(":::").some((part) => part !== "") ? address : void 0;
6725
- };
6296
+ const idInfo = idInfoArray[0];
6726
6297
  for (const [key, relation] of joinPathRelations) try {
6727
- const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
6728
- if (addressable.length === 0) continue;
6729
- const rowIds = addressable.map((r) => parentIdOf(r));
6298
+ const rowIds = rows.map((r) => {
6299
+ return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
6300
+ });
6730
6301
  if (relation.cardinality === "one") {
6731
6302
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
6732
- for (const row of addressable) {
6733
- const relatedRow = resultMap.get(String(parentIdOf(row)));
6734
- row[key] = relatedRow ? { ...relatedRow.values } : null;
6303
+ for (const row of rows) {
6304
+ const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
6305
+ const relatedRow = resultMap.get(String(id));
6306
+ if (relatedRow) row[key] = {
6307
+ ...relatedRow.values,
6308
+ id: relatedRow.id
6309
+ };
6310
+ else row[key] = null;
6735
6311
  }
6736
6312
  } else if (relation.cardinality === "many") {
6737
6313
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
6738
- for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
6314
+ for (const row of rows) {
6315
+ const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
6316
+ row[key] = (resultMap.get(String(id)) || []).map((e) => ({
6317
+ ...e.values,
6318
+ id: e.id
6319
+ }));
6320
+ }
6739
6321
  }
6740
6322
  } catch (e) {
6741
6323
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
6742
6324
  }
6743
6325
  }
6744
6326
  /**
6327
+ * Convert a db.query result row to a flat REST-style row with populated relations.
6328
+ *
6329
+ * Every column is copied through under its own name, with the value Postgres
6330
+ * returned. This used to open with a synthesized `id` and then skip the key
6331
+ * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
6332
+ * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
6333
+ * need an address derive it from the collection's primary keys.
6334
+ */
6335
+ drizzleResultToRestRow(row, collection) {
6336
+ const flat = {};
6337
+ const resolvedRelations = resolveCollectionRelations(collection);
6338
+ for (const [k, v] of Object.entries(row)) {
6339
+ const relation = findRelation(resolvedRelations, k);
6340
+ if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6341
+ if (this.isJunctionRelation(relation, collection)) {
6342
+ const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6343
+ if (nestedKey) return { ...item[nestedKey] };
6344
+ }
6345
+ return { ...item };
6346
+ });
6347
+ else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
6348
+ else flat[k] = v;
6349
+ }
6350
+ return flat;
6351
+ }
6352
+ /**
6745
6353
  * Build db.query-compatible options from standard fetch options.
6746
6354
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
6747
6355
  */
@@ -6811,7 +6419,7 @@ var FetchService = class {
6811
6419
  async fetchOne(collectionPath, id, databaseId) {
6812
6420
  const collection = getCollectionByPath(collectionPath, this.registry);
6813
6421
  const table = getTableForCollection(collection, this.registry);
6814
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6422
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
6815
6423
  const idInfo = idInfoArray[0];
6816
6424
  const idField = table[idInfo.fieldName];
6817
6425
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6825,7 +6433,7 @@ var FetchService = class {
6825
6433
  with: withConfig
6826
6434
  });
6827
6435
  if (!row) return void 0;
6828
- const flatRow = toCmsRow(row, collection, this.registry);
6436
+ const flatRow = this.drizzleResultToRow(row, collection);
6829
6437
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6830
6438
  return flatRow;
6831
6439
  } catch (e) {
@@ -6867,7 +6475,7 @@ var FetchService = class {
6867
6475
  async fetchRowsWithConditions(collectionPath, options = {}) {
6868
6476
  const collection = getCollectionByPath(collectionPath, this.registry);
6869
6477
  const table = getTableForCollection(collection, this.registry);
6870
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6478
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
6871
6479
  const idInfo = idInfoArray[0];
6872
6480
  const idField = table[idInfo.fieldName];
6873
6481
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6877,7 +6485,7 @@ var FetchService = class {
6877
6485
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6878
6486
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6879
6487
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6880
- return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
6488
+ return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
6881
6489
  } catch (e) {
6882
6490
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6883
6491
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6938,10 +6546,8 @@ var FetchService = class {
6938
6546
  }
6939
6547
  /**
6940
6548
  * Fallback path used when db.query is unavailable.
6941
- *
6942
- * The primary path runs the results through `toCmsRow`, which maps
6943
- * relations from what drizzle already nested — no query per row. This one
6944
- * has no nesting to read, so it resolves relations itself, in batches.
6549
+ * The primary path uses drizzleResultToRow which handles relation
6550
+ * mapping without N+1 queries.
6945
6551
  *
6946
6552
  * Process raw database results into flat rows with relations.
6947
6553
  */
@@ -7021,7 +6627,10 @@ var FetchService = class {
7021
6627
  const relationKey = pathSegments[i];
7022
6628
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
7023
6629
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
7024
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6630
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
6631
+ ...row.values,
6632
+ id: row.id
6633
+ }));
7025
6634
  if (i + 1 < pathSegments.length) {
7026
6635
  const nextEntityId = pathSegments[i + 1];
7027
6636
  currentCollection = relation.target();
@@ -7083,7 +6692,7 @@ var FetchService = class {
7083
6692
  if (value === void 0 || value === null) return true;
7084
6693
  const collection = getCollectionByPath(collectionPath, this.registry);
7085
6694
  const table = getTableForCollection(collection, this.registry);
7086
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6695
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
7087
6696
  const idInfo = idInfoArray[0];
7088
6697
  const idField = table[idInfo.fieldName];
7089
6698
  const field = table[fieldName];
@@ -7110,7 +6719,7 @@ var FetchService = class {
7110
6719
  async fetchCollectionForRest(collectionPath, options = {}, include) {
7111
6720
  const collection = getCollectionByPath(collectionPath, this.registry);
7112
6721
  const table = getTableForCollection(collection, this.registry);
7113
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6722
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
7114
6723
  const idInfo = idInfoArray[0];
7115
6724
  const idField = table[idInfo.fieldName];
7116
6725
  const tableName = getTableName(table);
@@ -7118,7 +6727,7 @@ var FetchService = class {
7118
6727
  if (qb && !options.searchString && !options.vectorSearch) try {
7119
6728
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
7120
6729
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
7121
- const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
6730
+ const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
7122
6731
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
7123
6732
  return restRows;
7124
6733
  } catch (e) {
@@ -7167,7 +6776,7 @@ var FetchService = class {
7167
6776
  async fetchOneForRest(collectionPath, id, include, databaseId) {
7168
6777
  const collection = getCollectionByPath(collectionPath, this.registry);
7169
6778
  const table = getTableForCollection(collection, this.registry);
7170
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
6779
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
7171
6780
  const idInfo = idInfoArray[0];
7172
6781
  const idField = table[idInfo.fieldName];
7173
6782
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -7180,7 +6789,7 @@ var FetchService = class {
7180
6789
  ...withConfig ? { with: withConfig } : {}
7181
6790
  });
7182
6791
  if (!row) return null;
7183
- const restRow = toRestRow(row, collection, this.registry);
6792
+ const restRow = this.drizzleResultToRestRow(row, collection);
7184
6793
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
7185
6794
  return restRow;
7186
6795
  } catch (e) {
@@ -7225,7 +6834,7 @@ var FetchService = class {
7225
6834
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
7226
6835
  const collection = getCollectionByPath(collectionPath, this.registry);
7227
6836
  const table = getTableForCollection(collection, this.registry);
7228
- const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
6837
+ const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7229
6838
  let vectorMeta;
7230
6839
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
7231
6840
  let query = vectorMeta ? this.db.select({
@@ -7714,7 +7323,7 @@ var PersistService = class {
7714
7323
  } catch (error) {
7715
7324
  throw this.toUserFriendlyError(error, collection.slug);
7716
7325
  }
7717
- const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7326
+ const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7718
7327
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7719
7328
  return finalEntity;
7720
7329
  }
@@ -8633,14 +8242,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8633
8242
  let updatedValues = values;
8634
8243
  const contextForCallback = this.buildCallContext();
8635
8244
  let previousValuesForHistory;
8636
- if (status === "existing" && id) try {
8637
- const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
8245
+ if (status === "existing" && id) {
8246
+ const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8638
8247
  if (existing) {
8639
8248
  const { id: _existingId, ...existingValues } = existing;
8640
8249
  previousValuesForHistory = existingValues;
8641
8250
  }
8642
- } catch (err) {
8643
- logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8644
8251
  }
8645
8252
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8646
8253
  if (globalCallbacks?.beforeSave) {
@@ -8708,8 +8315,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8708
8315
  context: contextForCallback
8709
8316
  });
8710
8317
  }
8711
- const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8712
- const savedValues = savedRow;
8318
+ const savedId = savedRow.id;
8319
+ const { id: _savedId, ...savedValues } = savedRow;
8713
8320
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
8714
8321
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
8715
8322
  collection: resolvedCollection,
@@ -8741,7 +8348,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8741
8348
  }
8742
8349
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8743
8350
  tableName: path,
8744
- id: savedId,
8351
+ id: savedId.toString(),
8745
8352
  action: status === "new" ? "create" : "update",
8746
8353
  values: savedValues,
8747
8354
  previousValues: previousValuesForHistory,
@@ -8749,11 +8356,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8749
8356
  });
8750
8357
  if (this._deferNotifications) this._pendingNotifications.push({
8751
8358
  path,
8752
- id: savedId,
8359
+ id: savedId.toString(),
8753
8360
  row: savedRow,
8754
8361
  databaseId: resolvedCollection?.databaseId
8755
8362
  });
8756
- else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
8363
+ else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8757
8364
  return savedRow;
8758
8365
  } catch (error) {
8759
8366
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8833,7 +8440,10 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8833
8440
  }
8834
8441
  async delete({ row, collection }) {
8835
8442
  const targetPath = row.path;
8836
- const targetRow = { ...row.values ?? {} };
8443
+ const targetRow = {
8444
+ id: row.id,
8445
+ ...row.values ?? {}
8446
+ };
8837
8447
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8838
8448
  const contextForCallback = this.buildCallContext();
8839
8449
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -9253,7 +8863,6 @@ var DatabasePoolManager = class {
9253
8863
  pool.on("error", (err) => {
9254
8864
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9255
8865
  });
9256
- guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9257
8866
  this.pools.set(databaseName, pool);
9258
8867
  return pool;
9259
8868
  }
@@ -9782,7 +9391,6 @@ var generateSchema = async (collections, stripPolicies = false) => {
9782
9391
  });
9783
9392
  });
9784
9393
  schemaContent += "\n";
9785
- const junctionSpecs = resolveJunctionSpecs(collections);
9786
9394
  for (const collection of collections) {
9787
9395
  const tableName = getTableName$1(collection);
9788
9396
  if (tableName) allTablesToGenerate.set(tableName, { collection });
@@ -9816,17 +9424,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
9816
9424
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
9817
9425
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
9818
9426
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
9819
- schemaContent += "}, (table) => ([\n";
9820
- schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
9821
- const junctionSpec = junctionSpecs.get(baseTableName);
9822
- if (!stripPolicies && junctionSpec) {
9823
- const junctionCollection = getJunctionCollectionConfig(junctionSpec);
9824
- const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
9825
- getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
9826
- schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
9827
- });
9828
- }
9829
- schemaContent += "])).enableRLS();\n\n";
9427
+ schemaContent += "}, (table) => ({\n";
9428
+ schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
9429
+ schemaContent += "}));\n\n";
9830
9430
  } else if (!isJunction) {
9831
9431
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
9832
9432
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -10517,7 +10117,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10517
10117
  startAfter: request.startAfter,
10518
10118
  searchString: request.searchString
10519
10119
  }, authContext);
10520
- this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
10120
+ this.sendCollectionUpdate(clientId, subscriptionId, rows);
10521
10121
  } catch (error) {
10522
10122
  const sanitized = sanitizeErrorForClient(error, request.path);
10523
10123
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10618,7 +10218,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10618
10218
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
10619
10219
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
10620
10220
  else if (subscription.type === "collection" && subscription.collectionRequest) {
10621
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
10221
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10622
10222
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
10623
10223
  }
10624
10224
  } catch (error) {
@@ -10648,7 +10248,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10648
10248
  if (!this._subscriptions.has(subscriptionId)) return;
10649
10249
  try {
10650
10250
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
10651
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
10251
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10652
10252
  } catch (error) {
10653
10253
  const sanitized = sanitizeErrorForClient(error, notifyPath);
10654
10254
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10862,12 +10462,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10862
10462
  }
10863
10463
  return await this.dataService.fetchOne(notifyPath, id);
10864
10464
  }
10865
- sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10465
+ sendCollectionUpdate(clientId, subscriptionId, rows) {
10866
10466
  const message = {
10867
10467
  type: "collection_update",
10868
10468
  subscriptionId,
10869
- rows,
10870
- pks: this.primaryKeysForPath(path)
10469
+ rows
10871
10470
  };
10872
10471
  this.sendMessage(clientId, message);
10873
10472
  }
@@ -10882,33 +10481,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10882
10481
  /**
10883
10482
  * Send a lightweight row-level patch to a collection subscriber.
10884
10483
  * The client can merge this into its cached data for instant feedback.
10885
- *
10886
- * The key columns ride along: the patch names a row by address, and the
10887
- * client has to find that row among the ones it cached — which carry
10888
- * columns and no address. The SDK holds no collection config to derive one
10889
- * from, so this is the only place the mapping can come from.
10890
10484
  */
10891
- sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10485
+ sendCollectionPatch(clientId, subscriptionId, id, row) {
10892
10486
  const message = {
10893
10487
  type: "collection_patch",
10894
10488
  subscriptionId,
10895
10489
  id,
10896
- row,
10897
- pks: this.primaryKeysForPath(notifyPath)
10490
+ row
10898
10491
  };
10899
10492
  this.sendMessage(clientId, message);
10900
10493
  }
10901
- /** The key columns of the collection at `path`, if they can be resolved. */
10902
- primaryKeysForPath(path) {
10903
- try {
10904
- const collection = this.registry.getCollectionByPath(path);
10905
- if (!collection) return void 0;
10906
- const keys = getPrimaryKeys(collection, this.registry);
10907
- return keys.length > 0 ? keys : void 0;
10908
- } catch {
10909
- return;
10910
- }
10911
- }
10912
10494
  sendError(clientId, error, subscriptionId, code) {
10913
10495
  const message = {
10914
10496
  type: "error",
@@ -11156,7 +10738,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11156
10738
  }
11157
10739
  /** Compute the canonical (possibly composite) id string from a captured row. */
11158
10740
  extractIdFromCdcRow(collection, row) {
11159
- return deriveRowAddress(row, collection, this.registry) || "*";
10741
+ try {
10742
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
10743
+ if (composite && composite !== ":::") return composite;
10744
+ } catch {}
10745
+ if (row.id !== void 0 && row.id !== null) return String(row.id);
10746
+ return "*";
11160
10747
  }
11161
10748
  dedupKey(path, id, databaseId) {
11162
10749
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -20616,33 +20203,6 @@ function formatBytes(bytes) {
20616
20203
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
20617
20204
  }
20618
20205
  //#endregion
20619
- //#region src/collections/buildRegistry.ts
20620
- /**
20621
- * Build the collection registry for a driver.
20622
- *
20623
- * The order matters and is the reason this is one function rather than a run of
20624
- * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
20625
- * anything that inspects them has to run *after* the tables are registered —
20626
- * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
20627
- * collection whose table it cannot look up is one it has nothing to say about.
20628
- * Warned too early, it would skip every collection and report nothing, which
20629
- * reads exactly like having nothing to report.
20630
- */
20631
- function buildCollectionRegistry(schema) {
20632
- const registry = new PostgresCollectionRegistry();
20633
- if (schema.collections) {
20634
- registry.registerMultiple(schema.collections);
20635
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
20636
- }
20637
- if (schema.tables) Object.values(schema.tables).forEach((table) => {
20638
- if (isTable(table)) registry.registerTable(table, getTableName(table));
20639
- });
20640
- if (schema.enums) registry.registerEnums(schema.enums);
20641
- if (schema.relations) registry.registerRelations(schema.relations);
20642
- warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
20643
- return registry;
20644
- }
20645
- //#endregion
20646
20206
  //#region src/auth/ensure-tables.ts
20647
20207
  /**
20648
20208
  * Auto-create auth tables if they don't exist.
@@ -20814,22 +20374,14 @@ async function ensureAuthTablesExist(db, collection) {
20814
20374
  $$ LANGUAGE sql STABLE
20815
20375
  `);
20816
20376
  });
20817
- for (const columnDef of [
20818
- "display_name VARCHAR(255)",
20819
- "photo_url VARCHAR(500)",
20820
- "roles TEXT[] DEFAULT '{}' NOT NULL",
20821
- "password_hash VARCHAR(255)",
20822
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
20823
- "email_verification_token VARCHAR(255)",
20824
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
20825
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
20826
- "metadata JSONB DEFAULT '{}' NOT NULL",
20827
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
20828
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
20829
- ]) await db.execute(sql`
20830
- ALTER TABLE ${sql.raw(usersTableName)}
20831
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
20832
- `);
20377
+ await db.execute(sql`
20378
+ ALTER TABLE ${sql.raw(usersTableName)}
20379
+ ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
20380
+ `);
20381
+ await db.execute(sql`
20382
+ ALTER TABLE ${sql.raw(usersTableName)}
20383
+ ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20384
+ `);
20833
20385
  try {
20834
20386
  if ((await db.execute(sql`
20835
20387
  SELECT EXISTS (
@@ -20900,34 +20452,6 @@ async function ensureAuthTablesExist(db, collection) {
20900
20452
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20901
20453
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20902
20454
  `);
20903
- try {
20904
- const authTablePairs = [
20905
- [usersSchema, resolvedTable],
20906
- [authSchema, "user_identities"],
20907
- [authSchema, "refresh_tokens"],
20908
- [authSchema, "password_reset_tokens"],
20909
- [authSchema, "app_config"],
20910
- [authSchema, "mfa_factors"],
20911
- [authSchema, "mfa_challenges"],
20912
- [authSchema, "recovery_codes"]
20913
- ];
20914
- for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
20915
- SELECT 1
20916
- FROM pg_class c
20917
- JOIN pg_namespace n ON n.oid = c.relnamespace
20918
- WHERE n.nspname = ${schemaName}
20919
- AND c.relname = ${tableName}
20920
- AND c.relforcerowsecurity
20921
- `)).rows.length > 0) {
20922
- await db.execute(sql`
20923
- ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
20924
- NO FORCE ROW LEVEL SECURITY
20925
- `);
20926
- logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
20927
- }
20928
- } catch (rlsReconcileError) {
20929
- logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
20930
- }
20931
20455
  logger.info("✅ Auth tables ready");
20932
20456
  } catch (error) {
20933
20457
  logger.error("❌ Failed to create auth tables", { error });
@@ -20975,31 +20499,6 @@ var UserService = class {
20975
20499
  const name = getTableName(this.usersTable);
20976
20500
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20977
20501
  }
20978
- /**
20979
- * Run a privileged auth write with an explicitly cleared RLS context.
20980
- *
20981
- * The auth services run on the base/owner connection, which by design
20982
- * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
20983
- * in the default policies applies. That NULL is normally guaranteed by
20984
- * `set_config(..., is_local = true)` resetting at transaction end — but a
20985
- * GUC that survives on a pooled connection (or a connection role that
20986
- * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
20987
- * turns the trusted write into an RLS-scoped one and denies it with
20988
- * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
20989
- * single chokepoint, makes the server context deterministic instead of
20990
- * trusting whatever state the pool hands us. `auth.uid()` reads '' as
20991
- * NULL via NULLIF, so '' is the server context.
20992
- */
20993
- async withServerContext(fn) {
20994
- return await this.db.transaction(async (tx) => {
20995
- await tx.execute(sql`
20996
- SELECT set_config('app.user_id', '', true),
20997
- set_config('app.user_roles', '', true),
20998
- set_config('app.jwt', '', true)
20999
- `);
21000
- return await fn(tx);
21001
- });
21002
- }
21003
20502
  mapRowToUser(row) {
21004
20503
  if (!row) return row;
21005
20504
  const id = row.id ?? row.uid;
@@ -21097,7 +20596,7 @@ var UserService = class {
21097
20596
  }
21098
20597
  async createUser(data) {
21099
20598
  const payload = this.mapPayload(data);
21100
- const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20599
+ const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21101
20600
  return this.mapRowToUser(row);
21102
20601
  }
21103
20602
  async getUserById(id) {
@@ -21136,12 +20635,12 @@ var UserService = class {
21136
20635
  }));
21137
20636
  }
21138
20637
  async linkUserIdentity(userId, provider, providerId, profileData) {
21139
- await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
20638
+ await this.db.insert(this.userIdentitiesTable).values({
21140
20639
  userId,
21141
20640
  provider,
21142
20641
  providerId,
21143
20642
  profileData: profileData || null
21144
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20643
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21145
20644
  }
21146
20645
  async updateUser(id, data) {
21147
20646
  const idCol = getColumn(this.usersTable, "id");
@@ -21149,13 +20648,13 @@ var UserService = class {
21149
20648
  const payload = this.mapPayload(data);
21150
20649
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21151
20650
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
21152
- const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
20651
+ const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21153
20652
  return row ? this.mapRowToUser(row) : null;
21154
20653
  }
21155
20654
  async deleteUser(id) {
21156
20655
  const idCol = getColumn(this.usersTable, "id");
21157
20656
  if (!idCol) return;
21158
- await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
20657
+ await this.db.delete(this.usersTable).where(eq(idCol, id));
21159
20658
  }
21160
20659
  async listUsers() {
21161
20660
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -21209,10 +20708,10 @@ var UserService = class {
21209
20708
  if (!idCol) return;
21210
20709
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
21211
20710
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21212
- await this.withServerContext(async (db) => db.update(this.usersTable).set({
20711
+ await this.db.update(this.usersTable).set({
21213
20712
  [passwordHashColKey]: passwordHash,
21214
20713
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21215
- }).where(eq(idCol, id)));
20714
+ }).where(eq(idCol, id));
21216
20715
  }
21217
20716
  /**
21218
20717
  * Set email verification status
@@ -21223,11 +20722,11 @@ var UserService = class {
21223
20722
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
21224
20723
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21225
20724
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21226
- await this.withServerContext(async (db) => db.update(this.usersTable).set({
20725
+ await this.db.update(this.usersTable).set({
21227
20726
  [emailVerifiedColKey]: verified,
21228
20727
  [emailVerificationTokenColKey]: null,
21229
20728
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21230
- }).where(eq(idCol, id)));
20729
+ }).where(eq(idCol, id));
21231
20730
  }
21232
20731
  /**
21233
20732
  * Set email verification token
@@ -21238,11 +20737,11 @@ var UserService = class {
21238
20737
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21239
20738
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
21240
20739
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21241
- await this.withServerContext(async (db) => db.update(this.usersTable).set({
20740
+ await this.db.update(this.usersTable).set({
21242
20741
  [emailVerificationTokenColKey]: token,
21243
20742
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
21244
20743
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21245
- }).where(eq(idCol, id)));
20744
+ }).where(eq(idCol, id));
21246
20745
  }
21247
20746
  /**
21248
20747
  * Find user by email verification token
@@ -21287,22 +20786,22 @@ var UserService = class {
21287
20786
  async setUserRoles(userId, roleIds) {
21288
20787
  const usersTableName = this.getQualifiedUsersTableName();
21289
20788
  const rolesArray = `{${roleIds.join(",")}}`;
21290
- await this.withServerContext(async (db) => db.execute(sql`
20789
+ await this.db.execute(sql`
21291
20790
  UPDATE ${sql.raw(usersTableName)}
21292
20791
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21293
20792
  WHERE id = ${userId}
21294
- `));
20793
+ `);
21295
20794
  }
21296
20795
  /**
21297
20796
  * Assign a specific role to new user (appends if not present)
21298
20797
  */
21299
20798
  async assignDefaultRole(userId, roleId) {
21300
20799
  const usersTableName = this.getQualifiedUsersTableName();
21301
- await this.withServerContext(async (db) => db.execute(sql`
20800
+ await this.db.execute(sql`
21302
20801
  UPDATE ${sql.raw(usersTableName)}
21303
20802
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21304
20803
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21305
- `));
20804
+ `);
21306
20805
  }
21307
20806
  /**
21308
20807
  * Get user with their roles
@@ -22686,14 +22185,21 @@ function createPostgresBootstrapper(pgConfig) {
22686
22185
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
22687
22186
  }
22688
22187
  const activeCollections = introspectedCollections ?? collections;
22188
+ const registry = new PostgresCollectionRegistry();
22189
+ if (activeCollections) {
22190
+ registry.registerMultiple(activeCollections);
22191
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
22192
+ }
22689
22193
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
22690
- const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
22691
- const registry = buildCollectionRegistry({
22692
- collections: activeCollections,
22693
- tables: schemaTables,
22694
- enums: pgConfig.schema?.enums,
22695
- relations: schemaRelations
22194
+ if (schemaTables) Object.values(schemaTables).forEach((table) => {
22195
+ if (isTable(table)) {
22196
+ const tableName = getTableName(table);
22197
+ registry.registerTable(table, tableName);
22198
+ }
22696
22199
  });
22200
+ if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
22201
+ const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
22202
+ if (schemaRelations) registry.registerRelations(schemaRelations);
22697
22203
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
22698
22204
  const mergedSchema = {
22699
22205
  ...schemaTables,
@@ -22772,7 +22278,6 @@ function createPostgresBootstrapper(pgConfig) {
22772
22278
  const wantsCdc = cdcMode !== "off";
22773
22279
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22774
22280
  let cdcEnabled = false;
22775
- let provisionCdcForTables;
22776
22281
  if (wantsCdc && !directUrl) {
22777
22282
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22778
22283
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22789,9 +22294,6 @@ function createPostgresBootstrapper(pgConfig) {
22789
22294
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22790
22295
  await realtimeService.enableCdc(directUrl);
22791
22296
  cdcEnabled = true;
22792
- provisionCdcForTables = async (tables) => {
22793
- await provisionTriggerCdc(cdcRunSql, tables);
22794
- };
22795
22297
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
22796
22298
  } catch (err) {
22797
22299
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22816,14 +22318,13 @@ function createPostgresBootstrapper(pgConfig) {
22816
22318
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22817
22319
  const missing = [];
22818
22320
  for (const col of registeredCollections) {
22819
- if (col.auth?.enabled) continue;
22820
22321
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22821
22322
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22822
22323
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22823
22324
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22824
22325
  if (!dbTables.has(fullCheckName)) missing.push({
22825
22326
  slug: col.slug,
22826
- table: fullCheckName
22327
+ table: checkName
22827
22328
  });
22828
22329
  }
22829
22330
  if (missing.length > 0) {
@@ -22857,8 +22358,7 @@ function createPostgresBootstrapper(pgConfig) {
22857
22358
  registry,
22858
22359
  realtimeService,
22859
22360
  driver,
22860
- poolManager,
22861
- provisionCdcForTables
22361
+ poolManager
22862
22362
  }
22863
22363
  };
22864
22364
  },
@@ -22870,18 +22370,6 @@ function createPostgresBootstrapper(pgConfig) {
22870
22370
  const registry = internals.registry;
22871
22371
  const authCollection = authConfig.collection;
22872
22372
  await ensureAuthTablesExist(db, authCollection);
22873
- if (authCollection && internals.provisionCdcForTables) {
22874
- const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
22875
- const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
22876
- if (authTable) try {
22877
- await internals.provisionCdcForTables([{
22878
- schema: authSchema,
22879
- table: authTable
22880
- }]);
22881
- } catch (err) {
22882
- logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
22883
- }
22884
- }
22885
22373
  let emailService;
22886
22374
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22887
22375
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22952,6 +22440,6 @@ function createPostgresAdapter(pgConfig) {
22952
22440
  };
22953
22441
  }
22954
22442
  //#endregion
22955
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22443
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22956
22444
 
22957
22445
  //# sourceMappingURL=index.es.js.map