@rebasepro/server-postgres 0.25.1 → 0.26.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-ClklxyAQ.js → BranchService-CWBnDhcv.js} +25 -9
  2. package/dist/BranchService-CWBnDhcv.js.map +1 -0
  3. package/dist/PostgresBackendDriver.d.ts +13 -0
  4. package/dist/PostgresBootstrapper.d.ts +7 -0
  5. package/dist/auth/services.d.ts +8 -1
  6. package/dist/auth/verify-auth-tables.d.ts +21 -0
  7. package/dist/{backup-cli-gijeXe0u.js → backup-cli-PoRG-zEq.js} +3 -3
  8. package/dist/{backup-cli-gijeXe0u.js.map → backup-cli-PoRG-zEq.js.map} +1 -1
  9. package/dist/{boot-ddl-DYH95Qeg.js → boot-ddl-EsgqDkx0.js} +2 -2
  10. package/dist/{boot-ddl-DYH95Qeg.js.map → boot-ddl-EsgqDkx0.js.map} +1 -1
  11. package/dist/branch-diff-CxE4VlxT.js +1396 -0
  12. package/dist/branch-diff-CxE4VlxT.js.map +1 -0
  13. package/dist/branch-diff-report-DpvVf7hR.js +48 -0
  14. package/dist/branch-diff-report-DpvVf7hR.js.map +1 -0
  15. package/dist/branching/branch-diff-report.d.ts +7 -0
  16. package/dist/branching/branch-diff.d.ts +115 -0
  17. package/dist/{cli-console-CQrQ0lAY.js → cli-console-f0k6_TsJ.js} +38 -5
  18. package/dist/cli-console-f0k6_TsJ.js.map +1 -0
  19. package/dist/cli-console.d.ts +17 -1
  20. package/dist/{cli-errors-DiA0XFIS.js → cli-errors-DsYyNYzd.js} +53 -2
  21. package/dist/cli-errors-DsYyNYzd.js.map +1 -0
  22. package/dist/cli.js +102 -122
  23. package/dist/cli.js.map +1 -1
  24. package/dist/{collections-schema-version-T6cGvrDi.js → collections-schema-version-D7bDI0PJ.js} +13 -9
  25. package/dist/collections-schema-version-D7bDI0PJ.js.map +1 -0
  26. package/dist/{deferred-index-build-BE0I6sYU.js → deferred-index-build-CqaOv1Xx.js} +3 -3
  27. package/dist/{deferred-index-build-BE0I6sYU.js.map → deferred-index-build-CqaOv1Xx.js.map} +1 -1
  28. package/dist/{doctor-BT--1kq3.js → doctor-DW_mlTzz.js} +81 -33
  29. package/dist/doctor-DW_mlTzz.js.map +1 -0
  30. package/dist/{ensure-collection-policies-BnGOURpG.js → ensure-collection-policies-DLWgabQ2.js} +5 -5
  31. package/dist/{ensure-collection-policies-BnGOURpG.js.map → ensure-collection-policies-DLWgabQ2.js.map} +1 -1
  32. package/dist/{ensure-collection-tables-Cet4650m.js → ensure-collection-tables-CFIRq8ni.js} +5 -5
  33. package/dist/{ensure-collection-tables-Cet4650m.js.map → ensure-collection-tables-CFIRq8ni.js.map} +1 -1
  34. package/dist/{ensure-tables-l_OgFQjN.js → ensure-tables-4nA-tfwi.js} +4 -4
  35. package/dist/{ensure-tables-l_OgFQjN.js.map → ensure-tables-4nA-tfwi.js.map} +1 -1
  36. package/dist/{enum-labels-kept-nheku1pB.js → enum-labels-kept-PcPW65xy.js} +2 -2
  37. package/dist/{enum-labels-kept-nheku1pB.js.map → enum-labels-kept-PcPW65xy.js.map} +1 -1
  38. package/dist/history/HistoryService.d.ts +7 -0
  39. package/dist/index.d.ts +20 -0
  40. package/dist/index.es.js +226 -8
  41. package/dist/index.es.js.map +1 -1
  42. package/dist/{db-subcommands-BPoHCxJR.js → migrate-baseline-B-2Ef4Mv.js} +266 -41
  43. package/dist/migrate-baseline-B-2Ef4Mv.js.map +1 -0
  44. package/dist/{migrate-policy-stamps-3eqfhes6.js → migrate-policy-stamps-BQtFHApx.js} +5 -5
  45. package/dist/{migrate-policy-stamps-3eqfhes6.js.map → migrate-policy-stamps-BQtFHApx.js.map} +1 -1
  46. package/dist/{open-rebase-EmqVmS14.js → open-rebase-C8jQ6s5a.js} +292 -46
  47. package/dist/open-rebase-C8jQ6s5a.js.map +1 -0
  48. package/dist/open-rebase.d.ts +9 -1
  49. package/dist/{pg-error-utils-CFEnm2m9.js → pg-error-utils-DhiUTlVx.js} +2 -2
  50. package/dist/{pg-error-utils-CFEnm2m9.js.map → pg-error-utils-DhiUTlVx.js.map} +1 -1
  51. package/dist/{push-index-prebuild-COd7i6wm.js → push-index-prebuild-UTvB5pqH.js} +3 -3
  52. package/dist/{push-index-prebuild-COd7i6wm.js.map → push-index-prebuild-UTvB5pqH.js.map} +1 -1
  53. package/dist/push-kept-defaults-BGOlOqRQ.js +137 -0
  54. package/dist/push-kept-defaults-BGOlOqRQ.js.map +1 -0
  55. package/dist/{push-legacy-renames-CLgYYwPE.js → push-legacy-renames-BwaKi8xF.js} +3 -3
  56. package/dist/{push-legacy-renames-CLgYYwPE.js.map → push-legacy-renames-BwaKi8xF.js.map} +1 -1
  57. package/dist/{push-policy-plan-D2gpQsdv.js → push-policy-plan-DQ2tWceD.js} +2 -2
  58. package/dist/{push-policy-plan-D2gpQsdv.js.map → push-policy-plan-DQ2tWceD.js.map} +1 -1
  59. package/dist/{rls-enforcement-DDyNoOSy.js → rls-enforcement-BOHuHVlc.js} +42 -3
  60. package/dist/rls-enforcement-BOHuHVlc.js.map +1 -0
  61. package/dist/schema/destructive-sql.d.ts +16 -0
  62. package/dist/schema/doctor-cli.js +3 -3
  63. package/dist/schema/introspect-db.js +1 -1
  64. package/dist/schema/migration-freshness.d.ts +69 -0
  65. package/dist/schema/push-kept-defaults.d.ts +57 -0
  66. package/dist/schema/rls-bootstrap-sql.d.ts +47 -10
  67. package/dist/security/rls-enforcement.d.ts +18 -0
  68. package/dist/services/FetchService.d.ts +12 -0
  69. package/dist/services/channel-presence.d.ts +3 -1
  70. package/dist/services/nested-path.d.ts +16 -0
  71. package/dist/services/realtimeService.d.ts +18 -1
  72. package/dist/{websocket-BKZwsjmf.js → websocket-CKYc3V4N.js} +41 -12
  73. package/dist/websocket-CKYc3V4N.js.map +1 -0
  74. package/package.json +7 -7
  75. package/dist/BranchService-ClklxyAQ.js.map +0 -1
  76. package/dist/cli-console-CQrQ0lAY.js.map +0 -1
  77. package/dist/cli-errors-DiA0XFIS.js.map +0 -1
  78. package/dist/collections-schema-version-T6cGvrDi.js.map +0 -1
  79. package/dist/db-subcommands-BPoHCxJR.js.map +0 -1
  80. package/dist/doctor-BT--1kq3.js.map +0 -1
  81. package/dist/open-rebase-EmqVmS14.js.map +0 -1
  82. package/dist/rls-enforcement-DDyNoOSy.js.map +0 -1
  83. package/dist/websocket-BKZwsjmf.js.map +0 -1
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
12
12
  import type { Pool } from "pg";
13
- import { type CollectionConfig, type RebaseSdkData, type RebaseServerClient, type RegisteredDatabase } from "@rebasepro/types";
13
+ import { type CollectionCallbacks, type CollectionConfig, type RebaseSdkData, type RebaseServerClient, type RegisteredDatabase } from "@rebasepro/types";
14
14
  import { type PostgresPoolConfig } from "./connection.js";
15
15
  /** Who a request runs as: what row-level security policies read. */
16
16
  export interface RebaseIdentity {
@@ -44,6 +44,14 @@ export interface OpenRebaseOptions {
44
44
  pool?: PostgresPoolConfig;
45
45
  /** The data source these collections are served from. Defaults to `(default)`. */
46
46
  dataSourceKey?: string;
47
+ /**
48
+ * The project's global callbacks — `callbacks` exported from
49
+ * `config/index.ts`, what the server runs ahead of every collection's own.
50
+ * Without them a script write skips them: a global `beforeSave` that
51
+ * stamps a tenant or writes an audit row ran on every server write and on
52
+ * no script write, while the collection's own `beforeSave` ran on both.
53
+ */
54
+ callbacks?: CollectionCallbacks;
47
55
  }
48
56
  /**
49
57
  * An open project. `dataAsAdmin` and `sql` are the accessors of the same name
@@ -1,6 +1,6 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
- import { nt as extractCauseMessage } from "./cli-errors-DiA0XFIS.js";
3
+ import { rt as extractCauseMessage } from "./cli-errors-DsYyNYzd.js";
4
4
  import { DATABASE_UNAVAILABLE_MESSAGE, declaredErrorAnswer, isDatabaseUnreachable, logger } from "@rebasepro/server";
5
5
  import { fieldKeyForColumn, getTableName } from "@rebasepro/common";
6
6
  //#region src/utils/pg-error-utils.ts
@@ -399,4 +399,4 @@ function sanitizeErrorForClient(error, context, options = {}) {
399
399
  //#endregion
400
400
  export { pgErrorToFriendlyMessage as a, isRowLevelSecurityDenial as i, extractPgError as n, reachedDatabase as o, isRoleSwitchingPermissionError as r, sanitizeErrorForClient as s, classifyConnectFailure as t };
401
401
 
402
- //# sourceMappingURL=pg-error-utils-CFEnm2m9.js.map
402
+ //# sourceMappingURL=pg-error-utils-DhiUTlVx.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"pg-error-utils-CFEnm2m9.js","names":[],"sources":["../src/utils/pg-error-utils.ts"],"sourcesContent":["/**\n * Shared PostgreSQL error extraction and user-friendly message formatting.\n *\n * Drizzle wraps native PG errors in a `.cause` chain. These utilities\n * unwrap that chain to get the real PostgreSQL error (identified by a\n * 5-character alphanumeric `code` such as `42P01`) and translate it into\n * a message that is safe and helpful to show to end-users.\n */\n\nimport { DATABASE_UNAVAILABLE_MESSAGE, declaredErrorAnswer, isDatabaseUnreachable, logger } from \"@rebasepro/server\";\nimport type { CollectionConfig } from \"@rebasepro/types\";\nimport { fieldKeyForColumn, getTableName } from \"@rebasepro/common\";\nimport { extractCauseMessage } from \"./error-cause\";\n\n/**\n * Shape of a deliberate client-facing error — `ApiError` from\n * `@rebasepro/server`, `RebaseApiError` from `@rebasepro/types`, or anything\n * else carrying a 4xx `statusCode`.\n *\n * Matched structurally rather than with `instanceof`: `@rebasepro/server` can\n * be loaded twice (published dist vs. workspace source), which breaks class\n * identity — `PersistService` hedges against the same thing by also accepting\n * `name === \"ApiError\"`.\n */\ninterface ClientFacingError extends Error {\n statusCode?: number;\n code?: string;\n /** See `ApiError.expected` — routine outcomes log at debug, not warn. */\n expected?: boolean;\n}\n\n/**\n * Return the error when it is a deliberate 4xx, otherwise null.\n *\n * A thrown `ApiError` is a decision the server made about the request, not a\n * database failure: its message and code are already written for the client.\n *\n * So is a `RebaseApiError` — what a collection callback throws, since a\n * collection file cannot import the server package. It spells its status\n * `status`, and reading only `statusCode` here turned an `afterRead` that\n * refused with a 403 into \"Could not load data … Check server logs\" on a\n * subscription, where REST and the socket's own requests answer the 403.\n * Recognised by the predicate those doors use, `declaredErrorAnswer`.\n */\nfunction asClientFacingError(error: unknown): Pick<ClientFacingError, \"statusCode\" | \"code\" | \"message\" | \"expected\"> | null {\n const answer = declaredErrorAnswer(error);\n if (answer) {\n if (answer.status < 400 || answer.status >= 500) return null;\n return { statusCode: answer.status, code: answer.code, message: answer.message, expected: answer.expected };\n }\n if (!(error instanceof Error)) return null;\n const e = error as ClientFacingError;\n if (typeof e.statusCode !== \"number\" || e.statusCode < 400 || e.statusCode >= 500) return null;\n return e;\n}\n\n/** Shape of PostgreSQL errors with diagnostic metadata. */\nexport interface PostgresError extends Error {\n code?: string;\n detail?: string;\n hint?: string;\n constraint?: string;\n column?: string;\n table?: string;\n dataType?: string;\n cause?: unknown;\n}\n\n/**\n * Extract the underlying PostgreSQL error from a Drizzle wrapper.\n * Drizzle wraps PG errors in a `cause` property — this function\n * recursively walks the chain until it finds an object with a PG\n * error code (5-char alphanumeric, e.g. `42P01`).\n */\nexport function extractPgError(error: unknown): PostgresError | null {\n if (!error || typeof error !== \"object\") return null;\n if (!(error instanceof Error)) {\n // Check non-Error objects for a cause chain (Drizzle sometimes wraps oddly)\n if (\"cause\" in error && (error as Record<string, unknown>).cause && typeof (error as Record<string, unknown>).cause === \"object\") {\n return extractPgError((error as Record<string, unknown>).cause);\n }\n return null;\n }\n\n // Check if the error itself has a PG error code\n if (\"code\" in error && typeof (error as PostgresError).code === \"string\" && /^[0-9A-Z]{5}$/.test((error as PostgresError).code!)) {\n return error as PostgresError;\n }\n\n // Check the cause chain (Drizzle wraps PG errors)\n if (error.cause && typeof error.cause === \"object\") {\n return extractPgError(error.cause);\n }\n\n return null;\n}\n\n/**\n * Whether the failure came back from Postgres rather than from building the\n * query — which decides whether a fallback query is worth issuing.\n *\n * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds\n * RLS). Once a statement raises, that transaction is aborted, and every later\n * statement on it returns `25P02` — \"current transaction is aborted, commands\n * ignored until end of transaction block\". So a retry after a database error\n * cannot succeed, and it replaces a precise diagnosis (\"invalid input syntax\n * for type uuid\") with a generic one. Rethrow instead.\n *\n * A query the driver could not even build — a missing reciprocal relation, say\n * — never reached Postgres, leaves the transaction usable, and is exactly what\n * the fallback paths exist for.\n */\nexport function reachedDatabase(error: unknown): boolean {\n return extractPgError(error) !== null;\n}\n\n// In its own module so the driver CLI can use it without loading the server\n// (this file imports `@rebasepro/server`).\nexport { extractCauseMessage };\n\n/**\n * Codes that mean \"this connection will never work as configured\".\n *\n * A wrong password or a database that does not exist is a settled fact about\n * the connection string, not a transient fault — retrying produces the same\n * answer forever.\n */\nconst UNRECOVERABLE_CONNECT_CODES = new Set([\n \"28P01\", // invalid_password\n \"28000\", // invalid_authorization_specification\n \"3D000\", // invalid_catalog_name — the database does not exist\n \"42501\" // insufficient_privilege\n]);\n\nexport interface ConnectFailure {\n /** True when retrying cannot help: the connection string itself is wrong. */\n fatal: boolean;\n /** The deepest message available — the Postgres one where there is one. */\n reason: string;\n /** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */\n code?: string;\n}\n\n/**\n * Describe a failed connection attempt in terms a developer can act on.\n *\n * The error a caller catches is Drizzle's wrapper: its message is\n * `Failed query: SELECT 1` and its stack runs through drizzle internals, while\n * the sentence that says what is actually wrong — \"password authentication\n * failed for user …\", \"database … does not exist\" — sits in `.cause`. Logging\n * the wrapper, as the bootstrapper used to, tells a developer with a typo in\n * their `DATABASE_URL` nothing at all.\n */\nexport function classifyConnectFailure(error: unknown): ConnectFailure {\n const pgError = extractPgError(error);\n const reason =\n pgError?.message ??\n extractCauseMessage(error) ??\n (error instanceof Error ? error.message : String(error));\n return {\n fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),\n reason,\n code: pgError?.code\n };\n}\n\n/**\n * Detect whether an error is specifically a role-switching permission failure\n * (e.g. \"permission denied to set role\" or \"must be member of role\"),\n * as opposed to a table-level permission denial.\n *\n * This is used by the backend driver to auto-disable role switching when the\n * connection user lacks SET ROLE privileges, rather than surfacing a confusing\n * error to the Studio SQL Editor user.\n */\nexport function isRoleSwitchingPermissionError(error: unknown): boolean {\n const pgError = extractPgError(error);\n if (!pgError || pgError.code !== \"42501\") return false;\n const msg = pgError.message.toLowerCase();\n return msg.includes(\"set role\") || msg.includes(\"member of role\");\n}\n\n/**\n * Was this `42501` the *caller* being refused by a policy, rather than the\n * server lacking a privilege?\n *\n * Both arrive as `insufficient_privilege`, and they are opposite kinds of\n * problem. A row-level-security refusal is a working access-control system\n * doing its job: the caller asked for something their policies do not permit,\n * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being\n * wrong — the connection role cannot touch the table at all, no policy is\n * involved, and nothing the caller changes about the request will help.\n *\n * Postgres distinguishes them in the message, so this does too:\n *\n * new row violates row-level security policy for table \"notes\" → the caller\n * permission denied for table notes → the server\n *\n * Only writes reach this. A read that RLS excludes is not an error — the rows\n * are filtered and the caller gets an empty page — so the erroring case is\n * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.\n *\n * Matched on the message because that is the only thing carrying the\n * distinction; the SQLSTATE is identical either way. Narrow by design: anything\n * not naming row-level security stays the server's problem, since reporting a\n * genuine privilege misconfiguration as \"forbidden\" would send an operator\n * hunting for a policy bug that does not exist.\n */\nexport function isRowLevelSecurityDenial(error: unknown): boolean {\n const pgError = extractPgError(error);\n if (!pgError || pgError.code !== \"42501\") return false;\n return pgError.message.toLowerCase().includes(\"row-level security policy\");\n}\n\n/**\n * One constraint the database refused, named in the caller's vocabulary.\n *\n * `field` is the **wire** name, derived from the physical column: `author_id`\n * on the table is `authorId` on the row and in the request body, and telling a\n * caller their `author_id` is missing sends them looking for a key their client\n * has never seen. `code` is the SQLSTATE, which is stable, documented by\n * Postgres and the same in every environment — unlike the message, which is\n * deliberately thinner in production.\n *\n * Deliberately no `value`. The column name is safe to publish: it is already in\n * the OpenAPI, the generated SDK and the caller's own request. The *value* is\n * not — `23505`'s detail reads `Key (email)=(a@b.c) already exists.`, which\n * answers \"is this person registered?\" for any address on a table whose rows\n * RLS is otherwise hiding completely.\n */\nexport interface PgFieldViolation {\n field: string;\n code: string;\n}\n\n/**\n * The physical column names a Postgres error is about.\n *\n * Postgres puts one in `column` for `23502` and nowhere else, so the rest are\n * read out of the two places that do carry them:\n *\n * - `detail` — `Key (author_id)=(9) is not present in table \"authors\".` and\n * `Key (email, tenant_id)=(…) already exists.` Only the parenthesised column\n * list is read; the values after `=` are never touched.\n * - `constraint` — `users_email_key`, `posts_author_id_fkey`. The table prefix\n * and the constraint suffix are stripped, leaving the columns joined by `_`,\n * which is ambiguous for multi-column constraints and is therefore only used\n * when `detail` gave nothing.\n *\n * `22P02` carries neither, because an invalid literal fails during parse rather\n * than against a constraint. Its message names the *type*, and an enum type is\n * generated as `<table>_<column>` — which is the one case worth decoding,\n * because a value outside an enum's labels is the most common 22P02 anyone\n * hits and the least legible one.\n */\nfunction offendingColumns(pgError: PostgresError, collection?: CollectionConfig): string[] {\n if (pgError.column) return [pgError.column];\n\n const detail = pgError.detail;\n if (detail) {\n const keyed = /Key \\(([^)]+)\\)=/.exec(detail);\n if (keyed) {\n return keyed[1].split(\",\").map(part => part.trim().replace(/^\"|\"$/g, \"\")).filter(Boolean);\n }\n }\n\n if (pgError.code === \"22P02\") {\n // `invalid input value for enum posts_status: \"archived\"`\n const enumType = /invalid input value for enum ([^:\\s]+)/i.exec(pgError.message ?? \"\");\n if (enumType && collection) {\n const typeName = enumType[1].replace(/^.*\\./, \"\").replace(/\"/g, \"\");\n const table = getTableName(collection);\n if (typeName.startsWith(`${table}_`)) return [typeName.slice(table.length + 1)];\n }\n return [];\n }\n\n const constraint = pgError.constraint;\n if (constraint && collection) {\n const table = getTableName(collection);\n const stripped = constraint\n .replace(new RegExp(`^${table}_`), \"\")\n .replace(/_(pkey|key|fkey|unique|check|excl|idx)$/, \"\");\n // Only when it names something the collection actually has: a\n // hand-written constraint name is not a column list, and guessing at one\n // would put a field in the response that does not exist.\n if (stripped && stripped !== constraint) {\n const columns = new Set<string>();\n for (const [key, prop] of Object.entries(collection.properties ?? {})) {\n columns.add((prop as { columnName?: string })?.columnName ?? key);\n }\n if (columns.has(stripped)) return [stripped];\n const wire = fieldKeyForColumn(collection, stripped);\n if (collection.properties && wire in collection.properties) return [stripped];\n }\n }\n\n return [];\n}\n\n/**\n * The fields a Postgres error is about, for `details.violations`.\n *\n * Only the four SQLSTATEs that describe *the caller's data*: a missing value, a\n * duplicate, a bad literal, a foreign key pointing at nothing. Everything else\n * — a dropped connection, a missing table, a privilege problem — is the\n * deployment's, and naming a field for it would send the reader to the wrong\n * place.\n *\n * Empty when the collection is unknown or the column cannot be recovered. An\n * empty list is the honest answer; inventing a field is worse than saying\n * nothing, because a form will mark the wrong input red.\n */\nexport function pgFieldViolations(\n pgError: PostgresError,\n collection?: CollectionConfig\n): PgFieldViolation[] {\n const code = pgError.code;\n if (code !== \"23502\" && code !== \"23505\" && code !== \"22P02\" && code !== \"23503\") return [];\n // Without the collection there is no mapping, only a guess. `toWireKey`\n // would get the default case right and silently invent a field for any\n // property carrying an explicit `columnName` — and a form pointed at an\n // input that does not exist is worse off than one shown the raw column.\n if (!collection) return [];\n const columns = offendingColumns(pgError, collection);\n if (columns.length === 0) return [];\n return columns.map(column => ({\n field: fieldKeyForColumn(collection, column),\n code\n }));\n}\n\n/**\n * What a missing table or column most often means: the database is behind the\n * collections. Both ways of catching it up, because \"run migrations\" alone\n * sent a project on the managed development database — which nobody\n * migrates — the wrong way.\n */\nconst SCHEMA_BEHIND =\n \"Check that the database is up to date with the collections: `rebase db push` in development, \" +\n \"or `rebase db migrate` where migrations are used.\";\n\n/**\n * Translate a raw PostgreSQL error into a user-friendly message.\n *\n * @param pgError - The extracted PostgreSQL error (from {@link extractPgError})\n * @param context - A human-readable context string (e.g. collection slug or path)\n * @returns An object with a `message` safe for the client and the PG `code`.\n */\nexport function pgErrorToFriendlyMessage(\n pgError: PostgresError,\n context: string,\n options: { verbose?: boolean; collection?: CollectionConfig } = {}\n): { message: string; code: string; violations: PgFieldViolation[] } {\n // Postgres's own `DETAIL` and `HINT`, and the raw driver message, are what\n // make these errors useful while you are building — and what makes them an\n // oracle once the server is answering strangers.\n //\n // The sharp one is `23505`: its detail reads `Key (email)=(a@b.c) already\n // exists.`, which answers \"is this person registered?\" for any address, on\n // a table whose rows RLS is otherwise hiding completely. The rest leak\n // physical column and constraint names, which are the map for the next\n // question. So in production the shape of the failure is served — what\n // rule was broken, and where — and the values are not.\n //\n // The full text is not lost: the caller logs the original error. This\n // decides what crosses the wire, not what is recorded.\n const verbose = options.verbose ?? process.env.NODE_ENV !== \"production\";\n\n const detail = verbose ? (pgError.detail as string | undefined) : undefined;\n const hint = verbose ? (pgError.hint as string | undefined) : undefined;\n const constraint = pgError.constraint as string | undefined;\n const column = pgError.column as string | undefined;\n const table = pgError.table as string | undefined;\n const dataType = verbose ? (pgError.dataType as string | undefined) : undefined;\n const rawMessage = pgError.message || \"Unknown database error\";\n const pgMessage = verbose ? rawMessage : \"\";\n const code = pgError.code || \"UNKNOWN\";\n\n // Which *field* the caller has to fix, derived from the column. Safe in\n // production — a column name is already published in the OpenAPI and the\n // generated SDK — where the value that broke the rule is not, and is never\n // included. See `pgFieldViolations`.\n const violations = pgFieldViolations(pgError, options.collection);\n\n const suffix = hint ? ` Hint: ${hint}` : \"\";\n const tableRef = table ?? context;\n\n /** `\": <postgres said>\"`, or nothing when the details are withheld. */\n const said = pgMessage ? `: ${pgMessage}` : \"\";\n\n switch (pgError.code) {\n case \"23503\": // foreign_key_violation\n return {\n message: detail\n ? `Foreign key constraint violated: ${detail}${suffix}`\n : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : \"\"} was violated in \"${context}\".${suffix}`,\n code,\n violations\n };\n case \"23001\": // restrict_violation\n // A row other rows still point at, under `ON DELETE RESTRICT` — the\n // default for a required link. `table` is the *referencing* table,\n // which is the one the caller has to clear or reassign first.\n return {\n message: `Cannot complete operation: rows in \"${table ?? \"another table\"}\" still reference this row in ` +\n `\"${context}\", and their foreign key${constraint ? ` (${constraint})` : \"\"} does not allow it to be ` +\n `deleted or its key changed. Delete or reassign those rows first.${detail ? ` ${detail}` : \"\"}${suffix}`,\n code,\n violations\n };\n case \"23505\": // unique_violation\n return {\n message: detail\n ? `Duplicate value: ${detail}${suffix}`\n : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : \"\"} was violated in \"${context}\".${suffix}`,\n code,\n violations\n };\n case \"23502\": {\n // not_null_violation. The message names the *field* when the\n // collection is known, because that is the key the caller sent and\n // the only one they can act on: told `author_id` is missing, they go\n // looking for a key their generated client has never mentioned.\n const named = violations[0]?.field ?? column ?? \"unknown\";\n return {\n message: `Missing required field: \"${named}\" in \"${tableRef}\" cannot be empty.${suffix}`,\n code,\n violations\n };\n }\n case \"23514\": // check_violation\n return {\n message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : \"\"} was violated in \"${context}\".${suffix}`,\n code,\n violations\n };\n case \"22P02\": // invalid_text_representation (e.g. invalid UUID, wrong enum value)\n return {\n message: `Invalid data format in \"${context}\"${said}.${suffix}`,\n code,\n violations\n };\n case \"22001\": // string_data_right_truncation (value too long)\n return {\n message: `Value too long for column \"${column ?? \"unknown\"}\" in \"${tableRef}\"${said}.${suffix}`,\n code,\n violations\n };\n case \"22003\": // numeric_value_out_of_range\n return {\n message: `Numeric value out of range for column \"${column ?? \"unknown\"}\" in \"${tableRef}\"${said}.${suffix}`,\n code,\n violations\n };\n case \"42703\": // undefined_column\n return {\n message: `Unknown column in \"${tableRef}\"${said}. ${SCHEMA_BEHIND}${suffix}`,\n code,\n violations\n };\n case \"42P01\": // undefined_table\n return {\n message: `Table not found for \"${context}\"${said}. ${SCHEMA_BEHIND}${suffix}`,\n code,\n violations\n };\n case \"42501\": // insufficient_privilege\n // Two unrelated failures share this SQLSTATE, and the old message\n // named both causes because it could not tell them apart — which\n // meant it was half wrong whichever one had happened, and sent the\n // reader to check the other. Postgres says which in its own message.\n return rawMessage.toLowerCase().includes(\"row-level security policy\")\n ? {\n // The caller. Their policies do not permit this row; the\n // deployment is working exactly as configured.\n message: `Not permitted to write this row in \"${tableRef}\": it does not satisfy the row-level security policy.${suffix}`,\n code,\n violations\n }\n : {\n // The deployment. No policy is involved — the connecting\n // role cannot touch the table at all.\n message: `Permission denied on \"${tableRef}\": the database role this server connects as lacks privileges on it.${suffix}`,\n code,\n violations\n };\n case \"28000\": // invalid_authorization_specification\n return {\n message: `Authorization failed for \"${context}\". Check your database credentials.${suffix}`,\n code,\n violations\n };\n default: {\n // Unhandled PG code — still surface the actual database message\n const parts = [`Database error in \"${context}\" [${code}]${said}`];\n if (detail) parts.push(`Detail: ${detail}`);\n if (column) parts.push(`Column: ${column}`);\n if (dataType) parts.push(`Data type: ${dataType}`);\n if (constraint) parts.push(`Constraint: ${constraint}`);\n if (hint) parts.push(`Hint: ${hint}`);\n return { message: parts.join(\". \"), code, violations };\n }\n }\n}\n\n/**\n * Sanitize any error into a message safe and helpful for the client.\n *\n * A deliberate 4xx (`ApiError`) passes through untouched — the server already\n * decided what the client should read. Otherwise the PG error is extracted\n * from the Drizzle cause chain, falling back to a generic message that\n * doesn't leak SQL.\n *\n * @param error - The raw caught error\n * @param context - A human-readable context string (e.g. collection path)\n * @returns An object with `message` (user-friendly) and optional `code`\n * (the `ApiError` code, or the PG SQLSTATE).\n */\nexport function sanitizeErrorForClient(\n error: unknown,\n context: string,\n options: { collection?: CollectionConfig } = {}\n): { message: string; code?: string; violations?: PgFieldViolation[] } {\n // ── A deliberate 4xx is not a database failure ──────────────────\n // Its message and code are written for the client; replacing them with\n // \"Check server logs\" would discard the only diagnosis the caller gets\n // (e.g. the offending filter field and the collection's valid ones).\n // Log level follows the same convention as the HTTP error handler in\n // @rebasepro/server: routine outcomes at debug, everything else at warn.\n const clientError = asClientFacingError(error);\n if (clientError) {\n const line = `[API ${clientError.statusCode} ${clientError.code ?? \"BAD_REQUEST\"}] in \"${context}\": ${clientError.message}`;\n if (clientError.expected) {\n logger.debug(line);\n } else {\n logger.warn(`⚠️ ${line}`);\n }\n return { message: clientError.message, ...(clientError.code && { code: clientError.code }) };\n }\n\n // ── The database unreachable: the answer every door gives then ──\n // Ahead of the SQLSTATE arm, because `57P01` is a SQLSTATE that says the\n // connection is gone rather than that the statement was wrong.\n if (isDatabaseUnreachable(error)) {\n logger.warn(`[DB] The database is unreachable; \"${context}\" could not be read`);\n return { message: DATABASE_UNAVAILABLE_MESSAGE, code: \"DATABASE_UNAVAILABLE\" };\n }\n\n // ── Always log the full, unsanitized error server-side ──────────\n const pgError = extractPgError(error);\n\n if (pgError) {\n logger.error(`[PG ${pgError.code}] Error in \"${context}\"`, {\n code: pgError.code,\n message: pgError.message,\n detail: pgError.detail,\n hint: pgError.hint,\n column: pgError.column,\n table: pgError.table,\n constraint: pgError.constraint,\n dataType: pgError.dataType\n // The outer Drizzle wrapper message used to be logged here \"for\n // full context\": it is `Failed query: <sql>\\nparams: <values>`, so\n // it published the statement and every bound value (an email, a\n // password hash) on every realtime data failure. The SQLSTATE,\n // detail, table, column and constraint above are the diagnostic\n // value; the wrapper added only the leak. `logger` strips the\n // wrapper as well, but the field itself carried nothing else.\n });\n const { message, code, violations } = pgErrorToFriendlyMessage(pgError, context, { collection: options.collection });\n return { message, code, ...(violations.length > 0 && { violations }) };\n }\n\n // No PG error found — log the raw error as-is\n logger.error(`Database error in \"${context}\" (no PG error extracted)`, {\n error: error instanceof Error ? error.message : String(error),\n stack: error instanceof Error ? error.stack : undefined,\n cause: error instanceof Error && error.cause\n ? (error.cause instanceof Error ? error.cause.message : String(error.cause))\n : undefined\n });\n\n // Try to get the deepest cause message\n const causeMessage = extractCauseMessage(error);\n if (causeMessage) {\n return { message: `Database error in \"${context}\": ${causeMessage}` };\n }\n\n // Last resort — generic message, never leak raw SQL\n return { message: `Could not load data for \"${context}\". Check server logs for details.` };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"pg-error-utils-DhiUTlVx.js","names":[],"sources":["../src/utils/pg-error-utils.ts"],"sourcesContent":["/**\n * Shared PostgreSQL error extraction and user-friendly message formatting.\n *\n * Drizzle wraps native PG errors in a `.cause` chain. These utilities\n * unwrap that chain to get the real PostgreSQL error (identified by a\n * 5-character alphanumeric `code` such as `42P01`) and translate it into\n * a message that is safe and helpful to show to end-users.\n */\n\nimport { DATABASE_UNAVAILABLE_MESSAGE, declaredErrorAnswer, isDatabaseUnreachable, logger } from \"@rebasepro/server\";\nimport type { CollectionConfig } from \"@rebasepro/types\";\nimport { fieldKeyForColumn, getTableName } from \"@rebasepro/common\";\nimport { extractCauseMessage } from \"./error-cause\";\n\n/**\n * Shape of a deliberate client-facing error — `ApiError` from\n * `@rebasepro/server`, `RebaseApiError` from `@rebasepro/types`, or anything\n * else carrying a 4xx `statusCode`.\n *\n * Matched structurally rather than with `instanceof`: `@rebasepro/server` can\n * be loaded twice (published dist vs. workspace source), which breaks class\n * identity — `PersistService` hedges against the same thing by also accepting\n * `name === \"ApiError\"`.\n */\ninterface ClientFacingError extends Error {\n statusCode?: number;\n code?: string;\n /** See `ApiError.expected` — routine outcomes log at debug, not warn. */\n expected?: boolean;\n}\n\n/**\n * Return the error when it is a deliberate 4xx, otherwise null.\n *\n * A thrown `ApiError` is a decision the server made about the request, not a\n * database failure: its message and code are already written for the client.\n *\n * So is a `RebaseApiError` — what a collection callback throws, since a\n * collection file cannot import the server package. It spells its status\n * `status`, and reading only `statusCode` here turned an `afterRead` that\n * refused with a 403 into \"Could not load data … Check server logs\" on a\n * subscription, where REST and the socket's own requests answer the 403.\n * Recognised by the predicate those doors use, `declaredErrorAnswer`.\n */\nfunction asClientFacingError(error: unknown): Pick<ClientFacingError, \"statusCode\" | \"code\" | \"message\" | \"expected\"> | null {\n const answer = declaredErrorAnswer(error);\n if (answer) {\n if (answer.status < 400 || answer.status >= 500) return null;\n return { statusCode: answer.status, code: answer.code, message: answer.message, expected: answer.expected };\n }\n if (!(error instanceof Error)) return null;\n const e = error as ClientFacingError;\n if (typeof e.statusCode !== \"number\" || e.statusCode < 400 || e.statusCode >= 500) return null;\n return e;\n}\n\n/** Shape of PostgreSQL errors with diagnostic metadata. */\nexport interface PostgresError extends Error {\n code?: string;\n detail?: string;\n hint?: string;\n constraint?: string;\n column?: string;\n table?: string;\n dataType?: string;\n cause?: unknown;\n}\n\n/**\n * Extract the underlying PostgreSQL error from a Drizzle wrapper.\n * Drizzle wraps PG errors in a `cause` property — this function\n * recursively walks the chain until it finds an object with a PG\n * error code (5-char alphanumeric, e.g. `42P01`).\n */\nexport function extractPgError(error: unknown): PostgresError | null {\n if (!error || typeof error !== \"object\") return null;\n if (!(error instanceof Error)) {\n // Check non-Error objects for a cause chain (Drizzle sometimes wraps oddly)\n if (\"cause\" in error && (error as Record<string, unknown>).cause && typeof (error as Record<string, unknown>).cause === \"object\") {\n return extractPgError((error as Record<string, unknown>).cause);\n }\n return null;\n }\n\n // Check if the error itself has a PG error code\n if (\"code\" in error && typeof (error as PostgresError).code === \"string\" && /^[0-9A-Z]{5}$/.test((error as PostgresError).code!)) {\n return error as PostgresError;\n }\n\n // Check the cause chain (Drizzle wraps PG errors)\n if (error.cause && typeof error.cause === \"object\") {\n return extractPgError(error.cause);\n }\n\n return null;\n}\n\n/**\n * Whether the failure came back from Postgres rather than from building the\n * query — which decides whether a fallback query is worth issuing.\n *\n * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds\n * RLS). Once a statement raises, that transaction is aborted, and every later\n * statement on it returns `25P02` — \"current transaction is aborted, commands\n * ignored until end of transaction block\". So a retry after a database error\n * cannot succeed, and it replaces a precise diagnosis (\"invalid input syntax\n * for type uuid\") with a generic one. Rethrow instead.\n *\n * A query the driver could not even build — a missing reciprocal relation, say\n * — never reached Postgres, leaves the transaction usable, and is exactly what\n * the fallback paths exist for.\n */\nexport function reachedDatabase(error: unknown): boolean {\n return extractPgError(error) !== null;\n}\n\n// In its own module so the driver CLI can use it without loading the server\n// (this file imports `@rebasepro/server`).\nexport { extractCauseMessage };\n\n/**\n * Codes that mean \"this connection will never work as configured\".\n *\n * A wrong password or a database that does not exist is a settled fact about\n * the connection string, not a transient fault — retrying produces the same\n * answer forever.\n */\nconst UNRECOVERABLE_CONNECT_CODES = new Set([\n \"28P01\", // invalid_password\n \"28000\", // invalid_authorization_specification\n \"3D000\", // invalid_catalog_name — the database does not exist\n \"42501\" // insufficient_privilege\n]);\n\nexport interface ConnectFailure {\n /** True when retrying cannot help: the connection string itself is wrong. */\n fatal: boolean;\n /** The deepest message available — the Postgres one where there is one. */\n reason: string;\n /** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */\n code?: string;\n}\n\n/**\n * Describe a failed connection attempt in terms a developer can act on.\n *\n * The error a caller catches is Drizzle's wrapper: its message is\n * `Failed query: SELECT 1` and its stack runs through drizzle internals, while\n * the sentence that says what is actually wrong — \"password authentication\n * failed for user …\", \"database … does not exist\" — sits in `.cause`. Logging\n * the wrapper, as the bootstrapper used to, tells a developer with a typo in\n * their `DATABASE_URL` nothing at all.\n */\nexport function classifyConnectFailure(error: unknown): ConnectFailure {\n const pgError = extractPgError(error);\n const reason =\n pgError?.message ??\n extractCauseMessage(error) ??\n (error instanceof Error ? error.message : String(error));\n return {\n fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),\n reason,\n code: pgError?.code\n };\n}\n\n/**\n * Detect whether an error is specifically a role-switching permission failure\n * (e.g. \"permission denied to set role\" or \"must be member of role\"),\n * as opposed to a table-level permission denial.\n *\n * This is used by the backend driver to auto-disable role switching when the\n * connection user lacks SET ROLE privileges, rather than surfacing a confusing\n * error to the Studio SQL Editor user.\n */\nexport function isRoleSwitchingPermissionError(error: unknown): boolean {\n const pgError = extractPgError(error);\n if (!pgError || pgError.code !== \"42501\") return false;\n const msg = pgError.message.toLowerCase();\n return msg.includes(\"set role\") || msg.includes(\"member of role\");\n}\n\n/**\n * Was this `42501` the *caller* being refused by a policy, rather than the\n * server lacking a privilege?\n *\n * Both arrive as `insufficient_privilege`, and they are opposite kinds of\n * problem. A row-level-security refusal is a working access-control system\n * doing its job: the caller asked for something their policies do not permit,\n * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being\n * wrong — the connection role cannot touch the table at all, no policy is\n * involved, and nothing the caller changes about the request will help.\n *\n * Postgres distinguishes them in the message, so this does too:\n *\n * new row violates row-level security policy for table \"notes\" → the caller\n * permission denied for table notes → the server\n *\n * Only writes reach this. A read that RLS excludes is not an error — the rows\n * are filtered and the caller gets an empty page — so the erroring case is\n * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.\n *\n * Matched on the message because that is the only thing carrying the\n * distinction; the SQLSTATE is identical either way. Narrow by design: anything\n * not naming row-level security stays the server's problem, since reporting a\n * genuine privilege misconfiguration as \"forbidden\" would send an operator\n * hunting for a policy bug that does not exist.\n */\nexport function isRowLevelSecurityDenial(error: unknown): boolean {\n const pgError = extractPgError(error);\n if (!pgError || pgError.code !== \"42501\") return false;\n return pgError.message.toLowerCase().includes(\"row-level security policy\");\n}\n\n/**\n * One constraint the database refused, named in the caller's vocabulary.\n *\n * `field` is the **wire** name, derived from the physical column: `author_id`\n * on the table is `authorId` on the row and in the request body, and telling a\n * caller their `author_id` is missing sends them looking for a key their client\n * has never seen. `code` is the SQLSTATE, which is stable, documented by\n * Postgres and the same in every environment — unlike the message, which is\n * deliberately thinner in production.\n *\n * Deliberately no `value`. The column name is safe to publish: it is already in\n * the OpenAPI, the generated SDK and the caller's own request. The *value* is\n * not — `23505`'s detail reads `Key (email)=(a@b.c) already exists.`, which\n * answers \"is this person registered?\" for any address on a table whose rows\n * RLS is otherwise hiding completely.\n */\nexport interface PgFieldViolation {\n field: string;\n code: string;\n}\n\n/**\n * The physical column names a Postgres error is about.\n *\n * Postgres puts one in `column` for `23502` and nowhere else, so the rest are\n * read out of the two places that do carry them:\n *\n * - `detail` — `Key (author_id)=(9) is not present in table \"authors\".` and\n * `Key (email, tenant_id)=(…) already exists.` Only the parenthesised column\n * list is read; the values after `=` are never touched.\n * - `constraint` — `users_email_key`, `posts_author_id_fkey`. The table prefix\n * and the constraint suffix are stripped, leaving the columns joined by `_`,\n * which is ambiguous for multi-column constraints and is therefore only used\n * when `detail` gave nothing.\n *\n * `22P02` carries neither, because an invalid literal fails during parse rather\n * than against a constraint. Its message names the *type*, and an enum type is\n * generated as `<table>_<column>` — which is the one case worth decoding,\n * because a value outside an enum's labels is the most common 22P02 anyone\n * hits and the least legible one.\n */\nfunction offendingColumns(pgError: PostgresError, collection?: CollectionConfig): string[] {\n if (pgError.column) return [pgError.column];\n\n const detail = pgError.detail;\n if (detail) {\n const keyed = /Key \\(([^)]+)\\)=/.exec(detail);\n if (keyed) {\n return keyed[1].split(\",\").map(part => part.trim().replace(/^\"|\"$/g, \"\")).filter(Boolean);\n }\n }\n\n if (pgError.code === \"22P02\") {\n // `invalid input value for enum posts_status: \"archived\"`\n const enumType = /invalid input value for enum ([^:\\s]+)/i.exec(pgError.message ?? \"\");\n if (enumType && collection) {\n const typeName = enumType[1].replace(/^.*\\./, \"\").replace(/\"/g, \"\");\n const table = getTableName(collection);\n if (typeName.startsWith(`${table}_`)) return [typeName.slice(table.length + 1)];\n }\n return [];\n }\n\n const constraint = pgError.constraint;\n if (constraint && collection) {\n const table = getTableName(collection);\n const stripped = constraint\n .replace(new RegExp(`^${table}_`), \"\")\n .replace(/_(pkey|key|fkey|unique|check|excl|idx)$/, \"\");\n // Only when it names something the collection actually has: a\n // hand-written constraint name is not a column list, and guessing at one\n // would put a field in the response that does not exist.\n if (stripped && stripped !== constraint) {\n const columns = new Set<string>();\n for (const [key, prop] of Object.entries(collection.properties ?? {})) {\n columns.add((prop as { columnName?: string })?.columnName ?? key);\n }\n if (columns.has(stripped)) return [stripped];\n const wire = fieldKeyForColumn(collection, stripped);\n if (collection.properties && wire in collection.properties) return [stripped];\n }\n }\n\n return [];\n}\n\n/**\n * The fields a Postgres error is about, for `details.violations`.\n *\n * Only the four SQLSTATEs that describe *the caller's data*: a missing value, a\n * duplicate, a bad literal, a foreign key pointing at nothing. Everything else\n * — a dropped connection, a missing table, a privilege problem — is the\n * deployment's, and naming a field for it would send the reader to the wrong\n * place.\n *\n * Empty when the collection is unknown or the column cannot be recovered. An\n * empty list is the honest answer; inventing a field is worse than saying\n * nothing, because a form will mark the wrong input red.\n */\nexport function pgFieldViolations(\n pgError: PostgresError,\n collection?: CollectionConfig\n): PgFieldViolation[] {\n const code = pgError.code;\n if (code !== \"23502\" && code !== \"23505\" && code !== \"22P02\" && code !== \"23503\") return [];\n // Without the collection there is no mapping, only a guess. `toWireKey`\n // would get the default case right and silently invent a field for any\n // property carrying an explicit `columnName` — and a form pointed at an\n // input that does not exist is worse off than one shown the raw column.\n if (!collection) return [];\n const columns = offendingColumns(pgError, collection);\n if (columns.length === 0) return [];\n return columns.map(column => ({\n field: fieldKeyForColumn(collection, column),\n code\n }));\n}\n\n/**\n * What a missing table or column most often means: the database is behind the\n * collections. Both ways of catching it up, because \"run migrations\" alone\n * sent a project on the managed development database — which nobody\n * migrates — the wrong way.\n */\nconst SCHEMA_BEHIND =\n \"Check that the database is up to date with the collections: `rebase db push` in development, \" +\n \"or `rebase db migrate` where migrations are used.\";\n\n/**\n * Translate a raw PostgreSQL error into a user-friendly message.\n *\n * @param pgError - The extracted PostgreSQL error (from {@link extractPgError})\n * @param context - A human-readable context string (e.g. collection slug or path)\n * @returns An object with a `message` safe for the client and the PG `code`.\n */\nexport function pgErrorToFriendlyMessage(\n pgError: PostgresError,\n context: string,\n options: { verbose?: boolean; collection?: CollectionConfig } = {}\n): { message: string; code: string; violations: PgFieldViolation[] } {\n // Postgres's own `DETAIL` and `HINT`, and the raw driver message, are what\n // make these errors useful while you are building — and what makes them an\n // oracle once the server is answering strangers.\n //\n // The sharp one is `23505`: its detail reads `Key (email)=(a@b.c) already\n // exists.`, which answers \"is this person registered?\" for any address, on\n // a table whose rows RLS is otherwise hiding completely. The rest leak\n // physical column and constraint names, which are the map for the next\n // question. So in production the shape of the failure is served — what\n // rule was broken, and where — and the values are not.\n //\n // The full text is not lost: the caller logs the original error. This\n // decides what crosses the wire, not what is recorded.\n const verbose = options.verbose ?? process.env.NODE_ENV !== \"production\";\n\n const detail = verbose ? (pgError.detail as string | undefined) : undefined;\n const hint = verbose ? (pgError.hint as string | undefined) : undefined;\n const constraint = pgError.constraint as string | undefined;\n const column = pgError.column as string | undefined;\n const table = pgError.table as string | undefined;\n const dataType = verbose ? (pgError.dataType as string | undefined) : undefined;\n const rawMessage = pgError.message || \"Unknown database error\";\n const pgMessage = verbose ? rawMessage : \"\";\n const code = pgError.code || \"UNKNOWN\";\n\n // Which *field* the caller has to fix, derived from the column. Safe in\n // production — a column name is already published in the OpenAPI and the\n // generated SDK — where the value that broke the rule is not, and is never\n // included. See `pgFieldViolations`.\n const violations = pgFieldViolations(pgError, options.collection);\n\n const suffix = hint ? ` Hint: ${hint}` : \"\";\n const tableRef = table ?? context;\n\n /** `\": <postgres said>\"`, or nothing when the details are withheld. */\n const said = pgMessage ? `: ${pgMessage}` : \"\";\n\n switch (pgError.code) {\n case \"23503\": // foreign_key_violation\n return {\n message: detail\n ? `Foreign key constraint violated: ${detail}${suffix}`\n : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : \"\"} was violated in \"${context}\".${suffix}`,\n code,\n violations\n };\n case \"23001\": // restrict_violation\n // A row other rows still point at, under `ON DELETE RESTRICT` — the\n // default for a required link. `table` is the *referencing* table,\n // which is the one the caller has to clear or reassign first.\n return {\n message: `Cannot complete operation: rows in \"${table ?? \"another table\"}\" still reference this row in ` +\n `\"${context}\", and their foreign key${constraint ? ` (${constraint})` : \"\"} does not allow it to be ` +\n `deleted or its key changed. Delete or reassign those rows first.${detail ? ` ${detail}` : \"\"}${suffix}`,\n code,\n violations\n };\n case \"23505\": // unique_violation\n return {\n message: detail\n ? `Duplicate value: ${detail}${suffix}`\n : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : \"\"} was violated in \"${context}\".${suffix}`,\n code,\n violations\n };\n case \"23502\": {\n // not_null_violation. The message names the *field* when the\n // collection is known, because that is the key the caller sent and\n // the only one they can act on: told `author_id` is missing, they go\n // looking for a key their generated client has never mentioned.\n const named = violations[0]?.field ?? column ?? \"unknown\";\n return {\n message: `Missing required field: \"${named}\" in \"${tableRef}\" cannot be empty.${suffix}`,\n code,\n violations\n };\n }\n case \"23514\": // check_violation\n return {\n message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : \"\"} was violated in \"${context}\".${suffix}`,\n code,\n violations\n };\n case \"22P02\": // invalid_text_representation (e.g. invalid UUID, wrong enum value)\n return {\n message: `Invalid data format in \"${context}\"${said}.${suffix}`,\n code,\n violations\n };\n case \"22001\": // string_data_right_truncation (value too long)\n return {\n message: `Value too long for column \"${column ?? \"unknown\"}\" in \"${tableRef}\"${said}.${suffix}`,\n code,\n violations\n };\n case \"22003\": // numeric_value_out_of_range\n return {\n message: `Numeric value out of range for column \"${column ?? \"unknown\"}\" in \"${tableRef}\"${said}.${suffix}`,\n code,\n violations\n };\n case \"42703\": // undefined_column\n return {\n message: `Unknown column in \"${tableRef}\"${said}. ${SCHEMA_BEHIND}${suffix}`,\n code,\n violations\n };\n case \"42P01\": // undefined_table\n return {\n message: `Table not found for \"${context}\"${said}. ${SCHEMA_BEHIND}${suffix}`,\n code,\n violations\n };\n case \"42501\": // insufficient_privilege\n // Two unrelated failures share this SQLSTATE, and the old message\n // named both causes because it could not tell them apart — which\n // meant it was half wrong whichever one had happened, and sent the\n // reader to check the other. Postgres says which in its own message.\n return rawMessage.toLowerCase().includes(\"row-level security policy\")\n ? {\n // The caller. Their policies do not permit this row; the\n // deployment is working exactly as configured.\n message: `Not permitted to write this row in \"${tableRef}\": it does not satisfy the row-level security policy.${suffix}`,\n code,\n violations\n }\n : {\n // The deployment. No policy is involved — the connecting\n // role cannot touch the table at all.\n message: `Permission denied on \"${tableRef}\": the database role this server connects as lacks privileges on it.${suffix}`,\n code,\n violations\n };\n case \"28000\": // invalid_authorization_specification\n return {\n message: `Authorization failed for \"${context}\". Check your database credentials.${suffix}`,\n code,\n violations\n };\n default: {\n // Unhandled PG code — still surface the actual database message\n const parts = [`Database error in \"${context}\" [${code}]${said}`];\n if (detail) parts.push(`Detail: ${detail}`);\n if (column) parts.push(`Column: ${column}`);\n if (dataType) parts.push(`Data type: ${dataType}`);\n if (constraint) parts.push(`Constraint: ${constraint}`);\n if (hint) parts.push(`Hint: ${hint}`);\n return { message: parts.join(\". \"), code, violations };\n }\n }\n}\n\n/**\n * Sanitize any error into a message safe and helpful for the client.\n *\n * A deliberate 4xx (`ApiError`) passes through untouched — the server already\n * decided what the client should read. Otherwise the PG error is extracted\n * from the Drizzle cause chain, falling back to a generic message that\n * doesn't leak SQL.\n *\n * @param error - The raw caught error\n * @param context - A human-readable context string (e.g. collection path)\n * @returns An object with `message` (user-friendly) and optional `code`\n * (the `ApiError` code, or the PG SQLSTATE).\n */\nexport function sanitizeErrorForClient(\n error: unknown,\n context: string,\n options: { collection?: CollectionConfig } = {}\n): { message: string; code?: string; violations?: PgFieldViolation[] } {\n // ── A deliberate 4xx is not a database failure ──────────────────\n // Its message and code are written for the client; replacing them with\n // \"Check server logs\" would discard the only diagnosis the caller gets\n // (e.g. the offending filter field and the collection's valid ones).\n // Log level follows the same convention as the HTTP error handler in\n // @rebasepro/server: routine outcomes at debug, everything else at warn.\n const clientError = asClientFacingError(error);\n if (clientError) {\n const line = `[API ${clientError.statusCode} ${clientError.code ?? \"BAD_REQUEST\"}] in \"${context}\": ${clientError.message}`;\n if (clientError.expected) {\n logger.debug(line);\n } else {\n logger.warn(`⚠️ ${line}`);\n }\n return { message: clientError.message, ...(clientError.code && { code: clientError.code }) };\n }\n\n // ── The database unreachable: the answer every door gives then ──\n // Ahead of the SQLSTATE arm, because `57P01` is a SQLSTATE that says the\n // connection is gone rather than that the statement was wrong.\n if (isDatabaseUnreachable(error)) {\n logger.warn(`[DB] The database is unreachable; \"${context}\" could not be read`);\n return { message: DATABASE_UNAVAILABLE_MESSAGE, code: \"DATABASE_UNAVAILABLE\" };\n }\n\n // ── Always log the full, unsanitized error server-side ──────────\n const pgError = extractPgError(error);\n\n if (pgError) {\n logger.error(`[PG ${pgError.code}] Error in \"${context}\"`, {\n code: pgError.code,\n message: pgError.message,\n detail: pgError.detail,\n hint: pgError.hint,\n column: pgError.column,\n table: pgError.table,\n constraint: pgError.constraint,\n dataType: pgError.dataType\n // The outer Drizzle wrapper message used to be logged here \"for\n // full context\": it is `Failed query: <sql>\\nparams: <values>`, so\n // it published the statement and every bound value (an email, a\n // password hash) on every realtime data failure. The SQLSTATE,\n // detail, table, column and constraint above are the diagnostic\n // value; the wrapper added only the leak. `logger` strips the\n // wrapper as well, but the field itself carried nothing else.\n });\n const { message, code, violations } = pgErrorToFriendlyMessage(pgError, context, { collection: options.collection });\n return { message, code, ...(violations.length > 0 && { violations }) };\n }\n\n // No PG error found — log the raw error as-is\n logger.error(`Database error in \"${context}\" (no PG error extracted)`, {\n error: error instanceof Error ? error.message : String(error),\n stack: error instanceof Error ? error.stack : undefined,\n cause: error instanceof Error && error.cause\n ? (error.cause instanceof Error ? error.cause.message : String(error.cause))\n : undefined\n });\n\n // Try to get the deepest cause message\n const causeMessage = extractCauseMessage(error);\n if (causeMessage) {\n return { message: `Database error in \"${context}\": ${causeMessage}` };\n }\n\n // Last resort — generic message, never leak raw SQL\n return { message: `Could not load data for \"${context}\". Check server logs for details.` };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,SAAS,oBAAoB,OAAgG;CACzH,MAAM,SAAS,oBAAoB,KAAK;CACxC,IAAI,QAAQ;EACR,IAAI,OAAO,SAAS,OAAO,OAAO,UAAU,KAAK,OAAO;EACxD,OAAO;GAAE,YAAY,OAAO;GAAQ,MAAM,OAAO;GAAM,SAAS,OAAO;GAAS,UAAU,OAAO;EAAS;CAC9G;CACA,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CACtC,MAAM,IAAI;CACV,IAAI,OAAO,EAAE,eAAe,YAAY,EAAE,aAAa,OAAO,EAAE,cAAc,KAAK,OAAO;CAC1F,OAAO;AACX;;;;;;;AAoBA,SAAgB,eAAe,OAAsC;CACjE,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO;CAChD,IAAI,EAAE,iBAAiB,QAAQ;EAE3B,IAAI,WAAW,SAAU,MAAkC,SAAS,OAAQ,MAAkC,UAAU,UACpH,OAAO,eAAgB,MAAkC,KAAK;EAElE,OAAO;CACX;CAGA,IAAI,UAAU,SAAS,OAAQ,MAAwB,SAAS,YAAY,gBAAgB,KAAM,MAAwB,IAAK,GAC3H,OAAO;CAIX,IAAI,MAAM,SAAS,OAAO,MAAM,UAAU,UACtC,OAAO,eAAe,MAAM,KAAK;CAGrC,OAAO;AACX;;;;;;;;;;;;;;;;AAiBA,SAAgB,gBAAgB,OAAyB;CACrD,OAAO,eAAe,KAAK,MAAM;AACrC;;;;;;;;AAaA,IAAM,8CAA8B,IAAI,IAAI;CACxC;CACA;CACA;CACA;AACJ,CAAC;;;;;;;;;;;AAqBD,SAAgB,uBAAuB,OAAgC;CACnE,MAAM,UAAU,eAAe,KAAK;CACpC,MAAM,SACF,SAAS,WACT,oBAAoB,KAAK,MACxB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CAC1D,OAAO;EACH,OAAO,QAAQ,SAAS,QAAQ,4BAA4B,IAAI,QAAQ,IAAI,CAAC;EAC7E;EACA,MAAM,SAAS;CACnB;AACJ;;;;;;;;;;AAWA,SAAgB,+BAA+B,OAAyB;CACpE,MAAM,UAAU,eAAe,KAAK;CACpC,IAAI,CAAC,WAAW,QAAQ,SAAS,SAAS,OAAO;CACjD,MAAM,MAAM,QAAQ,QAAQ,YAAY;CACxC,OAAO,IAAI,SAAS,UAAU,KAAK,IAAI,SAAS,gBAAgB;AACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,yBAAyB,OAAyB;CAC9D,MAAM,UAAU,eAAe,KAAK;CACpC,IAAI,CAAC,WAAW,QAAQ,SAAS,SAAS,OAAO;CACjD,OAAO,QAAQ,QAAQ,YAAY,CAAC,CAAC,SAAS,2BAA2B;AAC7E;;;;;;;;;;;;;;;;;;;;;AA2CA,SAAS,iBAAiB,SAAwB,YAAyC;CACvF,IAAI,QAAQ,QAAQ,OAAO,CAAC,QAAQ,MAAM;CAE1C,MAAM,SAAS,QAAQ;CACvB,IAAI,QAAQ;EACR,MAAM,QAAQ,mBAAmB,KAAK,MAAM;EAC5C,IAAI,OACA,OAAO,MAAM,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,KAAI,SAAQ,KAAK,KAAK,CAAC,CAAC,QAAQ,UAAU,EAAE,CAAC,CAAC,CAAC,OAAO,OAAO;CAEhG;CAEA,IAAI,QAAQ,SAAS,SAAS;EAE1B,MAAM,WAAW,0CAA0C,KAAK,QAAQ,WAAW,EAAE;EACrF,IAAI,YAAY,YAAY;GACxB,MAAM,WAAW,SAAS,EAAE,CAAC,QAAQ,SAAS,EAAE,CAAC,CAAC,QAAQ,MAAM,EAAE;GAClE,MAAM,QAAQ,aAAa,UAAU;GACrC,IAAI,SAAS,WAAW,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,MAAM,MAAM,SAAS,CAAC,CAAC;EAClF;EACA,OAAO,CAAC;CACZ;CAEA,MAAM,aAAa,QAAQ;CAC3B,IAAI,cAAc,YAAY;EAC1B,MAAM,QAAQ,aAAa,UAAU;EACrC,MAAM,WAAW,WACZ,QAAQ,IAAI,OAAO,IAAI,MAAM,EAAE,GAAG,EAAE,CAAC,CACrC,QAAQ,2CAA2C,EAAE;EAI1D,IAAI,YAAY,aAAa,YAAY;GACrC,MAAM,0BAAU,IAAI,IAAY;GAChC,KAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAChE,QAAQ,IAAK,MAAkC,cAAc,GAAG;GAEpE,IAAI,QAAQ,IAAI,QAAQ,GAAG,OAAO,CAAC,QAAQ;GAC3C,MAAM,OAAO,kBAAkB,YAAY,QAAQ;GACnD,IAAI,WAAW,cAAc,QAAQ,WAAW,YAAY,OAAO,CAAC,QAAQ;EAChF;CACJ;CAEA,OAAO,CAAC;AACZ;;;;;;;;;;;;;;AAeA,SAAgB,kBACZ,SACA,YACkB;CAClB,MAAM,OAAO,QAAQ;CACrB,IAAI,SAAS,WAAW,SAAS,WAAW,SAAS,WAAW,SAAS,SAAS,OAAO,CAAC;CAK1F,IAAI,CAAC,YAAY,OAAO,CAAC;CACzB,MAAM,UAAU,iBAAiB,SAAS,UAAU;CACpD,IAAI,QAAQ,WAAW,GAAG,OAAO,CAAC;CAClC,OAAO,QAAQ,KAAI,YAAW;EAC1B,OAAO,kBAAkB,YAAY,MAAM;EAC3C;CACJ,EAAE;AACN;;;;;;;AAQA,IAAM,gBACF;;;;;;;;AAUJ,SAAgB,yBACZ,SACA,SACA,UAAgE,CAAC,GACA;CAcjE,MAAM,UAAU,QAAQ,WAAA,QAAA,IAAA,aAAoC;CAE5D,MAAM,SAAS,UAAW,QAAQ,SAAgC,KAAA;CAClE,MAAM,OAAO,UAAW,QAAQ,OAA8B,KAAA;CAC9D,MAAM,aAAa,QAAQ;CAC3B,MAAM,SAAS,QAAQ;CACvB,MAAM,QAAQ,QAAQ;CACtB,MAAM,WAAW,UAAW,QAAQ,WAAkC,KAAA;CACtE,MAAM,aAAa,QAAQ,WAAW;CACtC,MAAM,YAAY,UAAU,aAAa;CACzC,MAAM,OAAO,QAAQ,QAAQ;CAM7B,MAAM,aAAa,kBAAkB,SAAS,QAAQ,UAAU;CAEhE,MAAM,SAAS,OAAO,UAAU,SAAS;CACzC,MAAM,WAAW,SAAS;;CAG1B,MAAM,OAAO,YAAY,KAAK,cAAc;CAE5C,QAAQ,QAAQ,MAAhB;EACI,KAAK,SACD,OAAO;GACH,SAAS,SACH,oCAAoC,SAAS,WAC7C,sDAAsD,aAAa,KAAK,WAAW,KAAK,GAAG,oBAAoB,QAAQ,IAAI;GACjI;GACA;EACJ;EACJ,KAAK,SAID,OAAO;GACH,SAAS,uCAAuC,SAAS,gBAAgB,iCACjE,QAAQ,0BAA0B,aAAa,KAAK,WAAW,KAAK,GAAG,2FACR,SAAS,IAAI,WAAW,KAAK;GACpG;GACA;EACJ;EACJ,KAAK,SACD,OAAO;GACH,SAAS,SACH,oBAAoB,SAAS,WAC7B,iDAAiD,aAAa,KAAK,WAAW,KAAK,GAAG,oBAAoB,QAAQ,IAAI;GAC5H;GACA;EACJ;EACJ,KAAK,SAMD,OAAO;GACH,SAAS,4BAFC,WAAW,EAAE,EAAE,SAAS,UAAU,UAED,QAAQ,SAAS,oBAAoB;GAChF;GACA;EACJ;EAEJ,KAAK,SACD,OAAO;GACH,SAAS,wCAAwC,aAAa,KAAK,WAAW,KAAK,GAAG,oBAAoB,QAAQ,IAAI;GACtH;GACA;EACJ;EACJ,KAAK,SACD,OAAO;GACH,SAAS,2BAA2B,QAAQ,GAAG,KAAK,GAAG;GACvD;GACA;EACJ;EACJ,KAAK,SACD,OAAO;GACH,SAAS,8BAA8B,UAAU,UAAU,QAAQ,SAAS,GAAG,KAAK,GAAG;GACvF;GACA;EACJ;EACJ,KAAK,SACD,OAAO;GACH,SAAS,0CAA0C,UAAU,UAAU,QAAQ,SAAS,GAAG,KAAK,GAAG;GACnG;GACA;EACJ;EACJ,KAAK,SACD,OAAO;GACH,SAAS,sBAAsB,SAAS,GAAG,KAAK,IAAI,gBAAgB;GACpE;GACA;EACJ;EACJ,KAAK,SACD,OAAO;GACH,SAAS,wBAAwB,QAAQ,GAAG,KAAK,IAAI,gBAAgB;GACrE;GACA;EACJ;EACJ,KAAK,SAKD,OAAO,WAAW,YAAY,CAAC,CAAC,SAAS,2BAA2B,IAC9D;GAGE,SAAS,uCAAuC,SAAS,uDAAuD;GAChH;GACA;EACJ,IACE;GAGE,SAAS,yBAAyB,SAAS,sEAAsE;GACjH;GACA;EACJ;EACR,KAAK,SACD,OAAO;GACH,SAAS,6BAA6B,QAAQ,qCAAqC;GACnF;GACA;EACJ;EACJ,SAAS;GAEL,MAAM,QAAQ,CAAC,sBAAsB,QAAQ,KAAK,KAAK,GAAG,MAAM;GAChE,IAAI,QAAQ,MAAM,KAAK,WAAW,QAAQ;GAC1C,IAAI,QAAQ,MAAM,KAAK,WAAW,QAAQ;GAC1C,IAAI,UAAU,MAAM,KAAK,cAAc,UAAU;GACjD,IAAI,YAAY,MAAM,KAAK,eAAe,YAAY;GACtD,IAAI,MAAM,MAAM,KAAK,SAAS,MAAM;GACpC,OAAO;IAAE,SAAS,MAAM,KAAK,IAAI;IAAG;IAAM;GAAW;EACzD;CACJ;AACJ;;;;;;;;;;;;;;AAeA,SAAgB,uBACZ,OACA,SACA,UAA6C,CAAC,GACqB;CAOnE,MAAM,cAAc,oBAAoB,KAAK;CAC7C,IAAI,aAAa;EACb,MAAM,OAAO,QAAQ,YAAY,WAAW,GAAG,YAAY,QAAQ,cAAc,QAAQ,QAAQ,KAAK,YAAY;EAClH,IAAI,YAAY,UACZ,OAAO,MAAM,IAAI;OAEjB,OAAO,KAAK,MAAM,MAAM;EAE5B,OAAO;GAAE,SAAS,YAAY;GAAS,GAAI,YAAY,QAAQ,EAAE,MAAM,YAAY,KAAK;EAAG;CAC/F;CAKA,IAAI,sBAAsB,KAAK,GAAG;EAC9B,OAAO,KAAK,sCAAsC,QAAQ,oBAAoB;EAC9E,OAAO;GAAE,SAAS;GAA8B,MAAM;EAAuB;CACjF;CAGA,MAAM,UAAU,eAAe,KAAK;CAEpC,IAAI,SAAS;EACT,OAAO,MAAM,OAAO,QAAQ,KAAK,cAAc,QAAQ,IAAI;GACvD,MAAM,QAAQ;GACd,SAAS,QAAQ;GACjB,QAAQ,QAAQ;GAChB,MAAM,QAAQ;GACd,QAAQ,QAAQ;GAChB,OAAO,QAAQ;GACf,YAAY,QAAQ;GACpB,UAAU,QAAQ;EAQtB,CAAC;EACD,MAAM,EAAE,SAAS,MAAM,eAAe,yBAAyB,SAAS,SAAS,EAAE,YAAY,QAAQ,WAAW,CAAC;EACnH,OAAO;GAAE;GAAS;GAAM,GAAI,WAAW,SAAS,KAAK,EAAE,WAAW;EAAG;CACzE;CAGA,OAAO,MAAM,sBAAsB,QAAQ,4BAA4B;EACnE,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EAC5D,OAAO,iBAAiB,QAAQ,MAAM,QAAQ,KAAA;EAC9C,OAAO,iBAAiB,SAAS,MAAM,QAChC,MAAM,iBAAiB,QAAQ,MAAM,MAAM,UAAU,OAAO,MAAM,KAAK,IACxE,KAAA;CACV,CAAC;CAGD,MAAM,eAAe,oBAAoB,KAAK;CAC9C,IAAI,cACA,OAAO,EAAE,SAAS,sBAAsB,QAAQ,KAAK,eAAe;CAIxE,OAAO,EAAE,SAAS,4BAA4B,QAAQ,mCAAmC;AAC7F"}
@@ -1,8 +1,8 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { n as planSchema } from "./plan-schema-Czf3q4-Q.js";
4
- import { s as diffPlanAgainstCatalogue } from "./deferred-index-build-BE0I6sYU.js";
5
- import { i as readExistingSchema } from "./ensure-collection-tables-Cet4650m.js";
4
+ import { s as diffPlanAgainstCatalogue } from "./deferred-index-build-CqaOv1Xx.js";
5
+ import { i as readExistingSchema } from "./ensure-collection-tables-CFIRq8ni.js";
6
6
  import { declaredDatabaseExtensions } from "@rebasepro/types";
7
7
  //#region src/schema/push-index-prebuild.ts
8
8
  /**
@@ -42,4 +42,4 @@ async function missingForeignKeyIndexes(client, collections) {
42
42
  //#endregion
43
43
  export { missingForeignKeyIndexes };
44
44
 
45
- //# sourceMappingURL=push-index-prebuild-COd7i6wm.js.map
45
+ //# sourceMappingURL=push-index-prebuild-UTvB5pqH.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"push-index-prebuild-COd7i6wm.js","names":[],"sources":["../src/schema/push-index-prebuild.ts"],"sourcesContent":["/**\n * The foreign-key indexes `rebase db push` builds before Atlas plans.\n *\n * Rebase derives a btree for every `belongsTo` key and every junction's target\n * column (15b00afcd). On a table that already exists — every table of a\n * project upgraded across that release — the index is missing, and a push left\n * it to Atlas, which builds it inside the push's one transaction: a plain\n * `CREATE INDEX`, holding writes to the table for the whole build, about 0.4 s\n * at a million rows and 6 s at ten million. Atlas's own concurrent mode would\n * run the entire apply outside a transaction, which the push relies on.\n *\n * So the push builds those first, `CONCURRENTLY`, with the builder the boot\n * uses for the same indexes (`deferred-index-build.ts`: one at a time, a lock\n * budget for getting the table, an INVALID leftover dropped and rebuilt, a\n * claim shared with booting instances). Atlas then finds them and plans\n * nothing for them. One it could not build is still Atlas's, as before.\n *\n * Only an index whose columns are already there: a column this push adds is\n * created by Atlas, and its index with it, on a column that holds no values.\n */\nimport { declaredDatabaseExtensions, type CollectionConfig } from \"@rebasepro/types\";\nimport { planSchema } from \"./plan/plan-schema\";\nimport { diffPlanAgainstCatalogue, type DeferredIndexBuild } from \"./plan/diff-plan\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\n\n/** The derived foreign-key indexes a live table lacks (or holds INVALID) whose columns it already has. */\nexport async function missingForeignKeyIndexes(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<DeferredIndexBuild[]> {\n const schema = planSchema(collections, { databaseExtensions: declaredDatabaseExtensions() });\n const schemas = Array.from(new Set(schema.tables.map(table => table.schema)));\n // No row probes: whether a table holds rows decides only constraints on a\n // column being added, which this does not plan.\n const existing = await readExistingSchema(client, schemas, { probeTables: () => [] });\n const plan = diffPlanAgainstCatalogue(schema, existing);\n\n const specs = new Map(schema.tables.flatMap(table => table.indexes)\n .map(spec => [`${spec.schema}.${spec.indexName}`, spec]));\n return plan.deferredIndexes.filter(build => {\n const spec = specs.get(`${build.schema}.${build.indexName}`);\n if (spec?.derived !== \"foreign-key\") return false;\n const columns = existing.tables.get(`${build.schema}.${build.table}`);\n if (!columns) return false;\n return [...spec.keys.map(key => key.column), ...spec.include].every(column => columns.has(column));\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,eAAsB,yBAClB,QACA,aAC6B;CAC7B,MAAM,SAAS,WAAW,aAAa,EAAE,oBAAoB,2BAA2B,EAAE,CAAC;CAI3F,MAAM,WAAW,MAAM,mBAAmB,QAH1B,MAAM,KAAK,IAAI,IAAI,OAAO,OAAO,KAAI,UAAS,MAAM,MAAM,CAAC,CAGzB,GAAS,EAAE,mBAAmB,CAAC,EAAE,CAAC;CACpF,MAAM,OAAO,yBAAyB,QAAQ,QAAQ;CAEtD,MAAM,QAAQ,IAAI,IAAI,OAAO,OAAO,SAAQ,UAAS,MAAM,OAAO,CAAC,CAC9D,KAAI,SAAQ,CAAC,GAAG,KAAK,OAAO,GAAG,KAAK,aAAa,IAAI,CAAC,CAAC;CAC5D,OAAO,KAAK,gBAAgB,QAAO,UAAS;EACxC,MAAM,OAAO,MAAM,IAAI,GAAG,MAAM,OAAO,GAAG,MAAM,WAAW;EAC3D,IAAI,MAAM,YAAY,eAAe,OAAO;EAC5C,MAAM,UAAU,SAAS,OAAO,IAAI,GAAG,MAAM,OAAO,GAAG,MAAM,OAAO;EACpE,IAAI,CAAC,SAAS,OAAO;EACrB,OAAO,CAAC,GAAG,KAAK,KAAK,KAAI,QAAO,IAAI,MAAM,GAAG,GAAG,KAAK,OAAO,CAAC,CAAC,OAAM,WAAU,QAAQ,IAAI,MAAM,CAAC;CACrG,CAAC;AACL"}
1
+ {"version":3,"file":"push-index-prebuild-UTvB5pqH.js","names":[],"sources":["../src/schema/push-index-prebuild.ts"],"sourcesContent":["/**\n * The foreign-key indexes `rebase db push` builds before Atlas plans.\n *\n * Rebase derives a btree for every `belongsTo` key and every junction's target\n * column (15b00afcd). On a table that already exists — every table of a\n * project upgraded across that release — the index is missing, and a push left\n * it to Atlas, which builds it inside the push's one transaction: a plain\n * `CREATE INDEX`, holding writes to the table for the whole build, about 0.4 s\n * at a million rows and 6 s at ten million. Atlas's own concurrent mode would\n * run the entire apply outside a transaction, which the push relies on.\n *\n * So the push builds those first, `CONCURRENTLY`, with the builder the boot\n * uses for the same indexes (`deferred-index-build.ts`: one at a time, a lock\n * budget for getting the table, an INVALID leftover dropped and rebuilt, a\n * claim shared with booting instances). Atlas then finds them and plans\n * nothing for them. One it could not build is still Atlas's, as before.\n *\n * Only an index whose columns are already there: a column this push adds is\n * created by Atlas, and its index with it, on a column that holds no values.\n */\nimport { declaredDatabaseExtensions, type CollectionConfig } from \"@rebasepro/types\";\nimport { planSchema } from \"./plan/plan-schema\";\nimport { diffPlanAgainstCatalogue, type DeferredIndexBuild } from \"./plan/diff-plan\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\n\n/** The derived foreign-key indexes a live table lacks (or holds INVALID) whose columns it already has. */\nexport async function missingForeignKeyIndexes(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<DeferredIndexBuild[]> {\n const schema = planSchema(collections, { databaseExtensions: declaredDatabaseExtensions() });\n const schemas = Array.from(new Set(schema.tables.map(table => table.schema)));\n // No row probes: whether a table holds rows decides only constraints on a\n // column being added, which this does not plan.\n const existing = await readExistingSchema(client, schemas, { probeTables: () => [] });\n const plan = diffPlanAgainstCatalogue(schema, existing);\n\n const specs = new Map(schema.tables.flatMap(table => table.indexes)\n .map(spec => [`${spec.schema}.${spec.indexName}`, spec]));\n return plan.deferredIndexes.filter(build => {\n const spec = specs.get(`${build.schema}.${build.indexName}`);\n if (spec?.derived !== \"foreign-key\") return false;\n const columns = existing.tables.get(`${build.schema}.${build.table}`);\n if (!columns) return false;\n return [...spec.keys.map(key => key.column), ...spec.include].every(column => columns.has(column));\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,eAAsB,yBAClB,QACA,aAC6B;CAC7B,MAAM,SAAS,WAAW,aAAa,EAAE,oBAAoB,2BAA2B,EAAE,CAAC;CAI3F,MAAM,WAAW,MAAM,mBAAmB,QAH1B,MAAM,KAAK,IAAI,IAAI,OAAO,OAAO,KAAI,UAAS,MAAM,MAAM,CAAC,CAGzB,GAAS,EAAE,mBAAmB,CAAC,EAAE,CAAC;CACpF,MAAM,OAAO,yBAAyB,QAAQ,QAAQ;CAEtD,MAAM,QAAQ,IAAI,IAAI,OAAO,OAAO,SAAQ,UAAS,MAAM,OAAO,CAAC,CAC9D,KAAI,SAAQ,CAAC,GAAG,KAAK,OAAO,GAAG,KAAK,aAAa,IAAI,CAAC,CAAC;CAC5D,OAAO,KAAK,gBAAgB,QAAO,UAAS;EACxC,MAAM,OAAO,MAAM,IAAI,GAAG,MAAM,OAAO,GAAG,MAAM,WAAW;EAC3D,IAAI,MAAM,YAAY,eAAe,OAAO;EAC5C,MAAM,UAAU,SAAS,OAAO,IAAI,GAAG,MAAM,OAAO,GAAG,MAAM,OAAO;EACpE,IAAI,CAAC,SAAS,OAAO;EACrB,OAAO,CAAC,GAAG,KAAK,KAAK,KAAI,QAAO,IAAI,MAAM,GAAG,GAAG,KAAK,OAAO,CAAC,CAAC,OAAM,WAAU,QAAQ,IAAI,MAAM,CAAC;CACrG,CAAC;AACL"}
@@ -0,0 +1,137 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ __createRequire(import.meta.url);
3
+ import { u as resolveColumnName } from "./column-plan-helpers-IG6uFb1s.js";
4
+ import { n as planSchema } from "./plan-schema-Czf3q4-Q.js";
5
+ import { declaredDatabaseExtensions } from "@rebasepro/types";
6
+ //#region src/schema/push-kept-defaults.ts
7
+ /**
8
+ * The DEFAULTs `db push` keeps because a property says the database owns them.
9
+ *
10
+ * `databaseDefault: true` declares a DEFAULT somebody else wrote: a DBA's
11
+ * `now()`, a migration's `'X-' || gen_random_uuid()`. The planner writes no
12
+ * DEFAULT for it, since it has no expression to write, so `schema.sql` holds
13
+ * the column without one. Atlas reads that file as the whole desired state and
14
+ * planned `ALTER COLUMN … DROP DEFAULT`. The API kept letting creates through
15
+ * without the field, and the database then refused them on NOT NULL.
16
+ *
17
+ * Atlas has no way to leave one column's DEFAULT alone: `--exclude` names
18
+ * objects, and a DEFAULT is not one. So the desired state is made to agree with
19
+ * the database instead. Before Atlas plans, the live DEFAULT of each such
20
+ * column is read and appended to `schema.sql` as `ALTER TABLE … SET DEFAULT`,
21
+ * and Atlas finds nothing to change.
22
+ *
23
+ * Two kinds are left out, because Atlas already does something else with them:
24
+ *
25
+ * - a DEFAULT reading a sequence (`nextval(…)`). Atlas does not manage
26
+ * sequences and leaves those DEFAULTs alone, and the sequence does not exist
27
+ * in the scratch database Atlas loads `schema.sql` into.
28
+ * - a DEFAULT calling a function or operator the database defines (an
29
+ * extension's `uuid_generate_v4()`, a project's own function). The scratch
30
+ * database has none of them, and Atlas refuses such a column whether or not
31
+ * it is in `schema.sql`; adding it would only change the error.
32
+ */
33
+ /**
34
+ * Every column declared `databaseDefault: true` that the planner writes no
35
+ * DEFAULT for. An id strategy or `autoValue` writes one of its own, and that
36
+ * one is the planner's to keep.
37
+ */
38
+ function databaseOwnedDefaultColumns(collections) {
39
+ const declared = collections.filter((collection) => Object.values(collection.properties ?? {}).some((prop) => isDatabaseOwned(prop)));
40
+ if (declared.length === 0) return [];
41
+ const plan = planSchema(collections, { databaseExtensions: declaredDatabaseExtensions() });
42
+ const found = [];
43
+ for (const table of plan.tables) {
44
+ const collection = declared.find((each) => each.slug === table.slug);
45
+ if (!collection) continue;
46
+ for (const [key, prop] of Object.entries(collection.properties ?? {})) {
47
+ if (!isDatabaseOwned(prop)) continue;
48
+ const name = resolveColumnName(key, prop);
49
+ const column = table.columns.find((each) => each.column === name);
50
+ if (!column || column.default || column.generated) continue;
51
+ found.push({
52
+ schema: table.schema,
53
+ table: table.table,
54
+ column: name
55
+ });
56
+ }
57
+ }
58
+ return found;
59
+ }
60
+ var isDatabaseOwned = (prop) => typeof prop === "object" && prop !== null && prop.databaseDefault === true;
61
+ /**
62
+ * Every column DEFAULT outside the system schemas, and whether Atlas can carry
63
+ * it (see the module comment for the two it cannot).
64
+ *
65
+ * Rendered with only `pg_catalog` on the search path, so anything outside it
66
+ * comes back schema-qualified and reads the same in whichever database
67
+ * `schema.sql` is loaded into.
68
+ */
69
+ var LIVE_DEFAULTS_SQL = `
70
+ SELECT n.nspname AS "schema",
71
+ c.relname AS "table",
72
+ a.attname AS "column",
73
+ pg_get_expr(d.adbin, d.adrelid) AS "expression",
74
+ EXISTS (
75
+ SELECT 1
76
+ FROM pg_depend dep
77
+ WHERE dep.classid = 'pg_attrdef'::regclass
78
+ AND dep.objid = d.oid
79
+ AND (
80
+ (dep.refclassid = 'pg_class'::regclass
81
+ AND EXISTS (SELECT 1 FROM pg_class s WHERE s.oid = dep.refobjid AND s.relkind = 'S'))
82
+ OR (dep.refclassid IN ('pg_proc'::regclass, 'pg_operator'::regclass)
83
+ AND dep.refobjid >= 16384)
84
+ )
85
+ ) AS "uncarried"
86
+ FROM pg_attrdef d
87
+ JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
88
+ JOIN pg_class c ON c.oid = d.adrelid
89
+ JOIN pg_namespace n ON n.oid = c.relnamespace
90
+ WHERE NOT a.attisdropped
91
+ AND a.attgenerated = ''
92
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
93
+ AND n.nspname NOT LIKE 'pg\\_%'
94
+ `;
95
+ /**
96
+ * The live DEFAULTs of `columns` that the desired state must carry so Atlas
97
+ * plans no `DROP DEFAULT`. A column with no DEFAULT, or one Atlas cannot carry,
98
+ * is not listed.
99
+ *
100
+ * Read in a transaction of its own, so the search path it narrows goes back
101
+ * when it ends.
102
+ */
103
+ async function readKeptDefaults(client, columns) {
104
+ if (columns.length === 0) return [];
105
+ const wanted = new Set(columns.map(keyOf));
106
+ let rows;
107
+ await client.query("BEGIN READ ONLY");
108
+ try {
109
+ await client.query("SET LOCAL search_path TO pg_catalog");
110
+ rows = (await client.query(LIVE_DEFAULTS_SQL)).rows;
111
+ } finally {
112
+ await client.query("ROLLBACK");
113
+ }
114
+ return rows.filter((row) => wanted.has(keyOf(row)) && !row.uncarried && row.expression).map(({ schema, table, column, expression }) => ({
115
+ schema,
116
+ table,
117
+ column,
118
+ expression
119
+ })).sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
120
+ }
121
+ var keyOf = (column) => `${column.schema}\u0000${column.table}\u0000${column.column}`;
122
+ var ident = (name) => `"${name.replace(/"/g, "\"\"")}"`;
123
+ /** What is appended to `schema.sql`: one `SET DEFAULT` per kept DEFAULT. */
124
+ function keptDefaultStatements(kept) {
125
+ return kept.map(({ schema, table, column, expression }) => `ALTER TABLE ${ident(schema)}.${ident(table)} ALTER COLUMN ${ident(column)} SET DEFAULT ${expression};`);
126
+ }
127
+ /** `schema.sql` with the kept DEFAULTs appended, under a comment saying why. */
128
+ function withKeptDefaults(desiredSql, kept) {
129
+ if (kept.length === 0) return desiredSql;
130
+ return `${desiredSql.replace(/\s*$/, "\n")}\n-- DEFAULTs the database holds for columns declared \`databaseDefault: true\`,
131
+ -- read before planning so the push leaves them as they are.
132
+ ${keptDefaultStatements(kept).join("\n")}\n`;
133
+ }
134
+ //#endregion
135
+ export { databaseOwnedDefaultColumns, readKeptDefaults, withKeptDefaults };
136
+
137
+ //# sourceMappingURL=push-kept-defaults-BGOlOqRQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-kept-defaults-BGOlOqRQ.js","names":[],"sources":["../src/schema/push-kept-defaults.ts"],"sourcesContent":["/**\n * The DEFAULTs `db push` keeps because a property says the database owns them.\n *\n * `databaseDefault: true` declares a DEFAULT somebody else wrote: a DBA's\n * `now()`, a migration's `'X-' || gen_random_uuid()`. The planner writes no\n * DEFAULT for it, since it has no expression to write, so `schema.sql` holds\n * the column without one. Atlas reads that file as the whole desired state and\n * planned `ALTER COLUMN … DROP DEFAULT`. The API kept letting creates through\n * without the field, and the database then refused them on NOT NULL.\n *\n * Atlas has no way to leave one column's DEFAULT alone: `--exclude` names\n * objects, and a DEFAULT is not one. So the desired state is made to agree with\n * the database instead. Before Atlas plans, the live DEFAULT of each such\n * column is read and appended to `schema.sql` as `ALTER TABLE … SET DEFAULT`,\n * and Atlas finds nothing to change.\n *\n * Two kinds are left out, because Atlas already does something else with them:\n *\n * - a DEFAULT reading a sequence (`nextval(…)`). Atlas does not manage\n * sequences and leaves those DEFAULTs alone, and the sequence does not exist\n * in the scratch database Atlas loads `schema.sql` into.\n * - a DEFAULT calling a function or operator the database defines (an\n * extension's `uuid_generate_v4()`, a project's own function). The scratch\n * database has none of them, and Atlas refuses such a column whether or not\n * it is in `schema.sql`; adding it would only change the error.\n */\nimport { declaredDatabaseExtensions, type CollectionConfig, type Property } from \"@rebasepro/types\";\nimport { planSchema } from \"./plan/plan-schema\";\nimport { resolveColumnName } from \"./column-plan-helpers\";\nimport type { Queryable } from \"./ensure-collection-tables\";\n\n/** A column whose DEFAULT the collections leave to the database. */\nexport interface DatabaseOwnedDefaultColumn {\n schema: string;\n table: string;\n column: string;\n}\n\n/** A DEFAULT the database holds for one of those columns, as SQL. */\nexport interface KeptDefault extends DatabaseOwnedDefaultColumn {\n expression: string;\n}\n\n/**\n * Every column declared `databaseDefault: true` that the planner writes no\n * DEFAULT for. An id strategy or `autoValue` writes one of its own, and that\n * one is the planner's to keep.\n */\nexport function databaseOwnedDefaultColumns(collections: CollectionConfig[]): DatabaseOwnedDefaultColumn[] {\n const declared = collections.filter(collection =>\n Object.values(collection.properties ?? {}).some(prop => isDatabaseOwned(prop)));\n if (declared.length === 0) return [];\n\n const plan = planSchema(collections, { databaseExtensions: declaredDatabaseExtensions() });\n const found: DatabaseOwnedDefaultColumn[] = [];\n for (const table of plan.tables) {\n const collection = declared.find(each => each.slug === table.slug);\n if (!collection) continue;\n for (const [key, prop] of Object.entries(collection.properties ?? {})) {\n if (!isDatabaseOwned(prop)) continue;\n const name = resolveColumnName(key, prop);\n const column = table.columns.find(each => each.column === name);\n if (!column || column.default || column.generated) continue;\n found.push({ schema: table.schema, table: table.table, column: name });\n }\n }\n return found;\n}\n\nconst isDatabaseOwned = (prop: unknown): prop is Property =>\n typeof prop === \"object\" && prop !== null &&\n (prop as { databaseDefault?: unknown }).databaseDefault === true;\n\n/**\n * Every column DEFAULT outside the system schemas, and whether Atlas can carry\n * it (see the module comment for the two it cannot).\n *\n * Rendered with only `pg_catalog` on the search path, so anything outside it\n * comes back schema-qualified and reads the same in whichever database\n * `schema.sql` is loaded into.\n */\nconst LIVE_DEFAULTS_SQL = `\n SELECT n.nspname AS \"schema\",\n c.relname AS \"table\",\n a.attname AS \"column\",\n pg_get_expr(d.adbin, d.adrelid) AS \"expression\",\n EXISTS (\n SELECT 1\n FROM pg_depend dep\n WHERE dep.classid = 'pg_attrdef'::regclass\n AND dep.objid = d.oid\n AND (\n (dep.refclassid = 'pg_class'::regclass\n AND EXISTS (SELECT 1 FROM pg_class s WHERE s.oid = dep.refobjid AND s.relkind = 'S'))\n OR (dep.refclassid IN ('pg_proc'::regclass, 'pg_operator'::regclass)\n AND dep.refobjid >= 16384)\n )\n ) AS \"uncarried\"\n FROM pg_attrdef d\n JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum\n JOIN pg_class c ON c.oid = d.adrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE NOT a.attisdropped\n AND a.attgenerated = ''\n AND n.nspname NOT IN ('pg_catalog', 'information_schema')\n AND n.nspname NOT LIKE 'pg\\\\_%'\n`;\n\ninterface LiveDefaultRow extends KeptDefault {\n uncarried: boolean;\n}\n\n/**\n * The live DEFAULTs of `columns` that the desired state must carry so Atlas\n * plans no `DROP DEFAULT`. A column with no DEFAULT, or one Atlas cannot carry,\n * is not listed.\n *\n * Read in a transaction of its own, so the search path it narrows goes back\n * when it ends.\n */\nexport async function readKeptDefaults(client: Queryable, columns: DatabaseOwnedDefaultColumn[]): Promise<KeptDefault[]> {\n if (columns.length === 0) return [];\n const wanted = new Set(columns.map(keyOf));\n let rows: LiveDefaultRow[];\n await client.query(\"BEGIN READ ONLY\");\n try {\n await client.query(\"SET LOCAL search_path TO pg_catalog\");\n rows = (await client.query<LiveDefaultRow>(LIVE_DEFAULTS_SQL)).rows;\n } finally {\n await client.query(\"ROLLBACK\");\n }\n return rows\n .filter(row => wanted.has(keyOf(row)) && !row.uncarried && row.expression)\n .map(({ schema, table, column, expression }) => ({ schema, table, column, expression }))\n .sort((a, b) => keyOf(a).localeCompare(keyOf(b)));\n}\n\nconst keyOf = (column: DatabaseOwnedDefaultColumn): string => `${column.schema}\\u0000${column.table}\\u0000${column.column}`;\n\nconst ident = (name: string): string => `\"${name.replace(/\"/g, \"\\\"\\\"\")}\"`;\n\n/** What is appended to `schema.sql`: one `SET DEFAULT` per kept DEFAULT. */\nexport function keptDefaultStatements(kept: KeptDefault[]): string[] {\n return kept.map(({ schema, table, column, expression }) =>\n `ALTER TABLE ${ident(schema)}.${ident(table)} ALTER COLUMN ${ident(column)} SET DEFAULT ${expression};`);\n}\n\n/** `schema.sql` with the kept DEFAULTs appended, under a comment saying why. */\nexport function withKeptDefaults(desiredSql: string, kept: KeptDefault[]): string {\n if (kept.length === 0) return desiredSql;\n return `${desiredSql.replace(/\\s*$/, \"\\n\")}\\n` +\n \"-- DEFAULTs the database holds for columns declared `databaseDefault: true`,\\n\" +\n \"-- read before planning so the push leaves them as they are.\\n\" +\n `${keptDefaultStatements(kept).join(\"\\n\")}\\n`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgDA,SAAgB,4BAA4B,aAA+D;CACvG,MAAM,WAAW,YAAY,QAAO,eAChC,OAAO,OAAO,WAAW,cAAc,CAAC,CAAC,CAAC,CAAC,MAAK,SAAQ,gBAAgB,IAAI,CAAC,CAAC;CAClF,IAAI,SAAS,WAAW,GAAG,OAAO,CAAC;CAEnC,MAAM,OAAO,WAAW,aAAa,EAAE,oBAAoB,2BAA2B,EAAE,CAAC;CACzF,MAAM,QAAsC,CAAC;CAC7C,KAAK,MAAM,SAAS,KAAK,QAAQ;EAC7B,MAAM,aAAa,SAAS,MAAK,SAAQ,KAAK,SAAS,MAAM,IAAI;EACjE,IAAI,CAAC,YAAY;EACjB,KAAK,MAAM,CAAC,KAAK,SAAS,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAAG;GACnE,IAAI,CAAC,gBAAgB,IAAI,GAAG;GAC5B,MAAM,OAAO,kBAAkB,KAAK,IAAI;GACxC,MAAM,SAAS,MAAM,QAAQ,MAAK,SAAQ,KAAK,WAAW,IAAI;GAC9D,IAAI,CAAC,UAAU,OAAO,WAAW,OAAO,WAAW;GACnD,MAAM,KAAK;IAAE,QAAQ,MAAM;IAAQ,OAAO,MAAM;IAAO,QAAQ;GAAK,CAAC;EACzE;CACJ;CACA,OAAO;AACX;AAEA,IAAM,mBAAmB,SACrB,OAAO,SAAS,YAAY,SAAS,QACpC,KAAuC,oBAAoB;;;;;;;;;AAUhE,IAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuC1B,eAAsB,iBAAiB,QAAmB,SAA+D;CACrH,IAAI,QAAQ,WAAW,GAAG,OAAO,CAAC;CAClC,MAAM,SAAS,IAAI,IAAI,QAAQ,IAAI,KAAK,CAAC;CACzC,IAAI;CACJ,MAAM,OAAO,MAAM,iBAAiB;CACpC,IAAI;EACA,MAAM,OAAO,MAAM,qCAAqC;EACxD,QAAQ,MAAM,OAAO,MAAsB,iBAAiB,EAAA,CAAG;CACnE,UAAU;EACN,MAAM,OAAO,MAAM,UAAU;CACjC;CACA,OAAO,KACF,QAAO,QAAO,OAAO,IAAI,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,aAAa,IAAI,UAAU,CAAC,CACzE,KAAK,EAAE,QAAQ,OAAO,QAAQ,kBAAkB;EAAE;EAAQ;EAAO;EAAQ;CAAW,EAAE,CAAC,CACvF,MAAM,GAAG,MAAM,MAAM,CAAC,CAAC,CAAC,cAAc,MAAM,CAAC,CAAC,CAAC;AACxD;AAEA,IAAM,SAAS,WAA+C,GAAG,OAAO,OAAO,QAAQ,OAAO,MAAM,QAAQ,OAAO;AAEnH,IAAM,SAAS,SAAyB,IAAI,KAAK,QAAQ,MAAM,MAAM,EAAE;;AAGvE,SAAgB,sBAAsB,MAA+B;CACjE,OAAO,KAAK,KAAK,EAAE,QAAQ,OAAO,QAAQ,iBACtC,eAAe,MAAM,MAAM,EAAE,GAAG,MAAM,KAAK,EAAE,gBAAgB,MAAM,MAAM,EAAE,eAAe,WAAW,EAAE;AAC/G;;AAGA,SAAgB,iBAAiB,YAAoB,MAA6B;CAC9E,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,OAAO,GAAG,WAAW,QAAQ,QAAQ,IAAI,EAAE;;EAGpC,sBAAsB,IAAI,CAAC,CAAC,KAAK,IAAI,EAAE;AAClD"}
@@ -1,8 +1,8 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { n as planSchema } from "./plan-schema-Czf3q4-Q.js";
4
- import { s as diffPlanAgainstCatalogue } from "./deferred-index-build-BE0I6sYU.js";
5
- import { i as readExistingSchema } from "./ensure-collection-tables-Cet4650m.js";
4
+ import { s as diffPlanAgainstCatalogue } from "./deferred-index-build-CqaOv1Xx.js";
5
+ import { i as readExistingSchema } from "./ensure-collection-tables-CFIRq8ni.js";
6
6
  import { declaredDatabaseExtensions } from "@rebasepro/types";
7
7
  //#region src/schema/push-legacy-renames.ts
8
8
  /**
@@ -31,4 +31,4 @@ async function legacyForeignKeyRenames(client, collections) {
31
31
  //#endregion
32
32
  export { legacyForeignKeyRenames };
33
33
 
34
- //# sourceMappingURL=push-legacy-renames-CLgYYwPE.js.map
34
+ //# sourceMappingURL=push-legacy-renames-BwaKi8xF.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"push-legacy-renames-CLgYYwPE.js","names":[],"sources":["../src/schema/push-legacy-renames.ts"],"sourcesContent":["/**\n * The relation columns an aged database still holds under the name Rebase\n * derived before 0.13 (`categorie_id`, `people_id`), for `db push` to rename\n * before Atlas plans.\n *\n * Boot renames them losslessly (`diff-plan.ts`, `renameLegacyColumn`). Push\n * runs Atlas, which sees only \"drop `categorie_id`, add `category_id` NOT\n * NULL\": it listed the links as destructive changes to approve, and with\n * `--allow-destructive` dropped them. A pipeline that runs only `db push`\n * could not upgrade an aged database at all. Renaming first — the same\n * metadata-only statement boot runs — leaves Atlas nothing to plan.\n */\nimport { declaredDatabaseExtensions, type CollectionConfig } from \"@rebasepro/types\";\nimport { planSchema } from \"./plan/plan-schema\";\nimport { diffPlanAgainstCatalogue, type LegacyForeignKey } from \"./plan/diff-plan\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\n\nexport interface LegacyRename extends LegacyForeignKey {\n /** `ALTER TABLE … RENAME COLUMN …;` */\n sql: string;\n}\n\nexport async function legacyForeignKeyRenames(client: Queryable, collections: CollectionConfig[]): Promise<LegacyRename[]> {\n const schema = planSchema(collections, { databaseExtensions: declaredDatabaseExtensions() });\n const schemas = Array.from(new Set(schema.tables.map(table => table.schema)));\n const existing = await readExistingSchema(client, schemas, { probeTables: () => [] });\n const plan = diffPlanAgainstCatalogue(schema, existing);\n return plan.legacyForeignKeys.map(legacy => {\n const action = plan.actions.find(a => a.kind === \"rename-column\" && a.target === `${legacy.table}.${legacy.expected}`);\n return { ...legacy, sql: action?.sql ?? \"\" };\n }).filter(rename => rename.sql !== \"\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAsBA,eAAsB,wBAAwB,QAAmB,aAA0D;CACvH,MAAM,SAAS,WAAW,aAAa,EAAE,oBAAoB,2BAA2B,EAAE,CAAC;CAG3F,MAAM,OAAO,yBAAyB,QAAQ,MADvB,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,OAAO,OAAO,KAAI,UAAS,MAAM,MAAM,CAAC,CACzB,GAAS,EAAE,mBAAmB,CAAC,EAAE,CAAC,CAC9B;CACtD,OAAO,KAAK,kBAAkB,KAAI,WAAU;EACxC,MAAM,SAAS,KAAK,QAAQ,MAAK,MAAK,EAAE,SAAS,mBAAmB,EAAE,WAAW,GAAG,OAAO,MAAM,GAAG,OAAO,UAAU;EACrH,OAAO;GAAE,GAAG;GAAQ,KAAK,QAAQ,OAAO;EAAG;CAC/C,CAAC,CAAC,CAAC,QAAO,WAAU,OAAO,QAAQ,EAAE;AACzC"}
1
+ {"version":3,"file":"push-legacy-renames-BwaKi8xF.js","names":[],"sources":["../src/schema/push-legacy-renames.ts"],"sourcesContent":["/**\n * The relation columns an aged database still holds under the name Rebase\n * derived before 0.13 (`categorie_id`, `people_id`), for `db push` to rename\n * before Atlas plans.\n *\n * Boot renames them losslessly (`diff-plan.ts`, `renameLegacyColumn`). Push\n * runs Atlas, which sees only \"drop `categorie_id`, add `category_id` NOT\n * NULL\": it listed the links as destructive changes to approve, and with\n * `--allow-destructive` dropped them. A pipeline that runs only `db push`\n * could not upgrade an aged database at all. Renaming first — the same\n * metadata-only statement boot runs — leaves Atlas nothing to plan.\n */\nimport { declaredDatabaseExtensions, type CollectionConfig } from \"@rebasepro/types\";\nimport { planSchema } from \"./plan/plan-schema\";\nimport { diffPlanAgainstCatalogue, type LegacyForeignKey } from \"./plan/diff-plan\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\n\nexport interface LegacyRename extends LegacyForeignKey {\n /** `ALTER TABLE … RENAME COLUMN …;` */\n sql: string;\n}\n\nexport async function legacyForeignKeyRenames(client: Queryable, collections: CollectionConfig[]): Promise<LegacyRename[]> {\n const schema = planSchema(collections, { databaseExtensions: declaredDatabaseExtensions() });\n const schemas = Array.from(new Set(schema.tables.map(table => table.schema)));\n const existing = await readExistingSchema(client, schemas, { probeTables: () => [] });\n const plan = diffPlanAgainstCatalogue(schema, existing);\n return plan.legacyForeignKeys.map(legacy => {\n const action = plan.actions.find(a => a.kind === \"rename-column\" && a.target === `${legacy.table}.${legacy.expected}`);\n return { ...legacy, sql: action?.sql ?? \"\" };\n }).filter(rename => rename.sql !== \"\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAsBA,eAAsB,wBAAwB,QAAmB,aAA0D;CACvH,MAAM,SAAS,WAAW,aAAa,EAAE,oBAAoB,2BAA2B,EAAE,CAAC;CAG3F,MAAM,OAAO,yBAAyB,QAAQ,MADvB,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,OAAO,OAAO,KAAI,UAAS,MAAM,MAAM,CAAC,CACzB,GAAS,EAAE,mBAAmB,CAAC,EAAE,CAAC,CAC9B;CACtD,OAAO,KAAK,kBAAkB,KAAI,WAAU;EACxC,MAAM,SAAS,KAAK,QAAQ,MAAK,MAAK,EAAE,SAAS,mBAAmB,EAAE,WAAW,GAAG,OAAO,MAAM,GAAG,OAAO,UAAU;EACrH,OAAO;GAAE,GAAG;GAAQ,KAAK,QAAQ,OAAO;EAAG;CAC/C,CAAC,CAAC,CAAC,QAAO,WAAU,OAAO,QAAQ,EAAE;AACzC"}
@@ -2,7 +2,7 @@ import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { n as generatePostgresPoliciesDdl } from "./generate-postgres-ddl-logic-DPl1oH_z.js";
4
4
  import { a as parseExpectedPolicies, o as partitionOrphans, t as checkPolicyDrift } from "./policy-drift-X4A-xAzv.js";
5
- import { probeRenderedPolicies, readPolicyStamps, tablesRlsWouldEnable } from "./ensure-collection-policies-BnGOURpG.js";
5
+ import { probeRenderedPolicies, readPolicyStamps, tablesRlsWouldEnable } from "./ensure-collection-policies-DLWgabQ2.js";
6
6
  //#region src/schema/push-policy-plan.ts
7
7
  var keyOf = (p) => `${p.schema}.${p.table}.${p.name}`;
8
8
  /** What a stamp that no longer matches says. */
@@ -124,4 +124,4 @@ async function underSearchPath(client, searchPath, read) {
124
124
  //#endregion
125
125
  export { formatPolicyPushPlan, planPolicyPush, summarizePolicyPushPlan, underSearchPath };
126
126
 
127
- //# sourceMappingURL=push-policy-plan-D2gpQsdv.js.map
127
+ //# sourceMappingURL=push-policy-plan-DQ2tWceD.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"push-policy-plan-D2gpQsdv.js","names":[],"sources":["../src/schema/push-policy-plan.ts"],"sourcesContent":["/**\n * What `rebase db push` will do to row-level security, read before it does it.\n *\n * Atlas has no model for a policy: its free tier neither plans nor reads one.\n * So its plan, which was everything `db push --dry-run` showed, never named\n * one. After `schema introspect` on a database whose tables had policies of\n * their own and no admin baseline, the dry run said \"No changes: the database\n * already matches these collections\", and the push then created eight\n * `<table>_default_admin_*` policies that let an admin read and write every\n * row.\n *\n * The plan is the push's own steps, read instead of run:\n *\n * - **RLS switched on:** the tables `lockCollectionTables` switches on\n * ({@link tablesRlsWouldEnable}).\n * - **Policies created and replaced:** `policies.sql`, which DROPs and CREATEs\n * every declared policy, against the database as `checkPolicyDrift` reads\n * it: by name, `TO` list, command, mode and which clauses there are. Text\n * is compared through the stamp the push and every boot write\n * ({@link readPolicyStamps}), which is what catches a rule whose SQL changed\n * under the same name. Where the stamp does not match, or there is none (a\n * policy `db migrate` or a hand made), the policy is made again under a probe\n * name and rolled back ({@link probeRenderedPolicies}): one Postgres would\n * store exactly as it is now is no change. Only one that could not be probed\n * is listed as unstamped.\n * - **Policies dropped and kept:** {@link partitionOrphans}, which is how the\n * push's reconcile step decides.\n */\nimport type { CollectionConfig } from \"@rebasepro/types\";\nimport {\n checkPolicyDrift,\n parseExpectedPolicies,\n partitionOrphans,\n type PolicyRef,\n type Queryable\n} from \"../security/policy-drift\";\nimport { generatePostgresPoliciesDdl } from \"./generate-postgres-ddl-logic\";\nimport { probeRenderedPolicies, readPolicyStamps, tablesRlsWouldEnable } from \"./ensure-collection-policies\";\n\nexport interface PolicyPushPlan {\n /** Declared tables in the database with row-level security off, as `schema.table`: the push switches it on. */\n enableRls: string[];\n /** Declared policies the database does not have: the push creates them. */\n create: PolicyRef[];\n /** Declared policies the database has under the same name, defined otherwise: the push replaces them. */\n replace: { policy: PolicyRef; why: string[] }[];\n /** Policies an earlier push generated from a rule edited since: the push drops them. */\n drop: PolicyRef[];\n /** Policies no collection describes, which the push leaves in force. */\n keep: PolicyRef[];\n /** Declared policies the database has with no stamp of Rebase's: re-created from their rules, changed or not. */\n unstamped: PolicyRef[];\n}\n\nconst keyOf = (p: PolicyRef): string => `${p.schema}.${p.table}.${p.name}`;\n\n/** What a stamp that no longer matches says. */\nconst STALE = \"its rule, or the policy itself, has changed since Rebase wrote it\";\n\n/** What a policy with no stamp, whose rule stores something else, says. */\nconst UNSTAMPED_DIFFERS = \"it carries no stamp of Rebase's, and its rule defines it otherwise\";\n\n/**\n * Read the plan.\n *\n * @param options.searchPath the `search_path` the stamps were written under\n * when the connection's own differs — the one `db push` stamps under.\n * See {@link readPolicyStamps}.\n */\nexport async function planPolicyPush(\n client: Queryable,\n collections: CollectionConfig[],\n options: { searchPath?: string } = {}\n): Promise<PolicyPushPlan> {\n const drift = await checkPolicyDrift(client, collections);\n const { dropped, kept } = partitionOrphans(drift, collections);\n const enableRls = await tablesRlsWouldEnable(client, collections);\n const stamps = await underSearchPath(client, options.searchPath, () => readPolicyStamps(client, collections));\n\n const replace = drift.diverged.map(d => ({ policy: d.expected, why: d.differences }));\n const settled = new Set([...drift.missing, ...drift.diverged.map(d => d.expected)].map(keyOf));\n const unsure: { policy: PolicyRef; state: \"stale\" | \"unstamped\" }[] = [];\n for (const policy of parseExpectedPolicies(generatePostgresPoliciesDdl(collections))) {\n if (settled.has(keyOf(policy))) continue;\n const state = stamps.get(`${policy.schema}.${policy.table}`)?.get(policy.name);\n if (state === \"stale\" || state === \"unstamped\") unsure.push({ policy, state });\n }\n // What the stamp cannot say, asked of Postgres: would the push store the\n // definition that is there? A policy it would re-create exactly is no change.\n const rendersAsStored = unsure.length === 0\n ? new Map<string, boolean>()\n : await probeRenderedPolicies(client, collections, unsure.map(entry => entry.policy), { searchPath: options.searchPath });\n const unstamped: PolicyRef[] = [];\n for (const { policy, state } of unsure) {\n const same = rendersAsStored.get(keyOf(policy));\n if (same === true) continue;\n if (state === \"stale\") replace.push({ policy, why: [STALE] });\n else if (same === false) replace.push({ policy, why: [UNSTAMPED_DIFFERS] });\n else unstamped.push(policy);\n }\n\n return { enableRls, create: drift.missing, replace, drop: dropped, keep: kept, unstamped };\n}\n\n/** Whether the push would change any row-level security. Kept and unstamped policies are not changes. */\nexport function policyPlanChanges(plan: PolicyPushPlan): boolean {\n return plan.enableRls.length + plan.create.length + plan.replace.length + plan.drop.length > 0;\n}\n\nconst describe = (p: PolicyRef): string => `${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`;\n\n/**\n * The plan as `db push --dry-run` prints it: `changes`, what the push does,\n * and `notes`, what it leaves or cannot tell. Both empty when there is\n * nothing to say.\n */\nexport function formatPolicyPushPlan(plan: PolicyPushPlan): { changes: string[]; notes: string[] } {\n const changes: string[] = [];\n if (policyPlanChanges(plan)) {\n changes.push(\" Planned row-level security changes:\");\n for (const table of plan.enableRls) changes.push(` switch RLS on ${table}`);\n for (const p of plan.create) changes.push(` create policy ${describe(p)}`);\n for (const { policy, why } of plan.replace) changes.push(` replace policy ${describe(policy)}: ${why.join(\"; \")}`);\n for (const p of plan.drop) changes.push(` drop policy ${describe(p)}, left by a rule edited since`);\n }\n\n const notes: string[] = [];\n if (plan.unstamped.length > 0) {\n notes.push(` ${plan.unstamped.length} declared ${plan.unstamped.length === 1 ? \"policy carries\" : \"policies carry\"} ` +\n \"no stamp of Rebase's, so the push re-creates each from its rule\");\n notes.push(\" and cannot tell beforehand whether that changes it:\");\n for (const p of plan.unstamped) notes.push(` ${describe(p)}`);\n }\n if (plan.keep.length > 0) {\n notes.push(\" Policies in the database that no collection describes, which the push leaves in force:\");\n for (const p of plan.keep) notes.push(` ${describe(p)}`);\n }\n return { changes, notes };\n}\n\n/** One line for the push itself: the counts, or `null` when it changes no row-level security. */\nexport function summarizePolicyPushPlan(plan: PolicyPushPlan): string | null {\n if (!policyPlanChanges(plan)) return null;\n const count = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`;\n const parts = [\n plan.create.length > 0 ? count(plan.create.length, \"policy to create\", \"policies to create\") : \"\",\n plan.replace.length > 0 ? `${plan.replace.length} to replace` : \"\",\n plan.drop.length > 0 ? `${plan.drop.length} to drop` : \"\",\n plan.enableRls.length > 0 ? `RLS to switch on for ${count(plan.enableRls.length, \"table\", \"tables\")}` : \"\"\n ].filter(Boolean);\n return ` Row-level security: ${parts.join(\", \")}. \\`rebase db push --dry-run\\` lists them.`;\n}\n\n/**\n * Run `read` under `searchPath`, set for one transaction and gone after it, or\n * as the connection is when there is none to set.\n */\nexport async function underSearchPath<T>(client: Queryable, searchPath: string | undefined, read: () => Promise<T>): Promise<T> {\n if (!searchPath) return read();\n await client.query(\"BEGIN\");\n try {\n await client.query(`SET LOCAL search_path TO ${searchPath}`);\n const result = await read();\n await client.query(\"COMMIT\");\n return result;\n } catch (err) {\n await client.query(\"ROLLBACK\").catch(() => undefined);\n throw err;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"push-policy-plan-DQ2tWceD.js","names":[],"sources":["../src/schema/push-policy-plan.ts"],"sourcesContent":["/**\n * What `rebase db push` will do to row-level security, read before it does it.\n *\n * Atlas has no model for a policy: its free tier neither plans nor reads one.\n * So its plan, which was everything `db push --dry-run` showed, never named\n * one. After `schema introspect` on a database whose tables had policies of\n * their own and no admin baseline, the dry run said \"No changes: the database\n * already matches these collections\", and the push then created eight\n * `<table>_default_admin_*` policies that let an admin read and write every\n * row.\n *\n * The plan is the push's own steps, read instead of run:\n *\n * - **RLS switched on:** the tables `lockCollectionTables` switches on\n * ({@link tablesRlsWouldEnable}).\n * - **Policies created and replaced:** `policies.sql`, which DROPs and CREATEs\n * every declared policy, against the database as `checkPolicyDrift` reads\n * it: by name, `TO` list, command, mode and which clauses there are. Text\n * is compared through the stamp the push and every boot write\n * ({@link readPolicyStamps}), which is what catches a rule whose SQL changed\n * under the same name. Where the stamp does not match, or there is none (a\n * policy `db migrate` or a hand made), the policy is made again under a probe\n * name and rolled back ({@link probeRenderedPolicies}): one Postgres would\n * store exactly as it is now is no change. Only one that could not be probed\n * is listed as unstamped.\n * - **Policies dropped and kept:** {@link partitionOrphans}, which is how the\n * push's reconcile step decides.\n */\nimport type { CollectionConfig } from \"@rebasepro/types\";\nimport {\n checkPolicyDrift,\n parseExpectedPolicies,\n partitionOrphans,\n type PolicyRef,\n type Queryable\n} from \"../security/policy-drift\";\nimport { generatePostgresPoliciesDdl } from \"./generate-postgres-ddl-logic\";\nimport { probeRenderedPolicies, readPolicyStamps, tablesRlsWouldEnable } from \"./ensure-collection-policies\";\n\nexport interface PolicyPushPlan {\n /** Declared tables in the database with row-level security off, as `schema.table`: the push switches it on. */\n enableRls: string[];\n /** Declared policies the database does not have: the push creates them. */\n create: PolicyRef[];\n /** Declared policies the database has under the same name, defined otherwise: the push replaces them. */\n replace: { policy: PolicyRef; why: string[] }[];\n /** Policies an earlier push generated from a rule edited since: the push drops them. */\n drop: PolicyRef[];\n /** Policies no collection describes, which the push leaves in force. */\n keep: PolicyRef[];\n /** Declared policies the database has with no stamp of Rebase's: re-created from their rules, changed or not. */\n unstamped: PolicyRef[];\n}\n\nconst keyOf = (p: PolicyRef): string => `${p.schema}.${p.table}.${p.name}`;\n\n/** What a stamp that no longer matches says. */\nconst STALE = \"its rule, or the policy itself, has changed since Rebase wrote it\";\n\n/** What a policy with no stamp, whose rule stores something else, says. */\nconst UNSTAMPED_DIFFERS = \"it carries no stamp of Rebase's, and its rule defines it otherwise\";\n\n/**\n * Read the plan.\n *\n * @param options.searchPath the `search_path` the stamps were written under\n * when the connection's own differs — the one `db push` stamps under.\n * See {@link readPolicyStamps}.\n */\nexport async function planPolicyPush(\n client: Queryable,\n collections: CollectionConfig[],\n options: { searchPath?: string } = {}\n): Promise<PolicyPushPlan> {\n const drift = await checkPolicyDrift(client, collections);\n const { dropped, kept } = partitionOrphans(drift, collections);\n const enableRls = await tablesRlsWouldEnable(client, collections);\n const stamps = await underSearchPath(client, options.searchPath, () => readPolicyStamps(client, collections));\n\n const replace = drift.diverged.map(d => ({ policy: d.expected, why: d.differences }));\n const settled = new Set([...drift.missing, ...drift.diverged.map(d => d.expected)].map(keyOf));\n const unsure: { policy: PolicyRef; state: \"stale\" | \"unstamped\" }[] = [];\n for (const policy of parseExpectedPolicies(generatePostgresPoliciesDdl(collections))) {\n if (settled.has(keyOf(policy))) continue;\n const state = stamps.get(`${policy.schema}.${policy.table}`)?.get(policy.name);\n if (state === \"stale\" || state === \"unstamped\") unsure.push({ policy, state });\n }\n // What the stamp cannot say, asked of Postgres: would the push store the\n // definition that is there? A policy it would re-create exactly is no change.\n const rendersAsStored = unsure.length === 0\n ? new Map<string, boolean>()\n : await probeRenderedPolicies(client, collections, unsure.map(entry => entry.policy), { searchPath: options.searchPath });\n const unstamped: PolicyRef[] = [];\n for (const { policy, state } of unsure) {\n const same = rendersAsStored.get(keyOf(policy));\n if (same === true) continue;\n if (state === \"stale\") replace.push({ policy, why: [STALE] });\n else if (same === false) replace.push({ policy, why: [UNSTAMPED_DIFFERS] });\n else unstamped.push(policy);\n }\n\n return { enableRls, create: drift.missing, replace, drop: dropped, keep: kept, unstamped };\n}\n\n/** Whether the push would change any row-level security. Kept and unstamped policies are not changes. */\nexport function policyPlanChanges(plan: PolicyPushPlan): boolean {\n return plan.enableRls.length + plan.create.length + plan.replace.length + plan.drop.length > 0;\n}\n\nconst describe = (p: PolicyRef): string => `${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`;\n\n/**\n * The plan as `db push --dry-run` prints it: `changes`, what the push does,\n * and `notes`, what it leaves or cannot tell. Both empty when there is\n * nothing to say.\n */\nexport function formatPolicyPushPlan(plan: PolicyPushPlan): { changes: string[]; notes: string[] } {\n const changes: string[] = [];\n if (policyPlanChanges(plan)) {\n changes.push(\" Planned row-level security changes:\");\n for (const table of plan.enableRls) changes.push(` switch RLS on ${table}`);\n for (const p of plan.create) changes.push(` create policy ${describe(p)}`);\n for (const { policy, why } of plan.replace) changes.push(` replace policy ${describe(policy)}: ${why.join(\"; \")}`);\n for (const p of plan.drop) changes.push(` drop policy ${describe(p)}, left by a rule edited since`);\n }\n\n const notes: string[] = [];\n if (plan.unstamped.length > 0) {\n notes.push(` ${plan.unstamped.length} declared ${plan.unstamped.length === 1 ? \"policy carries\" : \"policies carry\"} ` +\n \"no stamp of Rebase's, so the push re-creates each from its rule\");\n notes.push(\" and cannot tell beforehand whether that changes it:\");\n for (const p of plan.unstamped) notes.push(` ${describe(p)}`);\n }\n if (plan.keep.length > 0) {\n notes.push(\" Policies in the database that no collection describes, which the push leaves in force:\");\n for (const p of plan.keep) notes.push(` ${describe(p)}`);\n }\n return { changes, notes };\n}\n\n/** One line for the push itself: the counts, or `null` when it changes no row-level security. */\nexport function summarizePolicyPushPlan(plan: PolicyPushPlan): string | null {\n if (!policyPlanChanges(plan)) return null;\n const count = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`;\n const parts = [\n plan.create.length > 0 ? count(plan.create.length, \"policy to create\", \"policies to create\") : \"\",\n plan.replace.length > 0 ? `${plan.replace.length} to replace` : \"\",\n plan.drop.length > 0 ? `${plan.drop.length} to drop` : \"\",\n plan.enableRls.length > 0 ? `RLS to switch on for ${count(plan.enableRls.length, \"table\", \"tables\")}` : \"\"\n ].filter(Boolean);\n return ` Row-level security: ${parts.join(\", \")}. \\`rebase db push --dry-run\\` lists them.`;\n}\n\n/**\n * Run `read` under `searchPath`, set for one transaction and gone after it, or\n * as the connection is when there is none to set.\n */\nexport async function underSearchPath<T>(client: Queryable, searchPath: string | undefined, read: () => Promise<T>): Promise<T> {\n if (!searchPath) return read();\n await client.query(\"BEGIN\");\n try {\n await client.query(`SET LOCAL search_path TO ${searchPath}`);\n const result = await read();\n await client.query(\"COMMIT\");\n return result;\n } catch (err) {\n await client.query(\"ROLLBACK\").catch(() => undefined);\n throw err;\n }\n}\n"],"mappings":"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"}
@@ -13,6 +13,7 @@ var rls_enforcement_exports = /* @__PURE__ */ __exportAll({
13
13
  detectConnectionPosture: () => detectConnectionPosture,
14
14
  ensureAppRole: () => ensureAppRole,
15
15
  ensureAppRoleGrants: () => ensureAppRoleGrants,
16
+ findExposedInternalTables: () => findExposedInternalTables,
16
17
  internalTablesGrantedToUserRole: () => internalTablesGrantedToUserRole,
17
18
  revokeUserRoleSql: () => revokeUserRoleSql,
18
19
  validatePolicyPgRoles: () => validatePolicyPgRoles,
@@ -161,7 +162,15 @@ function appRoleSetupInstructions(connectionRole, schemas) {
161
162
  async function ensureAppRole(run, schemas) {
162
163
  const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
163
164
  if ((await run(`SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE$1}'`)).length === 0) try {
164
- await run(`CREATE ROLE ${REBASE_USER_ROLE$1} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
165
+ await run(`
166
+ DO $rebase_role$
167
+ BEGIN
168
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE$1}') THEN
169
+ CREATE ROLE ${REBASE_USER_ROLE$1} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;
170
+ END IF;
171
+ END
172
+ $rebase_role$
173
+ `);
165
174
  } catch (err) {
166
175
  throw new Error(`Failed to create the "${REBASE_USER_ROLE$1}" role: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions("current connection role", uniqueSchemas));
167
176
  }
@@ -182,6 +191,36 @@ async function ensureAppRole(run, schemas) {
182
191
  var sqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
183
192
  var textArray = (values) => values.length === 0 ? "ARRAY[]::text[]" : `ARRAY[${values.map(sqlLiteral).join(", ")}]::text[]`;
184
193
  /**
194
+ * The framework's internal tables in `schemas` that the request role can
195
+ * reach — `schema.table`, sorted — when nothing is left to take it back off.
196
+ *
197
+ * Every provisioning boot repairs this: each store revokes on its own table
198
+ * and {@link ensureAppRole}'s sweep revokes on the rest, because the grant
199
+ * tier hands the request role DML on whole schemas. A backend that changes
200
+ * nothing in its database runs neither, so a database whose `rebase_user` can
201
+ * read `refresh_tokens` or `api_keys` would stay that way for as long as it
202
+ * served. This is the question the sweep would have answered, asked instead.
203
+ *
204
+ * "Can reach" is what the role can actually do — through a grant to it, to a
205
+ * role it inherits, or to PUBLIC, on the table or on any of its columns —
206
+ * because that, not how the ACL is spelled, is what a request running as it
207
+ * gets. A table with RLS on is somebody's collection by that same rule as the
208
+ * sweep's, and is left out.
209
+ */
210
+ async function findExposedInternalTables(run, schemas) {
211
+ return (await run(`
212
+ SELECT n.nspname || '.' || c.relname AS name
213
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
214
+ WHERE n.nspname = ANY(${textArray(Array.from(new Set(schemas)))})
215
+ AND c.relname = ANY(${textArray(REBASE_INTERNAL_TABLES)})
216
+ AND c.relkind IN ('r', 'p')
217
+ AND NOT c.relrowsecurity
218
+ AND (has_table_privilege(${sqlLiteral(REBASE_USER_ROLE$1)}, c.oid, 'SELECT, INSERT, UPDATE, DELETE')
219
+ OR has_any_column_privilege(${sqlLiteral(REBASE_USER_ROLE$1)}, c.oid, 'SELECT, INSERT, UPDATE'))
220
+ ORDER BY 1
221
+ `)).map((row) => String(row.name));
222
+ }
223
+ /**
185
224
  * The grant tier's state for every existing schema in `schemas`, in one read.
186
225
  *
187
226
  * A grant counts as present only as the statement this tier runs would record
@@ -458,6 +497,6 @@ async function validatePolicyPgRoles(run, collections, requestRole = REBASE_USER
458
497
  if (problems.length > 0) throw new Error(`Security rules target PostgreSQL roles this server cannot use. Requests run as "${requestRole}", so these policies would never apply and every row would be filtered out — the collections would look empty rather than error.\n\n` + problems.join("\n\n") + "\n");
459
498
  }
460
499
  //#endregion
461
- export { ensureAppRoleGrants as a, rls_enforcement_exports as c, warnOnLegacyRlsFunctions as d, warnOnRoleSchemaCollision as f, ensureAppRole as i, validatePolicyPgRoles as l, applyAuthContext as n, internalTablesGrantedToUserRole as o, detectConnectionPosture as r, revokeUserRoleSql as s, REBASE_USER_ROLE$1 as t, warnOnAnonymousGrants as u };
500
+ export { ensureAppRoleGrants as a, revokeUserRoleSql as c, warnOnAnonymousGrants as d, warnOnLegacyRlsFunctions as f, ensureAppRole as i, rls_enforcement_exports as l, applyAuthContext as n, findExposedInternalTables as o, warnOnRoleSchemaCollision as p, detectConnectionPosture as r, internalTablesGrantedToUserRole as s, REBASE_USER_ROLE$1 as t, validatePolicyPgRoles as u };
462
501
 
463
- //# sourceMappingURL=rls-enforcement-DDyNoOSy.js.map
502
+ //# sourceMappingURL=rls-enforcement-BOHuHVlc.js.map