@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g394d868

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 (38) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/backup-service-CD8o_1Sl.js.map +1 -1
  3. package/dist/cli-helpers.d.ts +1 -1
  4. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-BTdgHBKV.js} +2 -2
  5. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-BTdgHBKV.js.map} +1 -1
  6. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CdRsuy33.js} +33 -7
  7. package/dist/ensure-collection-tables-CdRsuy33.js.map +1 -0
  8. package/dist/index.es.js +231 -94
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/schema/ensure-collection-tables.d.ts +1 -1
  11. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  12. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  13. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  14. package/dist/services/FetchService.d.ts +10 -7
  15. package/dist/services/dataService.d.ts +2 -0
  16. package/dist/services/realtimeService.d.ts +25 -21
  17. package/dist/{src-DlPBctw_.js → src-B0PGnKU3.js} +174 -16
  18. package/dist/src-B0PGnKU3.js.map +1 -0
  19. package/dist/src-DoU9yPqq.js.map +1 -1
  20. package/package.json +9 -8
  21. package/src/PostgresBackendDriver.ts +165 -3
  22. package/src/cli-helpers.ts +8 -2
  23. package/src/cli.ts +79 -0
  24. package/src/collections/validate-relations.ts +124 -17
  25. package/src/data-transformer.ts +13 -3
  26. package/src/schema/doctor.ts +7 -5
  27. package/src/schema/ensure-collection-tables.ts +8 -2
  28. package/src/schema/generate-drizzle-schema-logic.ts +8 -2
  29. package/src/schema/generate-postgres-ddl-logic.ts +47 -11
  30. package/src/schema/generated-schema-staleness.ts +169 -0
  31. package/src/schema/introspect-db-logic.ts +1 -1
  32. package/src/schema/non-sql-collections.test.ts +131 -0
  33. package/src/services/FetchService.ts +10 -93
  34. package/src/services/PersistService.ts +14 -1
  35. package/src/services/dataService.ts +2 -0
  36. package/src/services/realtimeService.ts +36 -33
  37. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  38. package/dist/src-DlPBctw_.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -2,7 +2,7 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
5
- import { A as toSnakeCase, C as normalizeToEntityRelation, D as getPolicyNamesForRule, F as Vector, N as hasForeignKeyOnTarget, O as mergeDeep, P as isManyToMany, S as createRelationRefWithData, T as generateForeignKeyName, _ as buildCompositeId, a as getJunctionSecurityRules, b as parseIdValues, c as policyToPostgres, d as findRelation, f as getColumnName, g as resolveCollectionRelations, h as getTableVarName, i as getJunctionCollectionConfig, k as camelCase, l as securityRuleToConditions, m as getTableName$1, n as CollectionRegistry, o as resolveJunctionSpecs, p as getEnumVarName, r as resolveStringColumnLength, s as getEffectiveSecurityRules, t as buildSdkData, u as findAnonymousGrants, v as getDeclaredPrimaryKeys, w as updateDateAutoValues, x as createRelationRef, y as isAddressableId } from "./src-DlPBctw_.js";
5
+ import { A as mergeDeep, C as createRelationRefWithData, D as legacyForeignKeyName, E as generateForeignKeyName, F as resolveClientListLimit, I as hasForeignKeyOnTarget, L as isManyToMany, M as toSnakeCase, O as getPolicyNamesForRule, R as Vector, S as createRelationRef, T as updateDateAutoValues, _ as resolveCollectionRelations, a as getJunctionCollectionConfig, b as isAddressableId, c as getEffectiveSecurityRules, d as findAnonymousGrants, f as findRelation, g as getTableVarName, h as getTableName$1, i as resolveStringColumnLength, j as camelCase, k as isPrototypePollutingKey, l as policyToPostgres, m as getEnumVarName, n as CollectionRegistry, o as getJunctionSecurityRules, p as getColumnName, r as relationalCollections, s as resolveJunctionSpecs, t as buildSdkData, u as securityRuleToConditions, v as buildCompositeId, w as normalizeToEntityRelation, x as parseIdValues, y as getDeclaredPrimaryKeys } from "./src-B0PGnKU3.js";
6
6
  import { n as isPostgresCollectionConfig, r as isRelationalCollectionConfig } from "./src-DoU9yPqq.js";
7
7
  import { t as ANONYMOUS_USER_ID } from "./policy-CeA1JcxP.js";
8
8
  import { t as createPostgresWebSocket } from "./websocket-B2LsrINK.js";
@@ -30,25 +30,6 @@ import { randomUUID } from "crypto";
30
30
  function isChannelBusInstance(setting) {
31
31
  return typeof setting?.publish === "function";
32
32
  }
33
- /**
34
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
35
- *
36
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
37
- * so `0`, negatives, and absurd values can never bypass the cap.
38
- * - An absent / blank / non-numeric limit falls back to the mode default:
39
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
40
- *
41
- * The return is never `undefined` — no ingress that routes its client limit
42
- * through this can produce an unbounded read.
43
- */
44
- function resolveClientListLimit(rawLimit, opts = {}) {
45
- const maxLimit = opts.maxLimit ?? 1e3;
46
- if (rawLimit != null && String(rawLimit).trim() !== "") {
47
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
48
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
49
- }
50
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
51
- }
52
33
  //#endregion
53
34
  //#region ../common/src/util/email.ts
54
35
  /**
@@ -1276,7 +1257,11 @@ function sanitizeAndConvertDates(obj) {
1276
1257
  if (obj instanceof Date) return obj.toISOString();
1277
1258
  if (typeof obj === "object") {
1278
1259
  const newObj = {};
1279
- for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = sanitizeAndConvertDates(obj[key]);
1260
+ for (const key in obj) {
1261
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
1262
+ if (isPrototypePollutingKey(key)) continue;
1263
+ newObj[key] = sanitizeAndConvertDates(obj[key]);
1264
+ }
1280
1265
  return newObj;
1281
1266
  }
1282
1267
  if (typeof obj === "string") {
@@ -1305,6 +1290,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1305
1290
  if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
1306
1291
  });
1307
1292
  for (const [key, value] of Object.entries(row)) {
1293
+ if (isPrototypePollutingKey(key)) continue;
1308
1294
  const property = properties[key];
1309
1295
  const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
1310
1296
  if (!property) {
@@ -3796,51 +3782,6 @@ var FetchService = class {
3796
3782
  return !!this.getQueryBuilder(tableName);
3797
3783
  }
3798
3784
  /**
3799
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
3800
- * for efficient JOIN-based relation loading.
3801
- * Returns null if the API is not available or the query fails.
3802
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
3803
- */
3804
- async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
3805
- try {
3806
- const table = getTableForCollection(collection, this.registry);
3807
- const tableName = getTableName(table);
3808
- const queryTarget = this.getQueryBuilder(tableName);
3809
- if (!queryTarget?.findMany) return null;
3810
- const resolvedRelations = resolveCollectionRelations(collection);
3811
- const withConfig = {};
3812
- for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
3813
- const drizzleRelName = relation.relationName || key;
3814
- withConfig[drizzleRelName] = true;
3815
- }
3816
- const queryOpts = { with: withConfig };
3817
- if (options.limit) queryOpts.limit = options.limit;
3818
- if (options.filter) {
3819
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3820
- if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
3821
- }
3822
- if (options.orderBy) {
3823
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3824
- if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
3825
- }
3826
- return (await queryTarget.findMany(queryOpts)).map((row) => {
3827
- const flat = {};
3828
- for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
3829
- const keys = Object.keys(item);
3830
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
3831
- if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
3832
- return { ...item };
3833
- });
3834
- else if (typeof v === "object" && v !== null) flat[k] = { ...v };
3835
- else flat[k] = v;
3836
- return flat;
3837
- });
3838
- } catch (e) {
3839
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
3840
- return null;
3841
- }
3842
- }
3843
- /**
3844
3785
  * Fallback path used when db.query is unavailable.
3845
3786
  * The primary path uses db.query.findMany with `with` config, which
3846
3787
  * loads all relations in a single query.
@@ -4091,7 +4032,8 @@ var PersistService = class {
4091
4032
  if (error instanceof ApiError || error?.name === "ApiError") return error;
4092
4033
  const pgError = extractPgError(error);
4093
4034
  if (pgError) {
4094
- const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4035
+ const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4036
+ if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
4095
4037
  return new Error(message);
4096
4038
  }
4097
4039
  const causeMessage = extractCauseMessage(error);
@@ -4749,6 +4691,25 @@ var PostgresBackendDriver = class PostgresBackendDriver {
4749
4691
  }
4750
4692
  };
4751
4693
  }
4694
+ /**
4695
+ * Build the context handed to every collection callback.
4696
+ *
4697
+ * Note `data: this.data` — `this` is whichever driver is running the
4698
+ * operation, so the callback's data plane inherits that driver's privilege.
4699
+ * On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
4700
+ * constructs a fresh base driver bound to the RLS-scoped transaction and
4701
+ * runs the operation on it, so `this.data` speaks through that connection
4702
+ * and policies apply. On server-context work `this` is the base driver on
4703
+ * the owner connection, and they do not. Pinned by the
4704
+ * `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
4705
+ * suite, because it is the kind of property that is easy to break from a
4706
+ * distance and impossible to notice.
4707
+ *
4708
+ * Previously returned through `as unknown as RebaseCallContext`, which
4709
+ * disabled checking for the whole object and let `driver` — documented in
4710
+ * the callbacks guide — sit on the runtime context while absent from the
4711
+ * contract. Both are declared now, so this is a plain typed return.
4712
+ */
4752
4713
  buildCallContext() {
4753
4714
  return {
4754
4715
  user: this.user,
@@ -5204,6 +5165,106 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5204
5165
  return saved;
5205
5166
  });
5206
5167
  }
5168
+ /**
5169
+ * Update many rows through the same pipeline as {@link save}, in one
5170
+ * transaction.
5171
+ *
5172
+ * Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
5173
+ * same deferred notifications, same per-row error labelling — but it calls
5174
+ * `save` with an explicit `id` and `status: "existing"`, which is precisely
5175
+ * what `saveMany` cannot do: that one passes `status: "new"` and keeps the
5176
+ * key inside `values`, so it inserts or upserts and can never target a
5177
+ * particular row.
5178
+ *
5179
+ * All-or-nothing, so an id matching no row aborts the batch. A partial
5180
+ * update is the outcome with no good recovery: the caller cannot tell which
5181
+ * half landed without re-reading everything.
5182
+ */
5183
+ async updateMany({ path, updates, collection }) {
5184
+ return this.db.transaction(async (tx) => {
5185
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5186
+ txDriver.dataService = new DataService(tx, this.registry);
5187
+ txDriver.client = this.client;
5188
+ txDriver._deferNotifications = this._deferNotifications;
5189
+ txDriver._pendingNotifications = this._pendingNotifications;
5190
+ const saved = [];
5191
+ for (let i = 0; i < updates.length; i++) {
5192
+ const { id, values } = updates[i];
5193
+ try {
5194
+ if (!await txDriver.fetchOne({
5195
+ path,
5196
+ id: String(id),
5197
+ collection
5198
+ })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5199
+ statusCode: 404,
5200
+ code: "NOT_FOUND"
5201
+ });
5202
+ saved.push(await txDriver.save({
5203
+ path,
5204
+ id: String(id),
5205
+ values,
5206
+ collection,
5207
+ status: "existing"
5208
+ }));
5209
+ } catch (error) {
5210
+ throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5211
+ statusCode: error?.statusCode,
5212
+ code: error?.code,
5213
+ name: error?.name
5214
+ });
5215
+ }
5216
+ }
5217
+ return saved;
5218
+ });
5219
+ }
5220
+ /**
5221
+ * Delete many rows in one transaction, running the full delete pipeline —
5222
+ * `beforeDelete`, the delete, `afterDelete` — for each.
5223
+ *
5224
+ * Looping the single-row {@link delete} rather than emitting one
5225
+ * `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
5226
+ * statement would be faster and would skip every callback, so a collection
5227
+ * relying on `beforeDelete` to veto or on `afterDelete` to clean up
5228
+ * dependents would behave differently depending on how many rows the caller
5229
+ * happened to delete at once. Same pipeline, one transaction.
5230
+ */
5231
+ async deleteMany({ path, ids, collection }) {
5232
+ await this.db.transaction(async (tx) => {
5233
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5234
+ txDriver.dataService = new DataService(tx, this.registry);
5235
+ txDriver.client = this.client;
5236
+ txDriver._deferNotifications = this._deferNotifications;
5237
+ txDriver._pendingNotifications = this._pendingNotifications;
5238
+ for (let i = 0; i < ids.length; i++) {
5239
+ const id = ids[i];
5240
+ try {
5241
+ const existing = await txDriver.fetchOne({
5242
+ path,
5243
+ id: String(id),
5244
+ collection
5245
+ });
5246
+ if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5247
+ statusCode: 404,
5248
+ code: "NOT_FOUND"
5249
+ });
5250
+ await txDriver.delete({
5251
+ row: {
5252
+ id: String(id),
5253
+ path,
5254
+ values: existing
5255
+ },
5256
+ collection
5257
+ });
5258
+ } catch (error) {
5259
+ throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5260
+ statusCode: error?.statusCode,
5261
+ code: error?.code,
5262
+ name: error?.name
5263
+ });
5264
+ }
5265
+ }
5266
+ });
5267
+ }
5207
5268
  async delete({ row, collection }) {
5208
5269
  const targetPath = row.path;
5209
5270
  const targetRow = { ...row.values ?? {} };
@@ -6168,7 +6229,8 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
6168
6229
  }
6169
6230
  return fallback;
6170
6231
  };
6171
- var generateSchema = async (collections, stripPolicies = false) => {
6232
+ var generateSchema = async (allCollections, stripPolicies = false) => {
6233
+ const collections = relationalCollections(allCollections);
6172
6234
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
6173
6235
  const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
6174
6236
  const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
@@ -7816,23 +7878,18 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7816
7878
  path: request.path,
7817
7879
  collectionRequest: {
7818
7880
  filter: request.filter,
7881
+ logical: request.logical,
7819
7882
  orderBy: request.orderBy,
7820
7883
  order: request.order,
7821
7884
  limit: boundedLimit,
7885
+ offset: request.offset,
7822
7886
  startAfter: request.startAfter,
7823
7887
  databaseId: request.collection?.databaseId,
7824
7888
  searchString: request.searchString
7825
7889
  },
7826
7890
  authContext
7827
7891
  });
7828
- const rows = await this.fetchCollectionWithAuth(request.path, {
7829
- filter: request.filter,
7830
- orderBy: request.orderBy,
7831
- order: request.order,
7832
- limit: boundedLimit,
7833
- startAfter: request.startAfter,
7834
- searchString: request.searchString
7835
- }, authContext);
7892
+ const rows = await this.fetchCollectionWithAuth(request.path, this._subscriptions.get(subscriptionId).collectionRequest, authContext);
7836
7893
  this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
7837
7894
  } catch (error) {
7838
7895
  const sanitized = sanitizeErrorForClient(error, request.path);
@@ -8016,6 +8073,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8016
8073
  });
8017
8074
  else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
8018
8075
  filter: collectionRequest.filter,
8076
+ logical: collectionRequest.logical,
8019
8077
  orderBy: collectionRequest.orderBy,
8020
8078
  order: collectionRequest.order,
8021
8079
  limit: collectionRequest.limit,
@@ -9203,6 +9261,54 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
9203
9261
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
9204
9262
  var asColumns = (value) => Array.isArray(value) ? value : [value];
9205
9263
  /**
9264
+ * Distinguish "this column name is wrong" from "the generated schema is old".
9265
+ *
9266
+ * They present identically here — a relation asks for a column the registered
9267
+ * table does not have — but they are opposite problems with opposite fixes, and
9268
+ * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
9269
+ * projects.
9270
+ *
9271
+ * The registered table is not the database. It comes from the project's
9272
+ * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
9273
+ * derives foreign-key names: `categories` yields `category_id` where it used to
9274
+ * yield `categorie_id`. Boot-ensure renames the database column to match, so by
9275
+ * the time this runs the *database* is correct and the *generated module* is the
9276
+ * stale one. Reporting "not a column" then points at the wrong artifact, and the
9277
+ * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
9278
+ * name because that is what the stale module still has — talks the reader into
9279
+ * pinning a column that no longer exists.
9280
+ *
9281
+ * So when the wanted name is what the current rule derives, and the table
9282
+ * carries what the *previous* rule would have derived from the same source, say
9283
+ * that instead.
9284
+ *
9285
+ * @param wanted the column the relation asks for
9286
+ * @param available every column the registered table has
9287
+ * @param sources names the default could have been derived from (a slug, a
9288
+ * relation name) — checking against these rather than guessing
9289
+ * backwards from `wanted` keeps the match exact
9290
+ */
9291
+ function staleCodegenRename(wanted, available, sources) {
9292
+ for (const source of sources) {
9293
+ if (!source) continue;
9294
+ const current = generateForeignKeyName(source);
9295
+ const legacy = legacyForeignKeyName(source);
9296
+ if (current !== wanted || legacy === current) continue;
9297
+ if (available.has(legacy) && !available.has(current)) return {
9298
+ legacy,
9299
+ current
9300
+ };
9301
+ }
9302
+ return null;
9303
+ }
9304
+ /** The shared explanation, so every relation kind reports it identically. */
9305
+ function staleCodegenDefect(table, { legacy, current }) {
9306
+ return {
9307
+ problem: `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this release derives \`${current}\` — the generated schema predates the foreign-key naming fix and no longer describes the database`,
9308
+ fix: `regenerate it with \`rebase schema generate\` (or \`pnpm run schema:generate\`). The database column has already been renamed for you at boot, so nothing else is needed. To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
9309
+ };
9310
+ }
9311
+ /**
9206
9312
  * Relations whose names do not resolve against the registered schema.
9207
9313
  *
9208
9314
  * Fails open wherever it cannot see enough to be sure — an unregistered source
@@ -9249,19 +9355,31 @@ function findRelationDefects(collections, registry) {
9249
9355
  const targetColumns = columnNames(targetTable);
9250
9356
  switch (relation.kind) {
9251
9357
  case "belongsTo":
9252
- if (!sourceColumns.has(relation.localKey)) defects.push({
9253
- ...at,
9254
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
9255
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
9256
- });
9358
+ if (!sourceColumns.has(relation.localKey)) {
9359
+ const stale = staleCodegenRename(relation.localKey, sourceColumns, [relation.relationName, targetCollection.slug]);
9360
+ defects.push(stale ? {
9361
+ ...at,
9362
+ ...staleCodegenDefect(sourceTableName, stale)
9363
+ } : {
9364
+ ...at,
9365
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
9366
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
9367
+ });
9368
+ }
9257
9369
  break;
9258
9370
  case "hasOne":
9259
9371
  case "hasMany":
9260
- if (!targetColumns.has(relation.foreignKeyOnTarget)) defects.push({
9261
- ...at,
9262
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
9263
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
9264
- });
9372
+ if (!targetColumns.has(relation.foreignKeyOnTarget)) {
9373
+ const stale = staleCodegenRename(relation.foreignKeyOnTarget, targetColumns, [collection.slug]);
9374
+ defects.push(stale ? {
9375
+ ...at,
9376
+ ...staleCodegenDefect(targetTableName, stale)
9377
+ } : {
9378
+ ...at,
9379
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
9380
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
9381
+ });
9382
+ }
9265
9383
  if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
9266
9384
  ...at,
9267
9385
  problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
@@ -9280,11 +9398,21 @@ function findRelationDefects(collections, registry) {
9280
9398
  break;
9281
9399
  }
9282
9400
  const junctionColumns = columnNames(junction);
9283
- for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) defects.push({
9284
- ...at,
9285
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
9286
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
9287
- });
9401
+ const derivedFrom = {
9402
+ sourceColumn: [collection.slug],
9403
+ targetColumn: [targetCollection.slug]
9404
+ };
9405
+ for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) {
9406
+ const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
9407
+ defects.push(stale ? {
9408
+ ...at,
9409
+ ...staleCodegenDefect(table, stale)
9410
+ } : {
9411
+ ...at,
9412
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
9413
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
9414
+ });
9415
+ }
9288
9416
  break;
9289
9417
  }
9290
9418
  case "via": {
@@ -9356,7 +9484,16 @@ function assertRelationsResolve(collections, registry) {
9356
9484
  const defects = findRelationDefects(collections, registry);
9357
9485
  if (defects.length === 0) return;
9358
9486
  const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
9359
- throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\nEach of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
9487
+ throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against \`backend/src/schema.generated.ts\`.
9488
+
9489
+ Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
9490
+
9491
+ If the database was migrated recently — an upgrade, a \`db push\`, a restore — this file is
9492
+ probably older than the schema it describes. Regenerate it before changing anything else:
9493
+
9494
+ rebase schema generate
9495
+
9496
+ If it is already current, then the collection is what disagrees with it:
9360
9497
 
9361
9498
  ` + lines.join("\n\n") + "\n");
9362
9499
  }
@@ -12288,7 +12425,7 @@ function createPostgresBootstrapper(pgConfig) {
12288
12425
  */
12289
12426
  async ensureCollectionSchema(collections, driverResult, log) {
12290
12427
  const internals = driverResult.internals;
12291
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CBQdOETu.js");
12428
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-CdRsuy33.js");
12292
12429
  const plan = await ensureCollectionTables({ async query(text) {
12293
12430
  const result = await internals.db.execute(sql.raw(text));
12294
12431
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -12313,7 +12450,7 @@ function createPostgresBootstrapper(pgConfig) {
12313
12450
  */
12314
12451
  async ensureCollectionPolicies(collections, driverResult, log) {
12315
12452
  const internals = driverResult.internals;
12316
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-ViG8XiPn.js");
12453
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-BTdgHBKV.js");
12317
12454
  const outcome = await ensureCollectionPolicies({ async query(text) {
12318
12455
  const result = await internals.db.execute(sql.raw(text));
12319
12456
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };