@rebasepro/server-postgres 0.21.2-canary.g1ea48be → 0.23.0

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 (83) hide show
  1. package/dist/{BranchService-CucnFcSE.js → BranchService-DGPL6G_C.js} +2 -2
  2. package/dist/{BranchService-CucnFcSE.js.map → BranchService-DGPL6G_C.js.map} +1 -1
  3. package/dist/PostgresBackendDriver.d.ts +41 -1
  4. package/dist/auth/services.d.ts +27 -8
  5. package/dist/backup/backup-cli.d.ts +22 -0
  6. package/dist/{backup-cli-CkjsJEcu.js → backup-cli-24v4OlSp.js} +17 -14
  7. package/dist/backup-cli-24v4OlSp.js.map +1 -0
  8. package/dist/{backup-service-BNLwvxuy.js → backup-service-HQ9GC4tN.js} +2 -2
  9. package/dist/{backup-service-BNLwvxuy.js.map → backup-service-HQ9GC4tN.js.map} +1 -1
  10. package/dist/{cli-errors-Dka89exj.js → cli-errors-B8qHg02P.js} +6 -1
  11. package/dist/cli-errors-B8qHg02P.js.map +1 -0
  12. package/dist/cli-flags-BglvpjHv.js +138 -0
  13. package/dist/cli-flags-BglvpjHv.js.map +1 -0
  14. package/dist/cli-flags.d.ts +28 -0
  15. package/dist/cli-helpers.d.ts +6 -0
  16. package/dist/cli-scratch-database.d.ts +34 -0
  17. package/dist/cli.js +97 -261
  18. package/dist/cli.js.map +1 -1
  19. package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-DF-8dTVa.js} +16 -20
  20. package/dist/column-plan-helpers-DF-8dTVa.js.map +1 -0
  21. package/dist/{doctor-Gzzd8wTq.js → doctor-Cb2thZ8s.js} +8 -7
  22. package/dist/doctor-Cb2thZ8s.js.map +1 -0
  23. package/dist/{ensure-collection-policies-B4IDFtQ-.js → ensure-collection-policies-RHUcEp4v.js} +5 -5
  24. package/dist/{ensure-collection-policies-B4IDFtQ-.js.map → ensure-collection-policies-RHUcEp4v.js.map} +1 -1
  25. package/dist/{ensure-collection-tables-Cr2ye5JC.js → ensure-collection-tables-BEEjn5cn.js} +53 -22
  26. package/dist/ensure-collection-tables-BEEjn5cn.js.map +1 -0
  27. package/dist/{ensure-tables-BnEvEJPr.js → ensure-tables-Dhn9KM3B.js} +2 -2
  28. package/dist/{ensure-tables-BnEvEJPr.js.map → ensure-tables-Dhn9KM3B.js.map} +1 -1
  29. package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js → generate-drizzle-schema-logic-BfzK7UQd.js} +2 -2
  30. package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js.map → generate-drizzle-schema-logic-BfzK7UQd.js.map} +1 -1
  31. package/dist/{generate-drizzle-schema-D2MDdJt-.js → generate-drizzle-schema-yzY_BLhr.js} +2 -2
  32. package/dist/{generate-drizzle-schema-D2MDdJt-.js.map → generate-drizzle-schema-yzY_BLhr.js.map} +1 -1
  33. package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js → generate-postgres-ddl-logic-Bt2d2mRH.js} +2 -2
  34. package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js.map → generate-postgres-ddl-logic-Bt2d2mRH.js.map} +1 -1
  35. package/dist/index.es.js +1882 -392
  36. package/dist/index.es.js.map +1 -1
  37. package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-WMuAfxvw.js} +323 -4
  38. package/dist/introspect-db-logic-WMuAfxvw.js.map +1 -0
  39. package/dist/{plan-schema-Hgl62S-w.js → plan-schema-C0fxM8dY.js} +170 -38
  40. package/dist/plan-schema-C0fxM8dY.js.map +1 -0
  41. package/dist/{policy-drift-B0GDRh7_.js → policy-drift-DljYdrpW.js} +2 -2
  42. package/dist/{policy-drift-B0GDRh7_.js.map → policy-drift-DljYdrpW.js.map} +1 -1
  43. package/dist/{rls-bootstrap-sql-BHA6jLtj.js → rls-bootstrap-sql-B8EclDyM.js} +320 -17
  44. package/dist/rls-bootstrap-sql-B8EclDyM.js.map +1 -0
  45. package/dist/schema/atlas-argv.d.ts +15 -0
  46. package/dist/schema/column-plan-helpers.d.ts +23 -4
  47. package/dist/schema/destructive-sql.d.ts +52 -1
  48. package/dist/schema/doctor-cli.js +3 -3
  49. package/dist/schema/generate-drizzle-schema.js +1 -1
  50. package/dist/schema/generate-postgres-ddl.js +1 -1
  51. package/dist/schema/introspect-db.js +1 -312
  52. package/dist/schema/introspect-db.js.map +1 -1
  53. package/dist/schema/plan/diff-plan.d.ts +3 -2
  54. package/dist/schema/plan/plan-schema.d.ts +10 -3
  55. package/dist/schema/plan/types.d.ts +6 -1
  56. package/dist/schema/search-column.d.ts +21 -1
  57. package/dist/services/FetchService.d.ts +147 -30
  58. package/dist/services/PersistService.d.ts +56 -5
  59. package/dist/services/RelationService.d.ts +42 -1
  60. package/dist/services/RelationWriteService.d.ts +22 -1
  61. package/dist/services/cdc/CdcListener.d.ts +6 -1
  62. package/dist/services/collection-helpers.d.ts +11 -0
  63. package/dist/services/dataService.d.ts +19 -21
  64. package/dist/services/field-op-sql.d.ts +71 -0
  65. package/dist/services/junction-writes.d.ts +19 -3
  66. package/dist/services/pg-notify-listener.d.ts +7 -0
  67. package/dist/services/read-field-access.d.ts +19 -0
  68. package/dist/services/read-scope.d.ts +100 -0
  69. package/dist/services/realtimeService.d.ts +77 -3
  70. package/dist/services/row-pipeline.d.ts +5 -0
  71. package/dist/services/soft-delete.d.ts +0 -2
  72. package/dist/services/write-transaction-scope.d.ts +39 -0
  73. package/dist/utils/drizzle-conditions.d.ts +63 -8
  74. package/dist/websocket.d.ts +10 -3
  75. package/package.json +9 -9
  76. package/dist/backup-cli-CkjsJEcu.js.map +0 -1
  77. package/dist/cli-errors-Dka89exj.js.map +0 -1
  78. package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
  79. package/dist/doctor-Gzzd8wTq.js.map +0 -1
  80. package/dist/ensure-collection-tables-Cr2ye5JC.js.map +0 -1
  81. package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
  82. package/dist/plan-schema-Hgl62S-w.js.map +0 -1
  83. package/dist/rls-bootstrap-sql-BHA6jLtj.js.map +0 -1
@@ -1,11 +1,11 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
- import { a as getPrimaryKeyName, c as isIdProperty, i as enumLabelsOf, l as resolveColumnName, o as getPrimaryKeyProp, r as declaresEnumType, s as idColumnDefault, t as assertSinglePrimaryKey } from "./column-plan-helpers-CpILzHJS.js";
3
+ import { a as getPrimaryKeyColumn, c as idColumnDefault, i as enumLabelsOf, l as isIdProperty, o as getPrimaryKeyName, r as declaresEnumType, s as getPrimaryKeyProp, t as assertSinglePrimaryKey, u as resolveColumnName } from "./column-plan-helpers-DF-8dTVa.js";
4
4
  import { i as isAuthCollection, n as authUsersColumnDefinition, r as authUsersColumnSql, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
5
5
  import { c as vectorExtensionStatement, n as buildVectorColumnSpecs, o as vectorExtensionDeclared, r as buildVectorIndexPlan } from "./vector-index-CMJKb3C9.js";
6
6
  import { a as deriveIndexName, t as buildCollectionIndexSpecs } from "./collection-index-DdnsxiJ_.js";
7
- import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_WEIGHT, REBASE_SCHEMA, hasForeignKeyOnTarget, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
8
- import { TENANT_INDEX_REASON, fieldKeyForColumn, findRelation, getEffectiveSecurityRules, getEnumVarName, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, getTableVarName, getTenantConfig, policyToPostgres, relationalCollections, resolveCollectionRelations, resolveJunctionSpecs, resolveStringColumnLength, securityRuleToConditions } from "@rebasepro/common";
7
+ import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_MODE, DEFAULT_SEARCH_WEIGHT, REBASE_SCHEMA, hasForeignKeyOnTarget, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
8
+ import { TENANT_INDEX_REASON, assertBeforeQueryIsPostgresOnly, fieldKeyForColumn, findRelation, getEffectiveSecurityRules, getEnumVarName, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, getTableVarName, getTenantConfig, policyToPostgres, relationalCollections, resolveCollectionRelations, resolveJunctionSpecs, resolveStringColumnLength, securityRuleToConditions } from "@rebasepro/common";
9
9
  import { generateForeignKeyName, getPolicyNamesForRule, legacyForeignKeyName, toPostgresIdentifier, toSnakeCase } from "@rebasepro/utils";
10
10
  import { createHash } from "node:crypto";
11
11
  //#region src/schema/search-column.ts
@@ -179,11 +179,12 @@ var resolveField = (entry, collection, cfg) => {
179
179
  if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
180
180
  if (rest.length > 0 && classified.kind !== "jsonb") throw new SearchConfigError(`${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`);
181
181
  const column = columnNameOf(head, prop);
182
- const textSql = normalize(rawTextSql({
182
+ const raw = rawTextSql({
183
183
  column,
184
184
  jsonPath: rest,
185
185
  kind: classified.kind
186
- }), cfg.unaccent === true);
186
+ });
187
+ const textSql = normalize(raw, cfg.unaccent === true);
187
188
  const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;
188
189
  return {
189
190
  path,
@@ -192,7 +193,8 @@ var resolveField = (entry, collection, cfg) => {
192
193
  kind: classified.kind,
193
194
  weight,
194
195
  sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
195
- textSql
196
+ textSql,
197
+ foldedTextSql: normalize(raw, true)
196
198
  };
197
199
  };
198
200
  /**
@@ -216,8 +218,9 @@ var buildSearchColumnSpec = (collection) => {
216
218
  if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
217
219
  seen.add(f.path);
218
220
  }
221
+ const mode = cfg.mode ?? DEFAULT_SEARCH_MODE;
219
222
  const extensions = [];
220
- if (cfg.unaccent) extensions.push("unaccent");
223
+ if (cfg.unaccent || mode === "hybrid") extensions.push("unaccent");
221
224
  if (cfg.fuzzy) extensions.push("pg_trgm");
222
225
  const spec = {
223
226
  schema,
@@ -225,6 +228,7 @@ var buildSearchColumnSpec = (collection) => {
225
228
  column,
226
229
  language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,
227
230
  unaccent: cfg.unaccent === true,
231
+ mode,
228
232
  fields,
229
233
  expression: fields.map((f) => f.sql).join(" || "),
230
234
  indexName: toPostgresIdentifier(`${table}_${column}_gin`),
@@ -256,7 +260,7 @@ var buildSearchColumnSpec = (collection) => {
256
260
  */
257
261
  var searchHelperFunctions = (spec) => {
258
262
  const statements = [`CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT array_to_string($1, ' ') $$;`, `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT coalesce(string_agg(v, ' '), '')\n FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`];
259
- if (spec.unaccent) statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
263
+ if (spec.unaccent || spec.mode === "hybrid") statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
260
264
  return statements;
261
265
  };
262
266
  /**
@@ -618,14 +622,30 @@ var foreignKeyPlan = (args) => {
618
622
  * The type a column pointing at this collection's primary key must have — a
619
623
  * junction endpoint, a `belongsTo` foreign key, a `reference`.
620
624
  *
621
- * One function because it was three: the ladder was spelled inline in
622
- * `generatePostgresDdl`, again in `planJunctionTables` and a third time in the
623
- * Drizzle generator.
625
+ * The key's own column type, from the same functions that typed the key: a
626
+ * foreign key only holds between compatible types, so any second reading of
627
+ * the key is a constraint waiting to fail. This used to look at the id
628
+ * strategy alone, so `id: { isId: true, columnType: "uuid" }` was referenced by
629
+ * TEXT columns (42804 — the constraint was never created) and a `bigint` key by
630
+ * INTEGER ones, which overflow at 2^31.
631
+ *
632
+ * One exception: a `serial` key owns a sequence, and a column pointing at it is
633
+ * the plain integer of the same width — `SERIAL` there would give every
634
+ * referencing column a sequence of its own.
624
635
  */
625
636
  var primaryKeyPgType = (collection) => {
626
- const pk = getPrimaryKeyProp(collection);
627
- if (pk.type === "number") return { kind: "integer" };
628
- return pk.isUuid ? { kind: "uuid" } : { kind: "text" };
637
+ const { name, prop } = getPrimaryKeyProp(collection);
638
+ if (prop?.type === "number") return withoutSequence(numberType(name, prop, collection, true));
639
+ if (prop?.type === "string") return stringType(name, prop, collection);
640
+ return { kind: "text" };
641
+ };
642
+ var withoutSequence = (type) => {
643
+ switch (type.kind) {
644
+ case "smallserial": return { kind: "smallint" };
645
+ case "serial": return { kind: "integer" };
646
+ case "bigserial": return { kind: "bigint" };
647
+ default: return type;
648
+ }
629
649
  };
630
650
  /** The `columnType`s a `number` property may name, and what each one is. */
631
651
  var NUMBER_COLUMN_TYPES = {
@@ -762,6 +782,33 @@ var linkTarget = (propName, prop, collection, resolveCollection) => {
762
782
  return;
763
783
  }
764
784
  };
785
+ /** A value already written as a day, or as a time of day. */
786
+ var BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
787
+ var BARE_TIME = /^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
788
+ /**
789
+ * A date default in the shape its column's type accepts: a day for `date`, a
790
+ * time of day for `time`, an instant for a timestamp.
791
+ *
792
+ * Rendered as a full ISO timestamp whatever the column, a `time` column's
793
+ * DEFAULT was `'2024-03-01T15:30:00.000Z'`, which Postgres refuses — "invalid
794
+ * input syntax for type time" — so the schema would not apply. A value already
795
+ * written in the column's shape (`"2024-12-25"`, `"09:00"`) is kept as written;
796
+ * anything else is read as an instant and its UTC day or time of day taken, the
797
+ * same zone the timestamp rendering has always used. A string that is no date
798
+ * at all is no DEFAULT, rather than a schema that cannot be applied.
799
+ */
800
+ var dateDefaultLiteral = (value, type) => {
801
+ if (typeof value === "string") {
802
+ if (type.kind === "date" && BARE_DATE.test(value)) return value;
803
+ if (type.kind === "time" && BARE_TIME.test(value)) return value;
804
+ }
805
+ const instant = value instanceof Date ? value : typeof value === "string" ? new Date(value) : void 0;
806
+ if (!instant || Number.isNaN(instant.getTime())) return void 0;
807
+ const iso = instant.toISOString();
808
+ if (type.kind === "date") return iso.slice(0, 10);
809
+ if (type.kind === "time") return iso.slice(11, 23);
810
+ return typeof value === "string" ? value : iso;
811
+ };
765
812
  /**
766
813
  * A `defaultValue` as a database DEFAULT.
767
814
  *
@@ -796,11 +843,11 @@ var literalDefault = (prop, type) => {
796
843
  sql: value ? "TRUE" : "FALSE"
797
844
  } : void 0;
798
845
  case "date": {
799
- const iso = value instanceof Date ? Number.isNaN(value.getTime()) ? void 0 : value.toISOString() : typeof value === "string" ? value : void 0;
800
- return iso === void 0 ? void 0 : {
846
+ const literal = dateDefaultLiteral(value, type);
847
+ return literal === void 0 ? void 0 : {
801
848
  kind: "literal",
802
- value: iso,
803
- sql: quoteSqlLiteral(iso)
849
+ value: literal,
850
+ sql: quoteSqlLiteral(literal)
804
851
  };
805
852
  }
806
853
  case "map":
@@ -882,13 +929,14 @@ var compileSecurityRule = (collection, rule, resolveCollection, injected, ruleKe
882
929
  };
883
930
  function planSchema(allCollections, options = {}) {
884
931
  assertSearchIsPostgresOnly(allCollections);
932
+ assertBeforeQueryIsPostgresOnly(allCollections);
885
933
  const collections = relationalCollections(allCollections);
886
934
  collections.forEach(assertSinglePrimaryKey);
887
935
  const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
888
936
  const declaredSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
889
937
  const schemas = Array.from(/* @__PURE__ */ new Set([REBASE_SCHEMA, ...declaredSchemas]));
890
938
  const enums = [];
891
- const seenEnums = /* @__PURE__ */ new Set();
939
+ const enumOwners = /* @__PURE__ */ new Map();
892
940
  /**
893
941
  * Every enum type one table's properties declare, appended once.
894
942
  *
@@ -906,10 +954,21 @@ function planSchema(allCollections, options = {}) {
906
954
  const labels = enumLabelsOf(propName, prop, collection);
907
955
  if (!declaresEnumType(prop)) continue;
908
956
  const schema = schemaOf(collection);
909
- const name = `${getTableName(collection)}_${resolveColumnName(propName, prop)}`;
910
- const qualified = `${schema}.${name}`;
911
- if (seenEnums.has(qualified)) continue;
912
- seenEnums.add(qualified);
957
+ const table = getTableName(collection);
958
+ const column = resolveColumnName(propName, prop);
959
+ const name = `${table}_${column}`;
960
+ const qualified = `${schema}.${toPostgresIdentifier(name)}`;
961
+ const owner = `property "${propName}" of collection "${describe(collection)}"`;
962
+ const claimed = enumOwners.get(qualified);
963
+ if (claimed) {
964
+ if (claimed.table === table && claimed.column === column) continue;
965
+ throw new Error(`The enum ${owner} and the enum ${claimed.owner} both derive the Postgres type "${schema}"."${toPostgresIdentifier(name)}" (an enum type is named <table>_<column>, and Postgres keeps the first 63 bytes). One type cannot hold both columns' values, and a type's name cannot change once a database holds it. Give one of the two columns another name with \`columnName\`, or its collection another \`table\` or \`schema\`.`);
966
+ }
967
+ enumOwners.set(qualified, {
968
+ table,
969
+ column,
970
+ owner
971
+ });
913
972
  enums.push({
914
973
  schema,
915
974
  name,
@@ -922,12 +981,14 @@ function planSchema(allCollections, options = {}) {
922
981
  };
923
982
  for (const collection of collections) collectEnumTypes(collection, collection.properties ?? {}, isPostgresCollectionConfig(collection) ? collection.schema : void 0);
924
983
  const tableEntries = /* @__PURE__ */ new Map();
984
+ const junctionShapes = /* @__PURE__ */ new Map();
925
985
  for (const collection of collections) {
926
986
  const tableName = getTableName(collection);
927
987
  if (tableName) tableEntries.set(tableName, { collection });
928
- for (const relation of Object.values(resolveCollectionRelations(collection))) {
988
+ for (const [relationKey, relation] of Object.entries(resolveCollectionRelations(collection))) {
929
989
  if (!isManyToMany(relation)) continue;
930
990
  const junctionTable = relation.through.table;
991
+ assertOneJunctionShape(junctionShapes, junctionTable, collection, relation.relationName ?? relationKey, relation);
931
992
  if (tableEntries.has(junctionTable)) continue;
932
993
  tableEntries.set(junctionTable, {
933
994
  collection: {
@@ -979,6 +1040,38 @@ function planSchema(allCollections, options = {}) {
979
1040
  options
980
1041
  };
981
1042
  }
1043
+ /**
1044
+ * Refuse two many-to-many relations that resolve to one junction table with
1045
+ * different key columns.
1046
+ *
1047
+ * A default `through` is named after the two tables, so `posts.tags` and
1048
+ * `posts.featuredTags` both resolve to `posts_tags` — one keyed by `tag_id`,
1049
+ * the other by `featured_tag_id`. One table is created, from whichever came
1050
+ * first, so the other relation read and wrote a column that was never there.
1051
+ * The two sides of one link (`posts.tags` and `tags.posts`) share a junction
1052
+ * legitimately, and they name the same two columns — so the comparison is of
1053
+ * the (column, endpoint) pairs, in either order.
1054
+ */
1055
+ function assertOneJunctionShape(seen, junctionTable, collection, relationName, relation) {
1056
+ if (!isManyToMany(relation)) return;
1057
+ const target = relation.target();
1058
+ if (!target) return;
1059
+ const sourceTable = getTableName(collection);
1060
+ const targetTable = getTableName(target);
1061
+ const { sourceColumn, targetColumn } = relation.through;
1062
+ const keys = JSON.stringify([[sourceColumn, sourceTable], [targetColumn, targetTable]].sort());
1063
+ const claim = `the relation "${relationName}" of collection "${describe(collection)}" ("${sourceColumn}" → ${sourceTable}, "${targetColumn}" → ${targetTable})`;
1064
+ const first = seen.get(junctionTable);
1065
+ if (!first) {
1066
+ seen.set(junctionTable, {
1067
+ keys,
1068
+ claim
1069
+ });
1070
+ return;
1071
+ }
1072
+ if (first.keys === keys) return;
1073
+ throw new Error(`Two many-to-many relations resolve to the junction table "${junctionTable}" with different key columns: ${first.claim} and ${claim}. Only one table is created, so one of them would read and write columns that do not exist. Give the second its own junction — \`through: { table: "${sourceTable}_${toSnakeCase(relationName)}" }\` — or, if both are meant to be one link, declare it once.`);
1074
+ }
982
1075
  function planCollectionTable(collection, resolveCollection, triggerFunctionNeeded) {
983
1076
  const schema = schemaOf(collection);
984
1077
  const table = bareTableName(getTableName(collection));
@@ -1078,6 +1171,7 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
1078
1171
  const injected = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
1079
1172
  const policies = getEffectiveSecurityRules(collection).flatMap((rule, index) => compileSecurityRule(collection, rule, resolveCollection, Boolean(rule.name && injected.has(rule.name)), rule.name ?? `#${index}`));
1080
1173
  const tenantIndexes = applyTenantColumnEffects(collection, columns, schema, table);
1174
+ settleDefaultOnDelete(collection, columns, relations);
1081
1175
  return {
1082
1176
  schema,
1083
1177
  table,
@@ -1217,19 +1311,58 @@ function applyTenantColumnEffects(collection, columns, schema, table) {
1217
1311
  * column — for the shape the documentation recommends.
1218
1312
  */
1219
1313
  function findTenantColumn(collection, columns, field) {
1220
- const direct = columns.find((c) => c.key === field);
1314
+ const created = columns.filter((c) => !c.columnOwnedByProperty);
1315
+ const direct = created.find((c) => c.key === field);
1221
1316
  if (direct) return direct;
1222
1317
  const prop = collection.properties?.[field];
1223
1318
  if (prop?.type === "relation") {
1224
1319
  const relation = findRelation(resolveCollectionRelations(collection), prop.relation?.relationName ?? field);
1225
- if (relation?.kind === "belongsTo" && relation.localKey) return columns.find((c) => c.column === relation.localKey);
1320
+ if (relation?.kind === "belongsTo" && relation.localKey) return created.find((c) => c.column === relation.localKey);
1226
1321
  return;
1227
1322
  }
1228
1323
  if (!prop) return void 0;
1229
1324
  const columnName = resolveColumnName(field, prop);
1230
- return columns.find((c) => c.column === columnName);
1325
+ return created.find((c) => c.column === columnName);
1231
1326
  }
1232
1327
  /**
1328
+ * The `ON DELETE` a link gets when the author did not say, decided from the
1329
+ * column as it will be created.
1330
+ *
1331
+ * {@link planRelationColumn} and {@link planReferenceColumn} see only their own
1332
+ * property's `required`, and two later facts change the premise: `tenant` makes
1333
+ * its column NOT NULL, and a declared property that owns a relation's column
1334
+ * decides that column's nullability itself. Defaulting before either produced
1335
+ * `NOT NULL … ON DELETE SET NULL` — a parent's delete that can only fail, and
1336
+ * fails with 23502 naming the child's column rather than as the foreign-key
1337
+ * refusal it is. An `onDelete` the author wrote is theirs and is kept.
1338
+ */
1339
+ function settleDefaultOnDelete(collection, columns, relations) {
1340
+ for (const column of columns) {
1341
+ const fk = column.foreignKey;
1342
+ if (!fk || declaresOnDelete(collection, relations, column)) continue;
1343
+ const onDelete = defaultBelongsToOnDelete(!(columns.find((c) => c.column === column.column && !c.columnOwnedByProperty) ?? column).nullable);
1344
+ if (onDelete === fk.onDelete) continue;
1345
+ column.foreignKey = foreignKeyPlan({
1346
+ schema: fk.schema,
1347
+ table: fk.table,
1348
+ column: fk.column,
1349
+ targetSchema: fk.targetSchema,
1350
+ targetTable: fk.targetTable,
1351
+ targetColumn: fk.targetColumn,
1352
+ onDelete,
1353
+ onUpdate: fk.onUpdate
1354
+ });
1355
+ }
1356
+ }
1357
+ /** Whether the author wrote this link's `onDelete`. A `reference` has none to write. */
1358
+ var declaresOnDelete = (collection, relations, column) => {
1359
+ const propName = column.source.propName;
1360
+ if (column.source.kind !== "relation" || !propName) return false;
1361
+ const prop = collection.properties?.[propName];
1362
+ const relation = findRelation(relations, prop?.relation?.relationName ?? propName);
1363
+ return relation?.kind === "belongsTo" && relation.onDelete !== void 0;
1364
+ };
1365
+ /**
1233
1366
  * The column a `belongsTo` relation owns, or `undefined` when the relation puts
1234
1367
  * no column on this table.
1235
1368
  *
@@ -1266,7 +1399,7 @@ function planRelationColumn(propName, prop, collection, relations, schema, table
1266
1399
  column: relation.localKey,
1267
1400
  targetSchema: schemaOf(target),
1268
1401
  targetTable: bareTableName(getTableName(target)),
1269
- targetColumn: getPrimaryKeyName(target),
1402
+ targetColumn: getPrimaryKeyColumn(target),
1270
1403
  onDelete: relation.onDelete ?? defaultBelongsToOnDelete(required),
1271
1404
  onUpdate: relation.onUpdate
1272
1405
  }),
@@ -1295,7 +1428,7 @@ function planReferenceColumn(propName, prop, collection, resolveCollection, sche
1295
1428
  column,
1296
1429
  targetSchema: schemaOf(target),
1297
1430
  targetTable: bareTableName(getTableName(target)),
1298
- targetColumn: getPrimaryKeyName(target),
1431
+ targetColumn: getPrimaryKeyColumn(target),
1299
1432
  onDelete: defaultBelongsToOnDelete(required)
1300
1433
  }) : void 0,
1301
1434
  source: {
@@ -1356,7 +1489,7 @@ function planJunctionTable(tableName, relation, source, junctionSpecs, resolveCo
1356
1489
  column,
1357
1490
  targetSchema: schemaOf(collection),
1358
1491
  targetTable: bareTableName(getTableName(collection)),
1359
- targetColumn: getPrimaryKeyName(collection),
1492
+ targetColumn: getPrimaryKeyColumn(collection),
1360
1493
  onDelete
1361
1494
  }),
1362
1495
  source: { kind: "junction-key" }
@@ -1411,14 +1544,12 @@ function planRelations(collections, tableEntries) {
1411
1544
  const { relation, source } = entry.junction;
1412
1545
  if (!isManyToMany(relation)) continue;
1413
1546
  const target = relation.target();
1414
- const owningRelationName = relation.relationName ?? toSnakeCase(getTableName(target));
1415
- let inverseRelationName;
1547
+ const owningRelationName = sharedRelationName(relation, source);
1548
+ let inverse;
1416
1549
  try {
1417
- for (const targetRel of Object.values(resolveCollectionRelations(target))) if (targetRel.kind !== "belongsTo" && targetRel.cardinality === "many" && targetRel.relationName === owningRelationName) {
1418
- inverseRelationName = targetRel.relationName;
1419
- break;
1420
- }
1550
+ inverse = Object.values(resolveCollectionRelations(target)).find((r) => r.kind === "manyToMany" && bareTableName(r.through.table) === bareTableName(tableName));
1421
1551
  } catch {}
1552
+ const inverseRelationName = inverse ? sharedRelationName(inverse, target) : `${tableName}_${relation.through.targetColumn}`;
1422
1553
  plans.push({
1423
1554
  tableVar,
1424
1555
  key: relation.through.sourceColumn,
@@ -1428,12 +1559,13 @@ function planRelations(collections, tableEntries) {
1428
1559
  fields: [relation.through.sourceColumn],
1429
1560
  references: [getPrimaryKeyName(source)]
1430
1561
  });
1562
+ if (inverseRelationName === owningRelationName) continue;
1431
1563
  plans.push({
1432
1564
  tableVar,
1433
1565
  key: relation.through.targetColumn,
1434
1566
  kind: "one",
1435
1567
  targetVar: getTableVarName(getTableName(target)),
1436
- relationName: inverseRelationName ?? `${tableName}_${relation.through.targetColumn}`,
1568
+ relationName: inverseRelationName,
1437
1569
  fields: [relation.through.targetColumn],
1438
1570
  references: [getPrimaryKeyName(target)]
1439
1571
  });
@@ -1528,4 +1660,4 @@ function planRelations(collections, tableEntries) {
1528
1660
  //#endregion
1529
1661
  export { searchHelperFunctions as _, triggerStatements as a, searchStampGuards as b, SEARCH_TEXT_FN as c, fuzzyColumnDefinition as d, hiddenColumnsOption as f, searchExtensionStatements as g, searchColumnStamps as h, SET_UPDATED_AT_FN as i, SEARCH_UNACCENT_FN as l, searchColumnNames as m, planSchema as n, sharedRelationName as o, searchColumnDefinition as p, quoteSqlLiteral as r, SEARCH_STAMP_PREFIX as s, columnPgType as t, buildSearchColumnSpec as u, searchIndexNames as v, visibleColumnProjection as x, searchIndexStatements as y };
1530
1662
 
1531
- //# sourceMappingURL=plan-schema-Hgl62S-w.js.map
1663
+ //# sourceMappingURL=plan-schema-C0fxM8dY.js.map