@rebasepro/server-postgres 0.17.3 → 0.18.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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,31 +1,47 @@
1
1
  import { createRequire as __createRequire } from "module";
2
- import "process";
3
- __createRequire(import.meta.url);
4
- import { u as __exportAll } from "./connection-GOKU3Hu5.js";
5
- import { B as getPolicyNamesForRule, C as getTableName, I as generateForeignKeyName, L as legacyForeignKeyName, R as toPostgresIdentifier, T as resolveCollectionRelations, W as toSnakeCase, Z as isManyToMany, _ as securityRuleToConditions, b as findRelation, d as getJunctionCollectionConfig, f as getJunctionSecurityRules, g as policyToPostgres, h as getInjectedSecurityRules, i as collectionIndexStatements, m as getEffectiveSecurityRules, n as buildCollectionIndexSpecs, p as resolveJunctionSpecs, r as collectionIndexStatement, t as buildCollectionIndexPlan, u as relationalCollections } from "./collection-index-BRUg10H5.js";
6
- import { l as isPostgresCollectionConfig, r as REBASE_SCHEMA, t as declaredDatabaseExtensions } from "./src-DiB5RP2Z.js";
2
+ import process from "process";
3
+ const require = __createRequire(import.meta.url);
4
+ import { i as collectionIndexStatements, n as buildCollectionIndexSpecs, r as collectionIndexStatement, t as buildCollectionIndexPlan } from "./collection-index-C7LopMyX.js";
7
5
  import { isConcurrentDdlRace, isDuplicateObjectRace, logger } from "@rebasepro/server";
6
+ import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_WEIGHT, REBASE_SCHEMA, declaredDatabaseExtensions, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
7
+ import { findRelation, getEffectiveSecurityRules, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, policyToPostgres, relationalCollections, resolveCollectionRelations, resolveJunctionSpecs, resolveStringColumnLength, securityRuleToConditions } from "@rebasepro/common";
8
+ import { generateForeignKeyName, getPolicyNamesForRule, legacyForeignKeyName, toPostgresIdentifier, toSnakeCase } from "@rebasepro/utils";
8
9
  import { createHash } from "node:crypto";
9
- /**
10
- * How wide a `varchar`/`char` column should be for a given property.
11
- *
12
- * One definition, three call sites, because they used to disagree. For the same
13
- * `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
14
- * while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
15
- * reads as *unbounded* — so which of the two you ran decided whether the column
16
- * had a limit at all. Introspection then dropped the length entirely, so reading
17
- * an existing `character varying(500)` column back and regenerating it produced
18
- * a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
19
- *
20
- * `validation.max` is the property's own statement about how long the value may
21
- * be, so it is the only sensible source for the column's width — and it keeps
22
- * the constraint the database enforces in step with the one the app enforces,
23
- * rather than inventing a second, different limit underneath it.
24
- */
25
- function resolveStringColumnLength(prop) {
26
- const max = prop.validation?.max;
27
- return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 255;
28
- }
10
+ //#region \0rolldown/runtime.js
11
+ var __create = Object.create;
12
+ var __defProp = Object.defineProperty;
13
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
14
+ var __getOwnPropNames = Object.getOwnPropertyNames;
15
+ var __getProtoOf = Object.getPrototypeOf;
16
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
17
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
18
+ var __exportAll = (all, no_symbols) => {
19
+ let target = {};
20
+ for (var name in all) __defProp(target, name, {
21
+ get: all[name],
22
+ enumerable: true
23
+ });
24
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
25
+ return target;
26
+ };
27
+ var __copyProps = (to, from, except, desc) => {
28
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
29
+ key = keys[i];
30
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
31
+ get: ((k) => from[k]).bind(null, key),
32
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
33
+ });
34
+ }
35
+ return to;
36
+ };
37
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
38
+ value: mod,
39
+ enumerable: true
40
+ }) : target, mod));
41
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
42
+ if (typeof require !== "undefined") return require.apply(this, arguments);
43
+ throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
44
+ });
29
45
  //#endregion
30
46
  //#region src/schema/search-column.ts
31
47
  /**
@@ -184,7 +200,7 @@ var quote$1 = (v) => `'${v.replace(/'/g, "''")}'`;
184
200
  */
185
201
  var resolveField = (entry, collection, cfg) => {
186
202
  const path = typeof entry === "string" ? entry : entry.path;
187
- const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
203
+ const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? DEFAULT_SEARCH_WEIGHT;
188
204
  const where = `${collection.slug}.search`;
189
205
  if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
190
206
  const [head, ...rest] = path.split(".");
@@ -203,7 +219,7 @@ var resolveField = (entry, collection, cfg) => {
203
219
  jsonPath: rest,
204
220
  kind: classified.kind
205
221
  }), cfg.unaccent === true);
206
- const language = cfg.language ?? "simple";
222
+ const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;
207
223
  return {
208
224
  path,
209
225
  column,
@@ -227,7 +243,7 @@ var buildSearchColumnSpec = (collection) => {
227
243
  if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
228
244
  const table = getTableName(collection);
229
245
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
230
- const column = cfg.column ?? "search_vector";
246
+ const column = cfg.column ?? DEFAULT_SEARCH_COLUMN;
231
247
  if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
232
248
  const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
233
249
  const seen = /* @__PURE__ */ new Set();
@@ -242,7 +258,7 @@ var buildSearchColumnSpec = (collection) => {
242
258
  schema,
243
259
  table,
244
260
  column,
245
- language: cfg.language ?? "simple",
261
+ language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,
246
262
  unaccent: cfg.unaccent === true,
247
263
  fields,
248
264
  expression: fields.map((f) => f.sql).join(" || "),
@@ -256,7 +272,7 @@ var buildSearchColumnSpec = (collection) => {
256
272
  column: fuzzyColumn,
257
273
  expression: fields.map((f) => f.textSql).join(" || ' ' || "),
258
274
  indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
259
- threshold: cfg.fuzzyThreshold ?? .3
275
+ threshold: cfg.fuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD
260
276
  };
261
277
  }
262
278
  return spec;
@@ -440,6 +456,284 @@ var excludedColumnNames = (tableColumns, collection) => {
440
456
  return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
441
457
  };
442
458
  //#endregion
459
+ //#region src/utils/pg-error-utils.ts
460
+ /**
461
+ * Shared PostgreSQL error extraction and user-friendly message formatting.
462
+ *
463
+ * Drizzle wraps native PG errors in a `.cause` chain. These utilities
464
+ * unwrap that chain to get the real PostgreSQL error (identified by a
465
+ * 5-character alphanumeric `code` such as `42P01`) and translate it into
466
+ * a message that is safe and helpful to show to end-users.
467
+ */
468
+ /**
469
+ * Return the error when it is a deliberate 4xx, otherwise null.
470
+ *
471
+ * A thrown `ApiError` is a decision the server made about the request, not a
472
+ * database failure: its message and code are already written for the client.
473
+ */
474
+ function asClientFacingError(error) {
475
+ if (!(error instanceof Error)) return null;
476
+ const e = error;
477
+ if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
478
+ return e;
479
+ }
480
+ /**
481
+ * Extract the underlying PostgreSQL error from a Drizzle wrapper.
482
+ * Drizzle wraps PG errors in a `cause` property — this function
483
+ * recursively walks the chain until it finds an object with a PG
484
+ * error code (5-char alphanumeric, e.g. `42P01`).
485
+ */
486
+ function extractPgError(error) {
487
+ if (!error || typeof error !== "object") return null;
488
+ if (!(error instanceof Error)) {
489
+ if ("cause" in error && error.cause && typeof error.cause === "object") return extractPgError(error.cause);
490
+ return null;
491
+ }
492
+ if ("code" in error && typeof error.code === "string" && /^[0-9A-Z]{5}$/.test(error.code)) return error;
493
+ if (error.cause && typeof error.cause === "object") return extractPgError(error.cause);
494
+ return null;
495
+ }
496
+ /**
497
+ * Whether the failure came back from Postgres rather than from building the
498
+ * query — which decides whether a fallback query is worth issuing.
499
+ *
500
+ * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
501
+ * RLS). Once a statement raises, that transaction is aborted, and every later
502
+ * statement on it returns `25P02` — "current transaction is aborted, commands
503
+ * ignored until end of transaction block". So a retry after a database error
504
+ * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
505
+ * for type uuid") with a generic one. Rethrow instead.
506
+ *
507
+ * A query the driver could not even build — a missing reciprocal relation, say
508
+ * — never reached Postgres, leaves the transaction usable, and is exactly what
509
+ * the fallback paths exist for.
510
+ */
511
+ function reachedDatabase(error) {
512
+ return extractPgError(error) !== null;
513
+ }
514
+ /**
515
+ * Walk the error cause chain and return the deepest meaningful message.
516
+ */
517
+ function extractCauseMessage(error) {
518
+ if (!error || typeof error !== "object") return null;
519
+ if (!(error instanceof Error)) return null;
520
+ if (error.cause && typeof error.cause === "object") {
521
+ const deeper = extractCauseMessage(error.cause);
522
+ if (deeper) return deeper;
523
+ if (error.cause instanceof Error && error.cause.message) return error.cause.message;
524
+ }
525
+ return null;
526
+ }
527
+ /**
528
+ * Codes that mean "this connection will never work as configured".
529
+ *
530
+ * A wrong password or a database that does not exist is a settled fact about
531
+ * the connection string, not a transient fault — retrying produces the same
532
+ * answer forever.
533
+ */
534
+ var UNRECOVERABLE_CONNECT_CODES = /* @__PURE__ */ new Set([
535
+ "28P01",
536
+ "28000",
537
+ "3D000",
538
+ "42501"
539
+ ]);
540
+ /**
541
+ * Describe a failed connection attempt in terms a developer can act on.
542
+ *
543
+ * The error a caller catches is Drizzle's wrapper: its message is
544
+ * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
545
+ * the sentence that says what is actually wrong — "password authentication
546
+ * failed for user …", "database … does not exist" — sits in `.cause`. Logging
547
+ * the wrapper, as the bootstrapper used to, tells a developer with a typo in
548
+ * their `DATABASE_URL` nothing at all.
549
+ */
550
+ function classifyConnectFailure(error) {
551
+ const pgError = extractPgError(error);
552
+ const reason = pgError?.message ?? extractCauseMessage(error) ?? (error instanceof Error ? error.message : String(error));
553
+ return {
554
+ fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
555
+ reason,
556
+ code: pgError?.code
557
+ };
558
+ }
559
+ /**
560
+ * Detect whether an error is specifically a role-switching permission failure
561
+ * (e.g. "permission denied to set role" or "must be member of role"),
562
+ * as opposed to a table-level permission denial.
563
+ *
564
+ * This is used by the backend driver to auto-disable role switching when the
565
+ * connection user lacks SET ROLE privileges, rather than surfacing a confusing
566
+ * error to the Studio SQL Editor user.
567
+ */
568
+ function isRoleSwitchingPermissionError(error) {
569
+ const pgError = extractPgError(error);
570
+ if (!pgError || pgError.code !== "42501") return false;
571
+ const msg = pgError.message.toLowerCase();
572
+ return msg.includes("set role") || msg.includes("member of role");
573
+ }
574
+ /**
575
+ * Was this `42501` the *caller* being refused by a policy, rather than the
576
+ * server lacking a privilege?
577
+ *
578
+ * Both arrive as `insufficient_privilege`, and they are opposite kinds of
579
+ * problem. A row-level-security refusal is a working access-control system
580
+ * doing its job: the caller asked for something their policies do not permit,
581
+ * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
582
+ * wrong — the connection role cannot touch the table at all, no policy is
583
+ * involved, and nothing the caller changes about the request will help.
584
+ *
585
+ * Postgres distinguishes them in the message, so this does too:
586
+ *
587
+ * new row violates row-level security policy for table "notes" → the caller
588
+ * permission denied for table notes → the server
589
+ *
590
+ * Only writes reach this. A read that RLS excludes is not an error — the rows
591
+ * are filtered and the caller gets an empty page — so the erroring case is
592
+ * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
593
+ *
594
+ * Matched on the message because that is the only thing carrying the
595
+ * distinction; the SQLSTATE is identical either way. Narrow by design: anything
596
+ * not naming row-level security stays the server's problem, since reporting a
597
+ * genuine privilege misconfiguration as "forbidden" would send an operator
598
+ * hunting for a policy bug that does not exist.
599
+ */
600
+ function isRowLevelSecurityDenial(error) {
601
+ const pgError = extractPgError(error);
602
+ if (!pgError || pgError.code !== "42501") return false;
603
+ return pgError.message.toLowerCase().includes("row-level security policy");
604
+ }
605
+ /**
606
+ * Translate a raw PostgreSQL error into a user-friendly message.
607
+ *
608
+ * @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
609
+ * @param context - A human-readable context string (e.g. collection slug or path)
610
+ * @returns An object with a `message` safe for the client and the PG `code`.
611
+ */
612
+ function pgErrorToFriendlyMessage(pgError, context, options = {}) {
613
+ const verbose = options.verbose ?? process.env.NODE_ENV !== "production";
614
+ const detail = verbose ? pgError.detail : void 0;
615
+ const hint = verbose ? pgError.hint : void 0;
616
+ const constraint = pgError.constraint;
617
+ const column = pgError.column;
618
+ const table = pgError.table;
619
+ const dataType = verbose ? pgError.dataType : void 0;
620
+ const rawMessage = pgError.message || "Unknown database error";
621
+ const pgMessage = verbose ? rawMessage : "";
622
+ const code = pgError.code || "UNKNOWN";
623
+ const suffix = hint ? ` Hint: ${hint}` : "";
624
+ const tableRef = table ?? context;
625
+ /** `": <postgres said>"`, or nothing when the details are withheld. */
626
+ const said = pgMessage ? `: ${pgMessage}` : "";
627
+ switch (pgError.code) {
628
+ case "23503": return {
629
+ message: detail ? `Foreign key constraint violated: ${detail}${suffix}` : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
630
+ code
631
+ };
632
+ case "23505": return {
633
+ message: detail ? `Duplicate value: ${detail}${suffix}` : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
634
+ code
635
+ };
636
+ case "23502": return {
637
+ message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
638
+ code
639
+ };
640
+ case "23514": return {
641
+ message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
642
+ code
643
+ };
644
+ case "22P02": return {
645
+ message: `Invalid data format in "${context}"${said}.${suffix}`,
646
+ code
647
+ };
648
+ case "22001": return {
649
+ message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}"${said}.${suffix}`,
650
+ code
651
+ };
652
+ case "22003": return {
653
+ message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}"${said}.${suffix}`,
654
+ code
655
+ };
656
+ case "42703": return {
657
+ message: `Unknown column in "${tableRef}"${said}. Check if your schema is up to date (run migrations).${suffix}`,
658
+ code
659
+ };
660
+ case "42P01": return {
661
+ message: `Table not found for "${context}"${said}. Check if your schema is up to date (run migrations).${suffix}`,
662
+ code
663
+ };
664
+ case "42501": return rawMessage.toLowerCase().includes("row-level security policy") ? {
665
+ message: `Not permitted to write this row in "${tableRef}": it does not satisfy the row-level security policy.${suffix}`,
666
+ code
667
+ } : {
668
+ message: `Permission denied on "${tableRef}": the database role this server connects as lacks privileges on it.${suffix}`,
669
+ code
670
+ };
671
+ case "28000": return {
672
+ message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
673
+ code
674
+ };
675
+ default: {
676
+ const parts = [`Database error in "${context}" [${code}]${said}`];
677
+ if (detail) parts.push(`Detail: ${detail}`);
678
+ if (column) parts.push(`Column: ${column}`);
679
+ if (dataType) parts.push(`Data type: ${dataType}`);
680
+ if (constraint) parts.push(`Constraint: ${constraint}`);
681
+ if (hint) parts.push(`Hint: ${hint}`);
682
+ return {
683
+ message: parts.join(". "),
684
+ code
685
+ };
686
+ }
687
+ }
688
+ }
689
+ /**
690
+ * Sanitize any error into a message safe and helpful for the client.
691
+ *
692
+ * A deliberate 4xx (`ApiError`) passes through untouched — the server already
693
+ * decided what the client should read. Otherwise the PG error is extracted
694
+ * from the Drizzle cause chain, falling back to a generic message that
695
+ * doesn't leak SQL.
696
+ *
697
+ * @param error - The raw caught error
698
+ * @param context - A human-readable context string (e.g. collection path)
699
+ * @returns An object with `message` (user-friendly) and optional `code`
700
+ * (the `ApiError` code, or the PG SQLSTATE).
701
+ */
702
+ function sanitizeErrorForClient(error, context) {
703
+ const clientError = asClientFacingError(error);
704
+ if (clientError) {
705
+ const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
706
+ if (clientError.expected) logger.debug(line);
707
+ else logger.warn(`⚠️ ${line}`);
708
+ return {
709
+ message: clientError.message,
710
+ ...clientError.code && { code: clientError.code }
711
+ };
712
+ }
713
+ const pgError = extractPgError(error);
714
+ if (pgError) {
715
+ logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
716
+ code: pgError.code,
717
+ message: pgError.message,
718
+ detail: pgError.detail,
719
+ hint: pgError.hint,
720
+ column: pgError.column,
721
+ table: pgError.table,
722
+ constraint: pgError.constraint,
723
+ dataType: pgError.dataType
724
+ });
725
+ return pgErrorToFriendlyMessage(pgError, context);
726
+ }
727
+ logger.error(`Database error in "${context}" (no PG error extracted)`, {
728
+ error: error instanceof Error ? error.message : String(error),
729
+ stack: error instanceof Error ? error.stack : void 0,
730
+ cause: error instanceof Error && error.cause ? error.cause instanceof Error ? error.cause.message : String(error.cause) : void 0
731
+ });
732
+ const causeMessage = extractCauseMessage(error);
733
+ if (causeMessage) return { message: `Database error in "${context}": ${causeMessage}` };
734
+ return { message: `Could not load data for "${context}". Check server logs for details.` };
735
+ }
736
+ //#endregion
443
737
  //#region src/schema/auth-users-columns.ts
444
738
  /**
445
739
  * `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
@@ -864,6 +1158,34 @@ var isIdProperty = (propName, prop, collection) => {
864
1158
  * the generated `policies.sql` emit the same SQL, from the same source.
865
1159
  */
866
1160
  var statementsToDdl = (statements) => statements.map((s) => `${s}\n`).join("");
1161
+ /**
1162
+ * The `ON DELETE` a foreign key gets when the author did not say.
1163
+ *
1164
+ * One rule for every property that emits one — `belongsTo` relations and
1165
+ * `reference` properties alike. `reference` kept its own `CASCADE` default for
1166
+ * a release after this rule was written, which is exactly the split this
1167
+ * function exists to prevent: two spellings of the same link, two data-retention
1168
+ * behaviours, and only one of them documented.
1169
+ *
1170
+ * An optional link is `SET NULL`: the column can hold NULL, so dropping the
1171
+ * parent leaves the child row with an empty pointer, which is what "optional"
1172
+ * already means.
1173
+ *
1174
+ * A required link is **`RESTRICT`**, not `CASCADE`. `NOT NULL` says the child
1175
+ * cannot exist without a parent; it does not say deleting the parent should
1176
+ * take the child with it. That second claim is a data-retention decision, and
1177
+ * defaulting to it meant `onDelete` — a field nobody has to write — silently
1178
+ * turned every `DELETE FROM authors` into a cascade through posts, comments and
1179
+ * anything else that hung off them. `RESTRICT` fails the delete instead and
1180
+ * names the constraint, so the author chooses `cascade` on purpose or clears
1181
+ * the children first.
1182
+ *
1183
+ * Both DDL paths — the `CREATE TABLE` generator and {@link planRelationalColumns},
1184
+ * which boot-ensure compares the live database against — call this, so a
1185
+ * default that appears in one and not the other cannot make `db push` plan a
1186
+ * constraint rewrite on every run.
1187
+ */
1188
+ var defaultBelongsToOnDelete = (required) => required ? "RESTRICT" : "SET NULL";
867
1189
  var generatePolicyDdl = (collection, rule, resolveCollection) => statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
868
1190
  /**
869
1191
  * The individual SQL statements a single security rule compiles to: a
@@ -1234,7 +1556,7 @@ var generatePostgresDdl = async (allCollections, options = {
1234
1556
  const fkColType = getSqlColumnType(propName, prop, collection, collections);
1235
1557
  const onUpdate = relInfo.onUpdate ? ` ON UPDATE ${relInfo.onUpdate.toUpperCase()}` : "";
1236
1558
  const required = prop.validation?.required;
1237
- const onDeleteVal = relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL");
1559
+ const onDeleteVal = relInfo.onDelete ?? defaultBelongsToOnDelete(required);
1238
1560
  let colDef = ` "${relInfo.localKey}" ${fkColType}`;
1239
1561
  if (required) colDef += " NOT NULL";
1240
1562
  columns.push(colDef);
@@ -1253,7 +1575,7 @@ var generatePostgresDdl = async (allCollections, options = {
1253
1575
  const targetTable = getTableName(targetCollection);
1254
1576
  const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
1255
1577
  const targetId = getPrimaryKeyName(targetCollection);
1256
- const onDelete = required ? "CASCADE" : "SET NULL";
1578
+ const onDelete = defaultBelongsToOnDelete(required);
1257
1579
  let colDef = ` "${colName}" ${colType}`;
1258
1580
  if (required) colDef += " NOT NULL";
1259
1581
  columns.push(colDef);
@@ -1420,7 +1742,7 @@ var planRelationalColumns = (allCollections) => {
1420
1742
  targetSchema: schemaOfCollection(targetCollection),
1421
1743
  targetTable: bareTableName(getTableName(targetCollection)),
1422
1744
  targetColumn: getPrimaryKeyName(targetCollection),
1423
- onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
1745
+ onDelete: relInfo.onDelete ?? defaultBelongsToOnDelete(required),
1424
1746
  onUpdate: relInfo.onUpdate
1425
1747
  })
1426
1748
  });
@@ -1442,7 +1764,7 @@ var planRelationalColumns = (allCollections) => {
1442
1764
  targetSchema: schemaOfCollection(targetCollection),
1443
1765
  targetTable: bareTableName(getTableName(targetCollection)),
1444
1766
  targetColumn: getPrimaryKeyName(targetCollection),
1445
- onDelete: required ? "CASCADE" : "SET NULL"
1767
+ onDelete: defaultBelongsToOnDelete(required)
1446
1768
  }) : void 0
1447
1769
  });
1448
1770
  }
@@ -1599,6 +1921,111 @@ var generatePostgresPoliciesDdl = (allCollections) => {
1599
1921
  return ddl;
1600
1922
  };
1601
1923
  //#endregion
1924
+ //#region src/schema/column-type-drift.ts
1925
+ /**
1926
+ * Types that mean the same thing to everything above the driver.
1927
+ *
1928
+ * Keyed by every spelling either side can produce: `information_schema` reports
1929
+ * `udt_name` (`int4`, `_numeric`, `bpchar`), while the generators emit SQL
1930
+ * (`INTEGER`, `NUMERIC[]`, `CHAR(3)`). Both are normalised through here.
1931
+ */
1932
+ var FAMILIES = {
1933
+ int2: "number",
1934
+ smallint: "number",
1935
+ int4: "number",
1936
+ int: "number",
1937
+ integer: "number",
1938
+ int8: "number",
1939
+ bigint: "number",
1940
+ numeric: "number",
1941
+ decimal: "number",
1942
+ float4: "number",
1943
+ real: "number",
1944
+ float8: "number",
1945
+ "double precision": "number",
1946
+ money: "number",
1947
+ text: "text",
1948
+ varchar: "text",
1949
+ "character varying": "text",
1950
+ bpchar: "text",
1951
+ char: "text",
1952
+ character: "text",
1953
+ citext: "text",
1954
+ bool: "boolean",
1955
+ boolean: "boolean",
1956
+ json: "json",
1957
+ jsonb: "jsonb",
1958
+ uuid: "uuid",
1959
+ bytea: "bytea",
1960
+ date: "date",
1961
+ time: "time",
1962
+ "time without time zone": "time",
1963
+ timetz: "time",
1964
+ "time with time zone": "time",
1965
+ timestamp: "timestamp",
1966
+ "timestamp without time zone": "timestamp",
1967
+ timestamptz: "timestamptz",
1968
+ "timestamp with time zone": "timestamptz",
1969
+ interval: "interval",
1970
+ tsvector: "tsvector",
1971
+ vector: "vector",
1972
+ halfvec: "halfvec",
1973
+ sparsevec: "sparsevec"
1974
+ };
1975
+ /**
1976
+ * One SQL or `udt_name` spelling reduced to its family, or `null` when this
1977
+ * module has no opinion about it.
1978
+ *
1979
+ * `null` is not a failure. It is the answer for an enum type, a domain, a
1980
+ * PostGIS column, a quoted qualified name — everything whose identity is a
1981
+ * *name* rather than a shape, where two spellings being different says nothing
1982
+ * about whether the values are compatible.
1983
+ */
1984
+ function typeFamily(raw) {
1985
+ if (!raw) return null;
1986
+ let type = raw.trim().toLowerCase();
1987
+ if (!type) return null;
1988
+ if (type.startsWith("\"")) return null;
1989
+ const generated = type.indexOf(" generated ");
1990
+ if (generated > 0) type = type.slice(0, generated).trim();
1991
+ if (type.endsWith("[]")) {
1992
+ const element = typeFamily(type.slice(0, -2));
1993
+ return element && `array:${element}`;
1994
+ }
1995
+ if (type.startsWith("_")) {
1996
+ const element = typeFamily(type.slice(1));
1997
+ return element && `array:${element}`;
1998
+ }
1999
+ const paren = type.indexOf("(");
2000
+ if (paren > 0) type = type.slice(0, paren).trim();
2001
+ return FAMILIES[type] ?? null;
2002
+ }
2003
+ /**
2004
+ * Do these two spellings describe columns that hold the same kind of value?
2005
+ *
2006
+ * `true` whenever the answer is not confidently "no" — an unrecognised type on
2007
+ * either side agrees with everything, because a report needs evidence and an
2008
+ * unknown type is the absence of it.
2009
+ */
2010
+ function typesAgree(declared, actual) {
2011
+ const a = typeFamily(declared);
2012
+ const b = typeFamily(actual);
2013
+ if (a === null || b === null) return true;
2014
+ return a === b;
2015
+ }
2016
+ /**
2017
+ * The sentence a boot log says about one drifted column.
2018
+ *
2019
+ * Written to be actionable in the place it is read, which is a runtime log with
2020
+ * no way to run a migration from it: it names both types, says which surface
2021
+ * will break first, and gives the statement that resolves it.
2022
+ */
2023
+ function columnTypeDriftMessage(drift) {
2024
+ const [schema, table] = drift.table.split(".");
2025
+ return `Column "${drift.table}"."${drift.column}" is ${drift.actual} in the database, but this collection declares ${drift.declared}. Boot does not change an existing column's type, so the database keeps what it has and writes that match the declaration will be rejected by Postgres. Change it deliberately, when you can watch it:
2026
+ ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${drift.column}" TYPE ${drift.declared} USING "${drift.column}"::${drift.declared};`;
2027
+ }
2028
+ //#endregion
1602
2029
  //#region src/schema/ensure-collection-tables.ts
1603
2030
  /**
1604
2031
  * Bringing a database up to date with a bundle's collections, additively.
@@ -1628,6 +2055,7 @@ var generatePostgresPoliciesDdl = (allCollections) => {
1628
2055
  * no-op.
1629
2056
  */
1630
2057
  var ensure_collection_tables_exports = /* @__PURE__ */ __exportAll({
2058
+ describeDriverError: () => describeDriverError,
1631
2059
  ensureCollectionTables: () => ensureCollectionTables,
1632
2060
  planCollectionSchemaEnsure: () => planCollectionSchemaEnsure,
1633
2061
  readExistingSchema: () => readExistingSchema,
@@ -1639,11 +2067,33 @@ function assertSafeIdentifier(value, what) {
1639
2067
  if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
1640
2068
  return value;
1641
2069
  }
2070
+ /**
2071
+ * The schema a collection lives in — validated, because it is about to be
2072
+ * interpolated into DDL.
2073
+ *
2074
+ * Validation used to run only on the names read back OUT of the catalogue,
2075
+ * which is the direction that cannot hurt anyone: those came from Postgres. The
2076
+ * names going IN — the schema and table a collection declares — were quoted and
2077
+ * concatenated on trust, and quoting is not escaping. A table named
2078
+ * `x" (id int); DROP TABLE users; --` closes the quote and the statement, and
2079
+ * the whole thing runs on the owner connection, which is the one connection in
2080
+ * the system that is exempt from every RLS policy.
2081
+ *
2082
+ * That is only a config file on a self-hosted project, where the person writing
2083
+ * it could run the SQL directly anyway. It is not only a config file where a
2084
+ * collection can be defined over the wire — the live-schema and source editors
2085
+ * both do that — and there the caller is an admin on the app, not an operator
2086
+ * of the database.
2087
+ */
1642
2088
  function schemaOf(collection) {
1643
- return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
2089
+ return assertSafeIdentifier(isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public", "schema name");
2090
+ }
2091
+ /** A collection's table name, validated for the same reason as {@link schemaOf}. */
2092
+ function tableOf(collection) {
2093
+ return assertSafeIdentifier(getTableName(collection), "table name");
1644
2094
  }
1645
2095
  function qualified(collection) {
1646
- return `${schemaOf(collection)}.${getTableName(collection)}`;
2096
+ return `${schemaOf(collection)}.${tableOf(collection)}`;
1647
2097
  }
1648
2098
  /**
1649
2099
  * Enum types a collection's properties require, as `schema.typename`.
@@ -1654,7 +2104,7 @@ function qualified(collection) {
1654
2104
  * silent schema fork.
1655
2105
  */
1656
2106
  function requiredEnums(collection) {
1657
- const table = getTableName(collection);
2107
+ const table = tableOf(collection);
1658
2108
  const schema = schemaOf(collection);
1659
2109
  const out = [];
1660
2110
  for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
@@ -1680,6 +2130,7 @@ function requiredEnums(collection) {
1680
2130
  function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1681
2131
  const constraintPolicy = options.constraints ?? "additive";
1682
2132
  const withheldConstraints = [];
2133
+ const columnTypeDrift = [];
1683
2134
  assertSearchIsPostgresOnly(allCollections);
1684
2135
  const collections = relationalCollections(allCollections);
1685
2136
  const actions = [];
@@ -1736,11 +2187,12 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1736
2187
  if (existing.tables.has(key) || created.has(key)) continue;
1737
2188
  created.add(key);
1738
2189
  const schema = schemaOf(collection);
1739
- const table = getTableName(collection);
2190
+ const table = tableOf(collection);
1740
2191
  const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) => isIdProperty(n, p, collection));
1741
2192
  const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1]) : "id";
1742
2193
  const idProp = idEntry?.[1];
1743
- let idDef = `"${idName}" ${idProp ? getSqlColumnType(idEntry[0], idProp, collection, collections) : "TEXT"} PRIMARY KEY`;
2194
+ const idType = idProp ? getSqlColumnType(idEntry[0], idProp, collection, collections) : "TEXT";
2195
+ let idDef = `"${assertSafeIdentifier(idName, "column name")}" ${idType} PRIMARY KEY`;
1744
2196
  if (idProp?.type === "string" && idProp.isId === "uuid") idDef += " DEFAULT gen_random_uuid()";
1745
2197
  actions.push({
1746
2198
  kind: "create-table",
@@ -1791,6 +2243,7 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1791
2243
  };
1792
2244
  const addColumn = (key, schema, table, column, definition) => {
1793
2245
  if (existing.tables.get(key)?.has(column)) return;
2246
+ assertSafeIdentifier(column, "column name");
1794
2247
  actions.push({
1795
2248
  kind: "add-column",
1796
2249
  target: `${key}.${column}`,
@@ -1800,7 +2253,7 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1800
2253
  for (const collection of collections) {
1801
2254
  const key = qualified(collection);
1802
2255
  const schema = schemaOf(collection);
1803
- const table = getTableName(collection);
2256
+ const table = tableOf(collection);
1804
2257
  const fresh = created.has(key);
1805
2258
  const auth = isAuthCollection(collection);
1806
2259
  for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
@@ -1813,7 +2266,8 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1813
2266
  addColumn(key, schema, table, column, authDefinition);
1814
2267
  continue;
1815
2268
  }
1816
- let definition = getSqlColumnType(propName, p, collection, collections);
2269
+ const declaredType = getSqlColumnType(propName, p, collection, collections);
2270
+ let definition = declaredType;
1817
2271
  if (fresh && p.validation?.unique) definition += " UNIQUE";
1818
2272
  const autoValue = p.autoValue;
1819
2273
  const hasDefault = p.type === "date" && (autoValue === "on_create" || autoValue === "on_update");
@@ -1821,6 +2275,13 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1821
2275
  const required = p.validation?.required === true;
1822
2276
  const columnKey = `${key}.${column}`;
1823
2277
  const columnExists = existing.tables.get(key)?.has(column) === true;
2278
+ const actualType = existing.columnTypes?.get(columnKey);
2279
+ if (columnExists && actualType && !typesAgree(declaredType, actualType)) columnTypeDrift.push({
2280
+ table: key,
2281
+ column,
2282
+ declared: declaredType,
2283
+ actual: actualType
2284
+ });
1824
2285
  const tableIsEmpty = existing.populatedTables !== void 0 && existing.tables.has(key) && !existing.populatedTables.has(key);
1825
2286
  const notNullIsSafe = fresh || tableIsEmpty || hasDefault;
1826
2287
  if (required && !columnExists) if (notNullIsSafe) definition += " NOT NULL";
@@ -1903,8 +2364,12 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1903
2364
  addColumn(key, junction.schema, junction.table, column.name, column.type);
1904
2365
  }
1905
2366
  }
2367
+ const plannedRelationColumns = /* @__PURE__ */ new Map();
1906
2368
  for (const relational of planRelationalColumns(collections)) {
1907
2369
  const relKey = `${relational.schema}.${relational.table}`;
2370
+ if (!plannedRelationColumns.has(relKey)) plannedRelationColumns.set(relKey, /* @__PURE__ */ new Set());
2371
+ plannedRelationColumns.get(relKey).add(relational.column);
2372
+ if (relational.legacyColumn) plannedRelationColumns.get(relKey).add(relational.legacyColumn);
1908
2373
  if (renameLegacyColumn(relKey, relational.schema, relational.table, relational.column, relational.legacyColumn)) continue;
1909
2374
  addColumn(relKey, relational.schema, relational.table, relational.column, relational.type);
1910
2375
  }
@@ -1922,10 +2387,11 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1922
2387
  sql: fk.sql
1923
2388
  });
1924
2389
  }
2390
+ const concurrentIndexing = (schema, table) => !created.has(`${schema}.${table}`);
1925
2391
  for (const spec of searchSpecs) for (const statement of searchIndexStatements(spec)) actions.push({
1926
2392
  kind: "create-index",
1927
2393
  target: `${spec.schema}.${spec.table}`,
1928
- sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
2394
+ sql: concurrentIndexing(spec.schema, spec.table) ? statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : statement
1929
2395
  });
1930
2396
  const vectorIndexSkipped = [];
1931
2397
  for (const collection of collections) {
@@ -1933,7 +2399,7 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1933
2399
  for (const spec of plan.specs) actions.push({
1934
2400
  kind: "create-index",
1935
2401
  target: `${spec.schema}.${spec.table}`,
1936
- sql: vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
2402
+ sql: concurrentIndexing(spec.schema, spec.table) ? vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : vectorIndexStatement(spec)
1937
2403
  });
1938
2404
  vectorIndexSkipped.push(...plan.skipped);
1939
2405
  }
@@ -1941,10 +2407,30 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1941
2407
  kind: "create-index",
1942
2408
  target: `${spec.schema}.${spec.table}`,
1943
2409
  sql: collectionIndexStatement(spec, {
1944
- concurrently: true,
2410
+ concurrently: concurrentIndexing(spec.schema, spec.table),
1945
2411
  ifNotExists: true
1946
2412
  })
1947
2413
  });
2414
+ const orphanedRequiredColumns = [];
2415
+ if (existing.notNullColumns && existing.columnDefaults) for (const collection of collections) {
2416
+ const key = qualified(collection);
2417
+ const live = existing.tables.get(key);
2418
+ if (!live || created.has(key)) continue;
2419
+ const declared = /* @__PURE__ */ new Set();
2420
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) declared.add(resolveColumnName(propName, prop));
2421
+ for (const column of plannedRelationColumns.get(key) ?? []) declared.add(column);
2422
+ for (const column of live) {
2423
+ const columnKey = `${key}.${column}`;
2424
+ if (declared.has(column)) continue;
2425
+ if (!existing.notNullColumns.has(columnKey)) continue;
2426
+ if (existing.columnDefaults.has(columnKey)) continue;
2427
+ orphanedRequiredColumns.push({
2428
+ table: key,
2429
+ column,
2430
+ slug: collection.slug
2431
+ });
2432
+ }
2433
+ }
1948
2434
  return {
1949
2435
  actions,
1950
2436
  statements: actions.map((a) => a.sql),
@@ -1952,7 +2438,9 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
1952
2438
  searchDrift,
1953
2439
  searchAdopted,
1954
2440
  vectorIndexSkipped,
1955
- withheldConstraints
2441
+ withheldConstraints,
2442
+ columnTypeDrift,
2443
+ orphanedRequiredColumns
1956
2444
  };
1957
2445
  }
1958
2446
  /** Read what the database has, for the schemas the collections live in. */
@@ -1965,7 +2453,9 @@ async function readExistingSchema(client, schemas) {
1965
2453
  };
1966
2454
  const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
1967
2455
  const notNullColumns = /* @__PURE__ */ new Set();
1968
- const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name, is_nullable
2456
+ const columnTypes = /* @__PURE__ */ new Map();
2457
+ const columnDefaults = /* @__PURE__ */ new Set();
2458
+ const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name, is_nullable, udt_name, column_default
1969
2459
  FROM information_schema.columns
1970
2460
  WHERE table_schema IN (${inList})`);
1971
2461
  for (const row of columns) {
@@ -1973,6 +2463,8 @@ async function readExistingSchema(client, schemas) {
1973
2463
  if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
1974
2464
  tables.get(key).add(row.column_name);
1975
2465
  if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
2466
+ if (row.udt_name) columnTypes.set(`${key}.${row.column_name}`, row.udt_name);
2467
+ if (row.column_default !== null) columnDefaults.add(`${key}.${row.column_name}`);
1976
2468
  }
1977
2469
  const populatedTables = /* @__PURE__ */ new Set();
1978
2470
  const { rows: realTables } = await client.query(`SELECT n.nspname AS schema, c.relname AS name
@@ -2030,7 +2522,9 @@ async function readExistingSchema(client, schemas) {
2030
2522
  columnComments,
2031
2523
  enumValues,
2032
2524
  notNullColumns,
2033
- populatedTables
2525
+ populatedTables,
2526
+ columnTypes,
2527
+ columnDefaults
2034
2528
  };
2035
2529
  }
2036
2530
  /**
@@ -2097,6 +2591,18 @@ async function ensureCollectionTables(client, collections, log, options = {}) {
2097
2591
  logger.warn(`[schema] ${message}`);
2098
2592
  log?.(message);
2099
2593
  }
2594
+ for (const drift of plan.columnTypeDrift) {
2595
+ const message = columnTypeDriftMessage(drift);
2596
+ logger.warn(`[schema] ${message}`);
2597
+ log?.(message);
2598
+ }
2599
+ for (const orphan of plan.orphanedRequiredColumns) {
2600
+ const [schema, table] = orphan.table.split(".");
2601
+ const message = `"${orphan.table}"."${orphan.column}" is NOT NULL with no default, and the "${orphan.slug}" collection no longer declares it. Every insert will be rejected for a field you cannot set (PG_23502, naming "${orphan.column}"). This is what a renamed property looks like: the new column was added, and the old one cannot be dropped unattended. Fix it with ONE of:
2602
+ ALTER TABLE "${schema}"."${table}" DROP COLUMN "${orphan.column}"; -- the rename is complete\n ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${orphan.column}" DROP NOT NULL; -- keep the data, stop requiring it\n ...or declare the property again, if the column is still wanted.`;
2603
+ logger.warn(`[schema] ${message}`);
2604
+ log?.(message);
2605
+ }
2100
2606
  if (plan.actions.length === 0) {
2101
2607
  log?.("Schema is up to date; nothing to create.");
2102
2608
  return {
@@ -2108,8 +2614,8 @@ async function ensureCollectionTables(client, collections, log, options = {}) {
2108
2614
  if (await applyAction(client, action)) log?.(`${action.kind}: ${action.target}`);
2109
2615
  else log?.(`${action.kind}: ${action.target} (already created by a peer)`);
2110
2616
  } catch (err) {
2111
- const message = err instanceof Error ? err.message : String(err);
2112
- if (action.kind === "add-constraint" || action.kind === "comment-column") {
2617
+ const message = describeDriverError(err);
2618
+ if (action.kind === "add-constraint" || action.kind === "comment-column" || action.kind === "create-index" && !/CREATE\s+UNIQUE\s+INDEX/i.test(action.sql)) {
2113
2619
  failures.push({
2114
2620
  kind: action.kind,
2115
2621
  target: action.target,
@@ -2124,6 +2630,56 @@ async function ensureCollectionTables(client, collections, log, options = {}) {
2124
2630
  failures
2125
2631
  };
2126
2632
  }
2633
+ /**
2634
+ * What actually went wrong, rather than what the ORM said about it.
2635
+ *
2636
+ * Drizzle wraps every driver error, and its `message` is literally
2637
+ * ``Failed query: ${sql}\nparams: ${params}`` — the statement we already know,
2638
+ * and not one word of Postgres's answer. Reporting `err.message` therefore
2639
+ * produced a failure that named the statement twice and the *reason* zero
2640
+ * times, so four unrelated boot failures — a missing extension, a wrong
2641
+ * privilege, a bad index method, a constraint the rows violate — all read
2642
+ * identically to whoever had to fix them.
2643
+ *
2644
+ * Two consequences, both observed in the field before this existed:
2645
+ *
2646
+ * 1. On a managed deployment the reason never reaches anybody. Boot dies, the
2647
+ * pod never goes ready, and the deploy reports a readiness timeout — so the
2648
+ * one line that says why is the one line that was thrown away here.
2649
+ * 2. {@link vectorExtensionHint} matches on the error text. Against the
2650
+ * wrapper it matched nothing, so the hint written specifically to explain a
2651
+ * missing pgvector never fired on the path that raises it.
2652
+ *
2653
+ * `detail` and `hint` come along because they are the fields Postgres uses to
2654
+ * say which object it meant and what to do about it — `CREATE EXTENSION` names
2655
+ * the missing library there, and a rejected constraint names the row. This
2656
+ * writes into the tenant's own boot log, about the tenant's own database.
2657
+ */
2658
+ function describeDriverError(err) {
2659
+ const pg = extractPgError(err);
2660
+ if (pg) {
2661
+ const parts = [pg.code ? `${pg.message} [${pg.code}]` : pg.message];
2662
+ if (pg.detail) parts.push(` detail: ${pg.detail}`);
2663
+ if (pg.hint) parts.push(` hint: ${pg.hint}`);
2664
+ return parts.join("\n");
2665
+ }
2666
+ const cause = extractCauseMessage(err);
2667
+ if (cause) return cause;
2668
+ return err instanceof Error ? err.message : String(err);
2669
+ }
2670
+ /** ` CONCURRENTLY ` as it appears in a rendered `CREATE [UNIQUE] INDEX`. */
2671
+ var CONCURRENTLY_RE = /\sCONCURRENTLY\s/i;
2672
+ /**
2673
+ * `25001 active_sql_transaction` — "cannot run inside a transaction block".
2674
+ *
2675
+ * Only ever raised here by `CREATE INDEX CONCURRENTLY`, and only when the
2676
+ * connection we were handed is inside a transaction. Deliberately narrow: it
2677
+ * decides to retry a *different* statement, so it must not fire on anything it
2678
+ * has not been reasoned about.
2679
+ */
2680
+ function isTransactionBlockRefusal(err) {
2681
+ return extractPgError(err)?.code === "25001";
2682
+ }
2127
2683
  /** Attempts per action, including the first. Matches the server's bootstraps. */
2128
2684
  var DDL_ATTEMPTS = 4;
2129
2685
  /**
@@ -2148,10 +2704,16 @@ var DDL_ATTEMPTS = 4;
2148
2704
  * permission failure, a unique constraint the customer's own rows violate.
2149
2705
  */
2150
2706
  async function applyAction(client, action) {
2707
+ let sqlText = action.sql;
2151
2708
  for (let attempt = 1;; attempt++) try {
2152
- await client.query(action.sql);
2709
+ await client.query(sqlText);
2153
2710
  return true;
2154
2711
  } catch (err) {
2712
+ if (isTransactionBlockRefusal(err) && CONCURRENTLY_RE.test(sqlText)) {
2713
+ sqlText = sqlText.replace(CONCURRENTLY_RE, " ");
2714
+ logger.warn(`[schema] ${action.kind} ${action.target}: this connection is inside a transaction, so the index is being built with a write lock rather than concurrently. Writes to this table block until it finishes.`);
2715
+ continue;
2716
+ }
2155
2717
  if (isDuplicateObjectRace(err)) {
2156
2718
  logger.debug(`[schema] ${action.kind} ${action.target}: already created by another instance`);
2157
2719
  return false;
@@ -2165,6 +2727,6 @@ async function applyAction(client, action) {
2165
2727
  }
2166
2728
  }
2167
2729
  //#endregion
2168
- export { resolveStringColumnLength as _, generatePostgresDdl as a, generatePostgresVectorDdl as c, AUTH_USERS_COLUMNS as d, authUsersColumnSql as f, visibleColumnProjection as g, hiddenColumnsOption as h, readSchemaFactsFor as i, planCollectionPolicies as l, buildSearchColumnSpec as m, planCollectionSchemaEnsure as n, generatePostgresPoliciesDdl as o, SEARCH_UNACCENT_FN as p, readExistingSchema as r, generatePostgresSearchDdl as s, ensure_collection_tables_exports as t, resolveColumnName as u };
2730
+ export { __toESM as A, SEARCH_UNACCENT_FN as C, __commonJSMin as D, visibleColumnProjection as E, __exportAll as O, sanitizeErrorForClient as S, hiddenColumnsOption as T, extractPgError as _, readSchemaFactsFor as a, pgErrorToFriendlyMessage as b, generatePostgresPoliciesDdl as c, planCollectionPolicies as d, resolveColumnName as f, extractCauseMessage as g, classifyConnectFailure as h, readExistingSchema as i, __require as k, generatePostgresSearchDdl as l, authUsersColumnSql as m, ensure_collection_tables_exports as n, defaultBelongsToOnDelete as o, AUTH_USERS_COLUMNS as p, planCollectionSchemaEnsure as r, generatePostgresDdl as s, describeDriverError as t, generatePostgresVectorDdl as u, isRoleSwitchingPermissionError as v, buildSearchColumnSpec as w, reachedDatabase as x, isRowLevelSecurityDenial as y };
2169
2731
 
2170
- //# sourceMappingURL=ensure-collection-tables-DgVixhX3.js.map
2732
+ //# sourceMappingURL=ensure-collection-tables-DbAhrjnG.js.map