@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g0d7af95

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 (99) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +75 -12
  3. package/dist/PostgresBootstrapper.d.ts +6 -6
  4. package/dist/auth/services.d.ts +1 -1
  5. package/dist/backup/backup-cron.d.ts +1 -1
  6. package/dist/backup/backup-service.d.ts +2 -2
  7. package/dist/backup/index.d.ts +4 -4
  8. package/dist/{backup-service-BZoixhVl.js → backup-service-BtgHxfFm.js} +5 -4
  9. package/dist/{backup-service-BZoixhVl.js.map → backup-service-BtgHxfFm.js.map} +1 -1
  10. package/dist/cli-helpers.d.ts +41 -0
  11. package/dist/{auth-users-columns-CgyPWQ18.js → collection-index-DxJBvVTH.js} +486 -503
  12. package/dist/collection-index-DxJBvVTH.js.map +1 -0
  13. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  14. package/dist/collections/buildRegistry.d.ts +1 -1
  15. package/dist/collections/validate-relations.d.ts +1 -1
  16. package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
  17. package/dist/connection-GOKU3Hu5.js.map +1 -0
  18. package/dist/connection.d.ts +16 -0
  19. package/dist/data-transformer.d.ts +1 -1
  20. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-DFpOl8SM.js} +4 -4
  21. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
  22. package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
  23. package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
  24. package/dist/index.d.ts +16 -16
  25. package/dist/index.es.js +19 -7128
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DNzaWd4C.js} +3 -3
  28. package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DNzaWd4C.js.map} +1 -1
  29. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CInuYj1-.js} +14 -4
  30. package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
  31. package/dist/schema/classify-change.d.ts +82 -0
  32. package/dist/schema/collection-index.d.ts +182 -0
  33. package/dist/schema/dynamic-tables.d.ts +1 -1
  34. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  35. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  36. package/dist/schema/generate-schema-commit.d.ts +136 -0
  37. package/dist/schema/generated-schema-staleness.d.ts +19 -0
  38. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  39. package/dist/schema/introspect-db-logic.d.ts +3 -3
  40. package/dist/schema/introspect-db-project.d.ts +1 -1
  41. package/dist/schema/introspect-db-queries.d.ts +1 -1
  42. package/dist/schema/introspect-db-structure.d.ts +2 -2
  43. package/dist/schema/introspect-runtime.d.ts +1 -1
  44. package/dist/schema/vector-index.d.ts +88 -0
  45. package/dist/services/BranchService.d.ts +2 -2
  46. package/dist/services/FetchService.d.ts +4 -4
  47. package/dist/services/PersistService.d.ts +5 -5
  48. package/dist/services/RelationService.d.ts +3 -3
  49. package/dist/services/RelationWriteService.d.ts +3 -3
  50. package/dist/services/cdc/junction-tables.d.ts +1 -1
  51. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  52. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  53. package/dist/services/channel-bus/index.d.ts +2 -2
  54. package/dist/services/collection-helpers.d.ts +1 -1
  55. package/dist/services/dataService.d.ts +10 -10
  56. package/dist/services/index.d.ts +4 -4
  57. package/dist/services/junction-writes.d.ts +2 -2
  58. package/dist/services/nested-path.d.ts +1 -1
  59. package/dist/services/realtimeService.d.ts +3 -3
  60. package/dist/services/row-pipeline.d.ts +1 -1
  61. package/dist/services/write-denial.d.ts +1 -1
  62. package/dist/{src-BBFsDaeA.js → src-DiDgtX8P.js} +97 -1
  63. package/dist/src-DiDgtX8P.js.map +1 -0
  64. package/dist/utils/drizzle-conditions.d.ts +2 -2
  65. package/dist/websocket-CUnpSe8v.js +8188 -0
  66. package/dist/websocket-CUnpSe8v.js.map +1 -0
  67. package/dist/websocket.d.ts +29 -2
  68. package/package.json +7 -7
  69. package/src/PostgresBackendDriver.ts +190 -59
  70. package/src/backup/backup-service.ts +1 -1
  71. package/src/cli-helpers.ts +117 -2
  72. package/src/cli.ts +22 -0
  73. package/src/connection.ts +37 -3
  74. package/src/databasePoolManager.ts +5 -2
  75. package/src/schema/classify-change.ts +436 -0
  76. package/src/schema/collection-index.ts +427 -0
  77. package/src/schema/ensure-collection-tables.test.ts +168 -1
  78. package/src/schema/ensure-collection-tables.ts +365 -14
  79. package/src/schema/generate-drizzle-schema-logic.ts +23 -11
  80. package/src/schema/generate-drizzle-schema.ts +13 -2
  81. package/src/schema/generate-postgres-ddl-logic.ts +33 -6
  82. package/src/schema/generate-postgres-ddl.ts +13 -2
  83. package/src/schema/generate-schema-commit.ts +242 -0
  84. package/src/schema/generated-schema-staleness.ts +114 -1
  85. package/src/schema/vector-index.ts +278 -0
  86. package/src/services/collection-helpers.ts +3 -2
  87. package/src/websocket.ts +47 -3
  88. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  89. package/dist/connection-BuZ97wsr.js.map +0 -1
  90. package/dist/data_driver-ULAyJEi9.js +0 -193
  91. package/dist/data_driver-ULAyJEi9.js.map +0 -1
  92. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  93. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
  94. package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
  95. package/dist/src-BBFsDaeA.js.map +0 -1
  96. package/dist/utils/table-classification.d.ts +0 -8
  97. package/dist/websocket-BVgDVO-V.js +0 -614
  98. package/dist/websocket-BVgDVO-V.js.map +0 -1
  99. package/src/utils/table-classification.ts +0 -16
@@ -2,7 +2,7 @@ import { CollectionRegistry } from "@rebasepro/common";
2
2
  import { type CollectionConfig } from "@rebasepro/types";
3
3
  import { PgEnum, PgTable } from "drizzle-orm/pg-core";
4
4
  import { Relations } from "drizzle-orm";
5
- import { CollectionRegistryInterface } from "../interfaces";
5
+ import { CollectionRegistryInterface } from "../interfaces.js";
6
6
  /**
7
7
  * PostgreSQL-specific collection registry.
8
8
  * Extends the base CollectionRegistry with support for Drizzle ORM tables, enums, and relations.
@@ -1,7 +1,7 @@
1
1
  import { Relations } from "drizzle-orm";
2
2
  import { PgEnum } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig } from "@rebasepro/types";
4
- import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
4
+ import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry.js";
5
5
  /**
6
6
  * Everything a registry is built from: the collections, and the drizzle schema
7
7
  * they are backed by. In BaaS mode all of it is introspected from the live
@@ -1,5 +1,5 @@
1
1
  import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
2
- import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
2
+ import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry.js";
3
3
  /**
4
4
  * Check every relation against the schema it actually runs on, at boot.
5
5
  *
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __createRequire } from "module";
2
- import "process";
2
+ import process from "process";
3
3
  const require = __createRequire(import.meta.url);
4
4
  import { Pool } from "pg";
5
5
  import { drizzle } from "drizzle-orm/node-postgres";
@@ -42,12 +42,39 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
42
42
  //#endregion
43
43
  //#region src/connection.ts
44
44
  var connection_exports = /* @__PURE__ */ __exportAll({
45
+ cappedPoolMax: () => cappedPoolMax,
45
46
  createDirectDatabaseConnection: () => createDirectDatabaseConnection,
46
47
  createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
47
48
  createReadReplicaConnection: () => createReadReplicaConnection,
48
49
  guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease,
49
- pinSearchPath: () => pinSearchPath
50
+ pinSearchPath: () => pinSearchPath,
51
+ poolMaxCeiling: () => poolMaxCeiling
50
52
  });
53
+ /**
54
+ * Ceiling on pool size, from the environment.
55
+ *
56
+ * Exists for the managed development database. PGlite is a single session
57
+ * behind a multiplexing socket server, and two pooled clients holding
58
+ * overlapping transactions deadlock there — which a request-per-transaction
59
+ * server produces the moment two requests overlap. One connection turns that
60
+ * deadlock into ordinary queueing.
61
+ *
62
+ * A ceiling rather than a value, so it constrains every pool in the process
63
+ * without any of them having to know why. Ignored when unset or unparseable,
64
+ * because a malformed limit must not silently serialize a production server.
65
+ */
66
+ function poolMaxCeiling(env = process.env) {
67
+ const raw = env.REBASE_DB_POOL_MAX;
68
+ if (typeof raw !== "string") return null;
69
+ if (!/^[0-9]+$/.test(raw.trim())) return null;
70
+ const parsed = Number(raw.trim());
71
+ return parsed >= 1 ? parsed : null;
72
+ }
73
+ /** Apply {@link poolMaxCeiling} to a requested pool size. */
74
+ function cappedPoolMax(requested, env = process.env) {
75
+ const ceiling = poolMaxCeiling(env);
76
+ return ceiling === null ? requested : Math.min(requested, ceiling);
77
+ }
51
78
  var DEFAULT_POOL = {
52
79
  max: 20,
53
80
  searchPath: "public",
@@ -160,7 +187,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
160
187
  connectionString = pinSearchPath(connectionString, opts.searchPath);
161
188
  const pool = new Pool({
162
189
  connectionString,
163
- max: opts.max,
190
+ max: cappedPoolMax(opts.max),
164
191
  idleTimeoutMillis: opts.idleTimeoutMillis,
165
192
  connectionTimeoutMillis: opts.connectionTimeoutMillis,
166
193
  query_timeout: opts.queryTimeout,
@@ -195,7 +222,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
195
222
  connectionString = pinSearchPath(connectionString, opts.searchPath);
196
223
  const pool = new Pool({
197
224
  connectionString,
198
- max: opts.max,
225
+ max: cappedPoolMax(opts.max),
199
226
  idleTimeoutMillis: opts.idleTimeoutMillis,
200
227
  connectionTimeoutMillis: opts.connectionTimeoutMillis,
201
228
  query_timeout: opts.queryTimeout,
@@ -226,7 +253,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
226
253
  connectionString = pinSearchPath(connectionString, opts.searchPath);
227
254
  const pool = new Pool({
228
255
  connectionString,
229
- max: opts.max,
256
+ max: cappedPoolMax(opts.max),
230
257
  idleTimeoutMillis: opts.idleTimeoutMillis,
231
258
  connectionTimeoutMillis: opts.connectionTimeoutMillis,
232
259
  query_timeout: opts.queryTimeout,
@@ -245,6 +272,6 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
245
272
  };
246
273
  }
247
274
  //#endregion
248
- export { guardPoolAgainstDirtyRelease as a, __exportAll as c, createReadReplicaConnection as i, __require as l, createDirectDatabaseConnection as n, pinSearchPath as o, createPostgresDatabaseConnection as r, __commonJSMin as s, connection_exports as t, __toESM as u };
275
+ export { createReadReplicaConnection as a, poolMaxCeiling as c, __require as d, __toESM as f, createPostgresDatabaseConnection as i, __commonJSMin as l, connection_exports as n, guardPoolAgainstDirtyRelease as o, createDirectDatabaseConnection as r, pinSearchPath as s, cappedPoolMax as t, __exportAll as u };
249
276
 
250
- //# sourceMappingURL=connection-BuZ97wsr.js.map
277
+ //# sourceMappingURL=connection-GOKU3Hu5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection-GOKU3Hu5.js","names":[],"sources":["../src/connection.ts"],"sourcesContent":["import { Pool, PoolConfig } from \"pg\";\nimport { drizzle } from \"drizzle-orm/node-postgres\";\nimport { logger } from \"@rebasepro/server\";\n\n/**\n * Configuration for the Postgres connection pool.\n *\n * Sensible defaults are provided for production Cloud Run / single-instance\n * deployments. Override via environment variables or explicit config.\n */\nexport interface PostgresPoolConfig {\n /** Maximum number of connections in the pool (default: 20) */\n max?: number;\n /** Close idle connections after this many ms (default: 30 000) */\n idleTimeoutMillis?: number;\n /** Abort connection attempts after this many ms (default: 10 000) */\n connectionTimeoutMillis?: number;\n /** Per-query timeout in ms (default: 30 000) */\n queryTimeout?: number;\n /** Per-statement timeout in ms (default: 30 000) */\n statementTimeout?: number;\n /** Enable TCP keep-alive (default: true) */\n keepAlive?: boolean;\n /**\n * `search_path` pinned on every connection (default: `\"public\"`).\n *\n * Pass `false` to send no `search_path` at all and inherit whatever the\n * server/role defaults to. See {@link pinSearchPath} for why the default\n * is not \"inherit\".\n */\n searchPath?: string | false;\n}\n\n/**\n * Ceiling on pool size, from the environment.\n *\n * Exists for the managed development database. PGlite is a single session\n * behind a multiplexing socket server, and two pooled clients holding\n * overlapping transactions deadlock there — which a request-per-transaction\n * server produces the moment two requests overlap. One connection turns that\n * deadlock into ordinary queueing.\n *\n * A ceiling rather than a value, so it constrains every pool in the process\n * without any of them having to know why. Ignored when unset or unparseable,\n * because a malformed limit must not silently serialize a production server.\n */\nexport function poolMaxCeiling(env: NodeJS.ProcessEnv = process.env): number | null {\n const raw = env.REBASE_DB_POOL_MAX;\n if (typeof raw !== \"string\") return null;\n\n // Plain decimal digits only. `Number` would also accept `1e3`, `0x10` and\n // ` 4 ` — none of which anyone writes on purpose in a config value, and all\n // of which would mean this silently did something other than it appears to.\n if (!/^[0-9]+$/.test(raw.trim())) return null;\n\n const parsed = Number(raw.trim());\n\n return parsed >= 1 ? parsed : null;\n}\n\n/** Apply {@link poolMaxCeiling} to a requested pool size. */\nexport function cappedPoolMax(requested: number, env: NodeJS.ProcessEnv = process.env): number {\n const ceiling = poolMaxCeiling(env);\n\n return ceiling === null ? requested : Math.min(requested, ceiling);\n}\n\nconst DEFAULT_POOL: Required<PostgresPoolConfig> = {\n max: 20,\n searchPath: \"public\",\n idleTimeoutMillis: 30_000,\n connectionTimeoutMillis: 10_000,\n // The client-side read timeout MUST be comfortably above the server-side\n // statement_timeout. When the client timer fires first, node-postgres\n // abandons the in-flight statement but keeps the connection — inside a\n // transaction that leaves the tx open (and any pending ROLLBACK is\n // spliced out of the client queue before it ever reaches the wire), so\n // the pooled connection is returned still in-transaction with its RLS\n // GUCs set. The server abort (SQLSTATE 57014) is the clean path; the\n // client timeout is only a backstop for a dead network.\n queryTimeout: 60_000,\n statementTimeout: 30_000,\n keepAlive: true\n};\n\n/** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */\nconst TX_IDLE = \"I\";\n\n/**\n * Pin `search_path` into a connection string, so unqualified SQL resolves to a\n * schema this framework chose rather than to one Postgres inferred.\n *\n * Postgres defaults `search_path` to `\"$user\", public`: the *first* candidate\n * is a schema named after the connecting role. Rebase creates a schema called\n * `rebase` (auth, history, api keys), and every template, compose file and\n * deployment doc names the database role `rebase` too — so `$user` resolves to\n * a schema that exists, and every unqualified statement lands there instead of\n * in `public`. The generated Drizzle schema emits bare `pgTable(\"posts\", …)`\n * for any collection without an explicit `schema`, which makes the *runtime's*\n * own reads and writes unqualified; a developer's raw `rebase.sql(...)`, the\n * Studio SQL editor and any hand-written migration are unqualified too. The\n * result is collection tables created in, and served from, `rebase`.\n *\n * Drizzle cannot express the fix on its side: `pgSchema(\"public\")` throws by\n * design (\"just use pgTable() instead\"), so there is no way to emit a\n * public-qualified table from the generator. The pin has to live on the\n * connection.\n *\n * Precedence is deliberate and verified against node-postgres: `options` in\n * the connection string wins over the `options` field passed to `Pool`, so\n * rewriting the URL — rather than setting the field — is what makes this\n * authoritative. Two escape hatches survive it:\n *\n * - an `options` that already mentions `search_path` is left untouched, so a\n * deployment that deliberately pins something else keeps it;\n * - `searchPath: false` (or an unparseable, non-URL connection string) sends\n * nothing and inherits the server default.\n *\n * Anything else in `options` (a `statement_timeout`, say) is preserved and the\n * `search_path` flag is appended to it.\n */\nexport function pinSearchPath(connectionString: string, searchPath: string | false = \"public\"): string {\n if (searchPath === false) return connectionString;\n\n let url: URL;\n try {\n url = new URL(connectionString);\n } catch {\n // Key/value DSNs and anything else we cannot parse are returned as\n // given: a connection that works unpinned beats one we corrupted.\n return connectionString;\n }\n if (url.protocol !== \"postgres:\" && url.protocol !== \"postgresql:\") return connectionString;\n\n const existing = url.searchParams.get(\"options\");\n if (existing && /(^|\\s)-c\\s*search_path\\s*=/.test(existing)) return connectionString;\n\n const flag = `-c search_path=${searchPath}`;\n url.searchParams.set(\"options\", existing ? `${existing} ${flag}` : flag);\n // Re-serialize by hand. `URLSearchParams` writes a space as `+`, which\n // node-postgres happens to decode but libpq does not — and this same string\n // is handed to `pg_dump`/`psql` for backups. Percent-encoding is the form\n // both agree on, and is what the scaffolded `.env` already ships.\n url.search = Array.from(url.searchParams.entries())\n .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)\n .join(\"&\");\n return url.toString();\n}\n\n/**\n * Destroy pool clients that are released while still inside a transaction.\n *\n * pg-pool returns a client to the idle list whenever `release()` is called\n * without an error — even if the connection is still mid-transaction (status\n * `T`/`E`). That happens in practice: drizzle's pool transaction releases in\n * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails\n * (e.g. it was queued behind a statement that hit the client-side\n * query_timeout), the client goes back dirty. The next checkout then runs\n * its statements inside the zombie transaction — with the previous request's\n * `app.*` RLS GUCs still applied, which turns unrelated queries into\n * RLS-scoped ones (observed in production as registration failing with\n * SQLSTATE 42501 under a leaked anonymous context).\n *\n * pg-pool emits `release` before it consults its private `_expired` set, so\n * marking the client expired here makes `_release()` destroy it instead of\n * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are\n * private APIs — feature-detect and fall back to loud logging so an upstream\n * change degrades to observability, never to silent corruption.\n */\nexport function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void {\n pool.on(\"release\", (err: Error | undefined, client: unknown) => {\n if (err) return; // errored clients are already destroyed by pg-pool\n const txStatus = (client as { _txStatus?: string | null })?._txStatus;\n if (typeof txStatus !== \"string\" || txStatus === TX_IDLE) return;\n\n // pg-pool keeps expired clients in a WeakSet (a plain object works too\n // if upstream ever changes it — duck-type on add/has).\n const expired = (pool as unknown as { _expired?: { add(c: object): unknown; has(c: object): boolean } })._expired;\n if (expired && typeof expired.add === \"function\" && typeof expired.has === \"function\" && client && typeof client === \"object\") {\n expired.add(client);\n logger.error(\n `[${label}] Client released back to the pool while still in a transaction ` +\n `(status '${txStatus}') — destroying it so the open transaction and its ` +\n `session state (RLS GUCs) cannot leak into the next request.`\n );\n } else {\n logger.error(\n `[${label}] Client released mid-transaction (status '${txStatus}') but the ` +\n `pool's internal expiry set is unavailable (pg-pool internals changed?). ` +\n `The connection may leak its open transaction into subsequent requests.`\n );\n }\n });\n}\n\n/**\n * Create a Drizzle-backed Postgres connection with a production-grade\n * connection pool.\n *\n * @param connectionString Postgres connection URL\n * @param schema Optional Drizzle schema for the relational API\n * @param poolConfig Optional pool tuning (merged over defaults)\n *\n * @returns `{ db, pool, connectionString }` — the `pool` is exposed so\n * callers can register shutdown hooks (`pool.end()`) or monitor\n * pool metrics.\n */\nexport function createPostgresDatabaseConnection(\n connectionString: string,\n schema?: Record<string, unknown>,\n poolConfig?: PostgresPoolConfig\n) {\n const opts = { ...DEFAULT_POOL,\n...poolConfig };\n connectionString = pinSearchPath(connectionString, opts.searchPath);\n\n const pgPoolConfig: PoolConfig = {\n connectionString,\n max: cappedPoolMax(opts.max),\n idleTimeoutMillis: opts.idleTimeoutMillis,\n connectionTimeoutMillis: opts.connectionTimeoutMillis,\n query_timeout: opts.queryTimeout,\n statement_timeout: opts.statementTimeout,\n keepAlive: opts.keepAlive,\n keepAliveInitialDelayMillis: 0\n };\n\n const pool = new Pool(pgPoolConfig);\n\n // ── Pool event logging ────────────────────────────────────────────────\n // Uses console.* because the structured logger lives in server\n // (a separate package). The caller can replace these with the structured\n // logger if desired via pool.on() after creation.\n pool.on(\"error\", (err) => {\n logger.error(\"[pg-pool] Unexpected pool error\", { detail: err.message });\n if (err.message.includes(\"ETIMEDOUT\")) {\n logger.warn(\"[pg-pool] Connection timeout detected — pool will auto-retry\");\n }\n });\n guardPoolAgainstDirtyRelease(pool, \"pg-pool\");\n\n // Create drizzle instance — pass schema when available to enable db.query relational API\n const db = schema ? drizzle(pool, { schema }) : drizzle(pool);\n\n return { db,\npool,\nconnectionString };\n}\n\n/**\n * Create a direct (non-pooled) connection for operations that require\n * session-level features incompatible with PgBouncer transaction mode,\n * such as LISTEN/NOTIFY, prepared statements, or advisory locks.\n *\n * Uses a smaller pool since this is only for specific use cases.\n */\nexport function createDirectDatabaseConnection(\n connectionString: string,\n schema?: Record<string, unknown>,\n poolConfig?: PostgresPoolConfig\n) {\n const opts = {\n ...DEFAULT_POOL,\n max: 5,\n ...poolConfig\n };\n connectionString = pinSearchPath(connectionString, opts.searchPath);\n\n const pgPoolConfig: PoolConfig = {\n connectionString,\n max: cappedPoolMax(opts.max),\n idleTimeoutMillis: opts.idleTimeoutMillis,\n connectionTimeoutMillis: opts.connectionTimeoutMillis,\n query_timeout: opts.queryTimeout,\n statement_timeout: opts.statementTimeout,\n keepAlive: opts.keepAlive,\n keepAliveInitialDelayMillis: 0\n };\n\n const pool = new Pool(pgPoolConfig);\n\n pool.on(\"error\", (err) => {\n logger.error(\"[pg-direct-pool] Unexpected pool error\", { detail: err.message });\n });\n guardPoolAgainstDirtyRelease(pool, \"pg-direct-pool\");\n\n const db = schema ? drizzle(pool, { schema }) : drizzle(pool);\n\n return { db,\npool,\nconnectionString };\n}\n\n/**\n * Create a read-only connection for routing read queries to replicas.\n * Uses a moderate pool size since reads are distributed across replicas.\n */\nexport function createReadReplicaConnection(\n connectionString: string,\n schema?: Record<string, unknown>,\n poolConfig?: PostgresPoolConfig\n) {\n const opts = {\n ...DEFAULT_POOL,\n max: 10,\n ...poolConfig\n };\n connectionString = pinSearchPath(connectionString, opts.searchPath);\n\n const pgPoolConfig: PoolConfig = {\n connectionString,\n max: cappedPoolMax(opts.max),\n idleTimeoutMillis: opts.idleTimeoutMillis,\n connectionTimeoutMillis: opts.connectionTimeoutMillis,\n query_timeout: opts.queryTimeout,\n statement_timeout: opts.statementTimeout,\n keepAlive: opts.keepAlive,\n keepAliveInitialDelayMillis: 0\n };\n\n const pool = new Pool(pgPoolConfig);\n\n pool.on(\"error\", (err) => {\n logger.error(\"[pg-replica-pool] Unexpected pool error\", { detail: err.message });\n });\n guardPoolAgainstDirtyRelease(pool, \"pg-replica-pool\");\n\n const db = schema ? drizzle(pool, { schema }) : drizzle(pool);\n\n return { db,\npool,\nconnectionString };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8CA,SAAgB,eAAe,MAAyB,QAAQ,KAAoB;CAChF,MAAM,MAAM,IAAI;CAChB,IAAI,OAAO,QAAQ,UAAU,OAAO;CAKpC,IAAI,CAAC,WAAW,KAAK,IAAI,KAAK,CAAC,GAAG,OAAO;CAEzC,MAAM,SAAS,OAAO,IAAI,KAAK,CAAC;CAEhC,OAAO,UAAU,IAAI,SAAS;AAClC;;AAGA,SAAgB,cAAc,WAAmB,MAAyB,QAAQ,KAAa;CAC3F,MAAM,UAAU,eAAe,GAAG;CAElC,OAAO,YAAY,OAAO,YAAY,KAAK,IAAI,WAAW,OAAO;AACrE;AAEA,IAAM,eAA6C;CAC/C,KAAK;CACL,YAAY;CACZ,mBAAmB;CACnB,yBAAyB;CASzB,cAAc;CACd,kBAAkB;CAClB,WAAW;AACf;;AAGA,IAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmChB,SAAgB,cAAc,kBAA0B,aAA6B,UAAkB;CACnG,IAAI,eAAe,OAAO,OAAO;CAEjC,IAAI;CACJ,IAAI;EACA,MAAM,IAAI,IAAI,gBAAgB;CAClC,QAAQ;EAGJ,OAAO;CACX;CACA,IAAI,IAAI,aAAa,eAAe,IAAI,aAAa,eAAe,OAAO;CAE3E,MAAM,WAAW,IAAI,aAAa,IAAI,SAAS;CAC/C,IAAI,YAAY,6BAA6B,KAAK,QAAQ,GAAG,OAAO;CAEpE,MAAM,OAAO,kBAAkB;CAC/B,IAAI,aAAa,IAAI,WAAW,WAAW,GAAG,SAAS,GAAG,SAAS,IAAI;CAKvE,IAAI,SAAS,MAAM,KAAK,IAAI,aAAa,QAAQ,CAAC,CAAC,CAC9C,KAAK,CAAC,KAAK,WAAW,GAAG,mBAAmB,GAAG,EAAE,GAAG,mBAAmB,KAAK,GAAG,CAAC,CAChF,KAAK,GAAG;CACb,OAAO,IAAI,SAAS;AACxB;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,6BAA6B,MAAY,OAAqB;CAC1E,KAAK,GAAG,YAAY,KAAwB,WAAoB;EAC5D,IAAI,KAAK;EACT,MAAM,WAAY,QAA0C;EAC5D,IAAI,OAAO,aAAa,YAAY,aAAa,SAAS;EAI1D,MAAM,UAAW,KAAwF;EACzG,IAAI,WAAW,OAAO,QAAQ,QAAQ,cAAc,OAAO,QAAQ,QAAQ,cAAc,UAAU,OAAO,WAAW,UAAU;GAC3H,QAAQ,IAAI,MAAM;GAClB,OAAO,MACH,IAAI,MAAM,2EACE,SAAS,+GAEzB;EACJ,OACI,OAAO,MACH,IAAI,MAAM,6CAA6C,SAAS,0JAGpE;CAER,CAAC;AACL;;;;;;;;;;;;;AAcA,SAAgB,iCACZ,kBACA,QACA,YACF;CACE,MAAM,OAAO;EAAE,GAAG;EACtB,GAAG;CAAW;CACV,mBAAmB,cAAc,kBAAkB,KAAK,UAAU;CAalE,MAAM,OAAO,IAAI,KAAK;EAVlB;EACA,KAAK,cAAc,KAAK,GAAG;EAC3B,mBAAmB,KAAK;EACxB,yBAAyB,KAAK;EAC9B,eAAe,KAAK;EACpB,mBAAmB,KAAK;EACxB,WAAW,KAAK;EAChB,6BAA6B;CAGX,CAAY;CAMlC,KAAK,GAAG,UAAU,QAAQ;EACtB,OAAO,MAAM,mCAAmC,EAAE,QAAQ,IAAI,QAAQ,CAAC;EACvE,IAAI,IAAI,QAAQ,SAAS,WAAW,GAChC,OAAO,KAAK,8DAA8D;CAElF,CAAC;CACD,6BAA6B,MAAM,SAAS;CAK5C,OAAO;EAAE,IAFE,SAAS,QAAQ,MAAM,EAAE,OAAO,CAAC,IAAI,QAAQ,IAAI;EAGhE;EACA;CAAiB;AACjB;;;;;;;;AASA,SAAgB,+BACZ,kBACA,QACA,YACF;CACE,MAAM,OAAO;EACT,GAAG;EACH,KAAK;EACL,GAAG;CACP;CACA,mBAAmB,cAAc,kBAAkB,KAAK,UAAU;CAalE,MAAM,OAAO,IAAI,KAAK;EAVlB;EACA,KAAK,cAAc,KAAK,GAAG;EAC3B,mBAAmB,KAAK;EACxB,yBAAyB,KAAK;EAC9B,eAAe,KAAK;EACpB,mBAAmB,KAAK;EACxB,WAAW,KAAK;EAChB,6BAA6B;CAGX,CAAY;CAElC,KAAK,GAAG,UAAU,QAAQ;EACtB,OAAO,MAAM,0CAA0C,EAAE,QAAQ,IAAI,QAAQ,CAAC;CAClF,CAAC;CACD,6BAA6B,MAAM,gBAAgB;CAInD,OAAO;EAAE,IAFE,SAAS,QAAQ,MAAM,EAAE,OAAO,CAAC,IAAI,QAAQ,IAAI;EAGhE;EACA;CAAiB;AACjB;;;;;AAMA,SAAgB,4BACZ,kBACA,QACA,YACF;CACE,MAAM,OAAO;EACT,GAAG;EACH,KAAK;EACL,GAAG;CACP;CACA,mBAAmB,cAAc,kBAAkB,KAAK,UAAU;CAalE,MAAM,OAAO,IAAI,KAAK;EAVlB;EACA,KAAK,cAAc,KAAK,GAAG;EAC3B,mBAAmB,KAAK;EACxB,yBAAyB,KAAK;EAC9B,eAAe,KAAK;EACpB,mBAAmB,KAAK;EACxB,WAAW,KAAK;EAChB,6BAA6B;CAGX,CAAY;CAElC,KAAK,GAAG,UAAU,QAAQ;EACtB,OAAO,MAAM,2CAA2C,EAAE,QAAQ,IAAI,QAAQ,CAAC;CACnF,CAAC;CACD,6BAA6B,MAAM,iBAAiB;CAIpD,OAAO;EAAE,IAFE,SAAS,QAAQ,MAAM,EAAE,OAAO,CAAC,IAAI,QAAQ,IAAI;EAGhE;EACA;CAAiB;AACjB"}
@@ -27,6 +27,22 @@ export interface PostgresPoolConfig {
27
27
  */
28
28
  searchPath?: string | false;
29
29
  }
30
+ /**
31
+ * Ceiling on pool size, from the environment.
32
+ *
33
+ * Exists for the managed development database. PGlite is a single session
34
+ * behind a multiplexing socket server, and two pooled clients holding
35
+ * overlapping transactions deadlock there — which a request-per-transaction
36
+ * server produces the moment two requests overlap. One connection turns that
37
+ * deadlock into ordinary queueing.
38
+ *
39
+ * A ceiling rather than a value, so it constrains every pool in the process
40
+ * without any of them having to know why. Ignored when unset or unparseable,
41
+ * because a malformed limit must not silently serialize a production server.
42
+ */
43
+ export declare function poolMaxCeiling(env?: NodeJS.ProcessEnv): number | null;
44
+ /** Apply {@link poolMaxCeiling} to a requested pool size. */
45
+ export declare function cappedPoolMax(requested: number, env?: NodeJS.ProcessEnv): number;
30
46
  /**
31
47
  * Pin `search_path` into a connection string, so unqualified SQL resolves to a
32
48
  * schema this framework chose rather than to one Postgres inferred.
@@ -1,6 +1,6 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
2
  import { CollectionConfig, Properties, Property, ResolvedRelation, type ResolvedVia } from "@rebasepro/types";
3
- import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
3
+ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry.js";
4
4
  /**
5
5
  * Data transformation utilities for converting between frontend and database formats.
6
6
  */
@@ -1,9 +1,9 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import "process";
3
3
  __createRequire(import.meta.url);
4
- import "./src-BBFsDaeA.js";
5
- import { c as REBASE_USER_ROLE } from "./rls-enforcement-Ch0T6OwW.js";
6
- import { readExistingSchema, t as planCollectionPolicies } from "./ensure-collection-tables-BY1pHRD_.js";
4
+ import "./src-DiDgtX8P.js";
5
+ import { c as planCollectionPolicies, r as readExistingSchema } from "./ensure-collection-tables-DMjOkeRy.js";
6
+ import { c as REBASE_USER_ROLE } from "./rls-enforcement-CInuYj1-.js";
7
7
  //#region src/security/policy-drift.ts
8
8
  /**
9
9
  * Does this name look like one the generator produced for this table?
@@ -122,4 +122,4 @@ async function ensureCollectionPolicies(client, collections, log) {
122
122
  //#endregion
123
123
  export { ensureCollectionPolicies };
124
124
 
125
- //# sourceMappingURL=ensure-collection-policies-BVFb2olB.js.map
125
+ //# sourceMappingURL=ensure-collection-policies-DFpOl8SM.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ensure-collection-policies-BVFb2olB.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
1
+ {"version":3,"file":"ensure-collection-policies-DFpOl8SM.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}