@rebasepro/server-postgres 0.24.0 → 0.24.1-canary.gcb918fc

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 (172) hide show
  1. package/README.md +16 -0
  2. package/dist/{BranchService-BRt78gfa.js → BranchService-Bsn2wfaj.js} +3 -2
  3. package/dist/{BranchService-BRt78gfa.js.map → BranchService-Bsn2wfaj.js.map} +1 -1
  4. package/dist/PostgresBackendDriver.d.ts +91 -2
  5. package/dist/PostgresBootstrapper.d.ts +101 -2
  6. package/dist/auth/ensure-tables.d.ts +12 -0
  7. package/dist/auth/services.d.ts +6 -0
  8. package/dist/backup/backup-cli.d.ts +14 -0
  9. package/dist/backup/backup-service.d.ts +2 -2
  10. package/dist/backup/pg-binary.d.ts +5 -0
  11. package/dist/{backup-cli-DW5p9_zv.js → backup-cli-DnpJi6I7.js} +37 -29
  12. package/dist/backup-cli-DnpJi6I7.js.map +1 -0
  13. package/dist/{backup-service-EwcDVG-8.js → backup-service-BTBB7RXn.js} +55 -21
  14. package/dist/backup-service-BTBB7RXn.js.map +1 -0
  15. package/dist/boot-ddl-DVpsFN6r.js +105 -0
  16. package/dist/boot-ddl-DVpsFN6r.js.map +1 -0
  17. package/dist/cli-console-Ca6ZlAVU.js +154 -0
  18. package/dist/cli-console-Ca6ZlAVU.js.map +1 -0
  19. package/dist/cli-console.d.ts +39 -0
  20. package/dist/cli-errors-CaI4ntyD.js +1919 -0
  21. package/dist/cli-errors-CaI4ntyD.js.map +1 -0
  22. package/dist/cli-errors.d.ts +51 -5
  23. package/dist/{cli-flags-BglvpjHv.js → cli-flags-JNI-pwSf.js} +6 -3
  24. package/dist/cli-flags-JNI-pwSf.js.map +1 -0
  25. package/dist/cli-helpers.d.ts +40 -0
  26. package/dist/cli-output.d.ts +16 -28
  27. package/dist/cli-push.d.ts +87 -0
  28. package/dist/cli.js +566 -182
  29. package/dist/cli.js.map +1 -1
  30. package/dist/{column-plan-helpers-1-LQD0yI.js → column-plan-helpers-drZIsSNf.js} +10 -4
  31. package/dist/{column-plan-helpers-1-LQD0yI.js.map → column-plan-helpers-drZIsSNf.js.map} +1 -1
  32. package/dist/{databasePoolManager-Bj5FbeAs.js → connection-C9mT8bgf.js} +24 -112
  33. package/dist/connection-C9mT8bgf.js.map +1 -0
  34. package/dist/connection.d.ts +13 -2
  35. package/dist/databasePoolManager-BIuewidb.js +119 -0
  36. package/dist/databasePoolManager-BIuewidb.js.map +1 -0
  37. package/dist/{ensure-collection-tables-kkkHk8oo.js → deferred-index-build-DP1_SNFo.js} +484 -346
  38. package/dist/deferred-index-build-DP1_SNFo.js.map +1 -0
  39. package/dist/{doctor-C-sYWbmt.js → doctor-C4WiftHm.js} +63 -52
  40. package/dist/doctor-C4WiftHm.js.map +1 -0
  41. package/dist/ensure-collection-policies-CXpbeNag.js +541 -0
  42. package/dist/ensure-collection-policies-CXpbeNag.js.map +1 -0
  43. package/dist/ensure-collection-tables-BhsU6qUE.js +532 -0
  44. package/dist/ensure-collection-tables-BhsU6qUE.js.map +1 -0
  45. package/dist/{ensure-tables-BmI_tRxc.js → ensure-tables-CVm_yvzb.js} +173 -77
  46. package/dist/ensure-tables-CVm_yvzb.js.map +1 -0
  47. package/dist/enum-labels-kept-D8fI1MoX.js +56 -0
  48. package/dist/enum-labels-kept-D8fI1MoX.js.map +1 -0
  49. package/dist/generate-drizzle-schema-Bz52jH2h.js +57 -0
  50. package/dist/generate-drizzle-schema-Bz52jH2h.js.map +1 -0
  51. package/dist/{generate-drizzle-schema-logic-sSFDp6LR.js → generate-drizzle-schema-logic-B74WN37S.js} +5 -5
  52. package/dist/generate-drizzle-schema-logic-B74WN37S.js.map +1 -0
  53. package/dist/generate-files-CdEckX6c.js +199 -0
  54. package/dist/generate-files-CdEckX6c.js.map +1 -0
  55. package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js → generate-postgres-ddl-logic-B64O2oZF.js} +3 -3
  56. package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js.map → generate-postgres-ddl-logic-B64O2oZF.js.map} +1 -1
  57. package/dist/generated-sql.d.ts +8 -2
  58. package/dist/history/HistoryService.d.ts +64 -5
  59. package/dist/history/ensure-history-table.d.ts +63 -4
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.es.js +139 -19236
  62. package/dist/index.es.js.map +1 -1
  63. package/dist/{introspect-db-logic-kCETE8TY.js → introspect-db-logic-C-Di8dA9.js} +44 -6
  64. package/dist/introspect-db-logic-C-Di8dA9.js.map +1 -0
  65. package/dist/{introspect-db-queries-C_Q5VgQw.js → introspect-db-queries-D6kAqReI.js} +23 -1
  66. package/dist/introspect-db-queries-D6kAqReI.js.map +1 -0
  67. package/dist/open-rebase-CbOur0TU.js +20858 -0
  68. package/dist/open-rebase-CbOur0TU.js.map +1 -0
  69. package/dist/open-rebase.d.ts +86 -0
  70. package/dist/pg-error-utils-vJezlXdI.js +388 -0
  71. package/dist/pg-error-utils-vJezlXdI.js.map +1 -0
  72. package/dist/{plan-schema-DU9exq6C.js → plan-schema-B1YF_tYq.js} +151 -41
  73. package/dist/plan-schema-B1YF_tYq.js.map +1 -0
  74. package/dist/{policy-drift-B-J2hhm0.js → policy-drift-Dm8i-s1J.js} +23 -6
  75. package/dist/policy-drift-Dm8i-s1J.js.map +1 -0
  76. package/dist/push-index-prebuild-Db4AQF4i.js +45 -0
  77. package/dist/push-index-prebuild-Db4AQF4i.js.map +1 -0
  78. package/dist/push-legacy-renames-BK6ilQq2.js +34 -0
  79. package/dist/push-legacy-renames-BK6ilQq2.js.map +1 -0
  80. package/dist/{render-ddl-Ds2t_d9V.js → render-ddl-CtC3y9Ol.js} +8 -4
  81. package/dist/render-ddl-CtC3y9Ol.js.map +1 -0
  82. package/dist/{rls-bootstrap-sql-_KNnjanK.js → rls-bootstrap-sql-RQUljnJ1.js} +125 -1270
  83. package/dist/rls-bootstrap-sql-RQUljnJ1.js.map +1 -0
  84. package/dist/{rls-enforcement-CfXOJJaW.js → rls-enforcement-DvzqlaL1.js} +129 -17
  85. package/dist/rls-enforcement-DvzqlaL1.js.map +1 -0
  86. package/dist/schema/auth-schema.d.ts +2 -2
  87. package/dist/schema/boot-ddl.d.ts +47 -0
  88. package/dist/schema/collection-index.d.ts +45 -0
  89. package/dist/schema/column-plan-helpers.d.ts +5 -0
  90. package/dist/schema/deferred-index-build.d.ts +131 -0
  91. package/dist/schema/destructive-sql.d.ts +7 -0
  92. package/dist/schema/doctor-cli.js +110 -6
  93. package/dist/schema/doctor-cli.js.map +1 -1
  94. package/dist/schema/doctor-search-scale.d.ts +45 -0
  95. package/dist/schema/doctor.d.ts +20 -1
  96. package/dist/schema/ensure-collection-policies.d.ts +73 -35
  97. package/dist/schema/ensure-collection-tables.d.ts +56 -3
  98. package/dist/schema/enum-labels-kept.d.ts +26 -0
  99. package/dist/schema/generate-drizzle-schema.js +1 -1
  100. package/dist/schema/generate-files.d.ts +44 -0
  101. package/dist/schema/generate-next-step.d.ts +0 -13
  102. package/dist/schema/generate-postgres-ddl.js +10 -81
  103. package/dist/schema/generate-postgres-ddl.js.map +1 -1
  104. package/dist/schema/introspect-db-logic.d.ts +12 -0
  105. package/dist/schema/introspect-db-queries.d.ts +11 -1
  106. package/dist/schema/introspect-db-structure.d.ts +10 -0
  107. package/dist/schema/introspect-db.js +4 -5
  108. package/dist/schema/introspect-db.js.map +1 -1
  109. package/dist/schema/migration-post-process.d.ts +34 -0
  110. package/dist/schema/plan/diff-plan.d.ts +126 -0
  111. package/dist/schema/plan/types.d.ts +8 -0
  112. package/dist/schema/push-index-prebuild.d.ts +25 -0
  113. package/dist/schema/push-legacy-renames.d.ts +20 -0
  114. package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
  115. package/dist/security/rls-enforcement.d.ts +32 -4
  116. package/dist/services/FetchService.d.ts +58 -8
  117. package/dist/services/PersistService.d.ts +15 -0
  118. package/dist/services/RelationService.d.ts +88 -3
  119. package/dist/services/cdc/CdcListener.d.ts +17 -6
  120. package/dist/services/cdc/trigger-cdc.d.ts +151 -27
  121. package/dist/services/channel-bus/index.d.ts +1 -1
  122. package/dist/services/channel-history.d.ts +15 -4
  123. package/dist/services/collection-helpers.d.ts +15 -0
  124. package/dist/services/field-viewer.d.ts +15 -1
  125. package/dist/services/pg-notify-listener.d.ts +7 -0
  126. package/dist/services/realtimeService.d.ts +314 -14
  127. package/dist/services/row-pipeline.d.ts +16 -9
  128. package/dist/services/socket-liveness.d.ts +42 -2
  129. package/dist/services/sql-script.d.ts +207 -3
  130. package/dist/services/vector-iterative-scan.d.ts +40 -0
  131. package/dist/services/write-depth.d.ts +6 -0
  132. package/dist/services/write-transaction-scope.d.ts +9 -0
  133. package/dist/socket-opt-ins.d.ts +64 -0
  134. package/dist/source-DBDbBTL4.js +811 -0
  135. package/dist/source-DBDbBTL4.js.map +1 -0
  136. package/dist/tsx-loader.d.ts +14 -0
  137. package/dist/utils/drizzle-conditions.d.ts +21 -1
  138. package/dist/utils/error-cause.d.ts +7 -0
  139. package/dist/utils/pg-error-utils.d.ts +2 -4
  140. package/dist/utils/result-rows.d.ts +10 -0
  141. package/dist/websocket-BLGc5yhs.js +1604 -0
  142. package/dist/websocket-BLGc5yhs.js.map +1 -0
  143. package/dist/websocket.d.ts +13 -0
  144. package/package.json +7 -7
  145. package/dist/backup-cli-DW5p9_zv.js.map +0 -1
  146. package/dist/backup-service-EwcDVG-8.js.map +0 -1
  147. package/dist/cli-errors-DsA-K9uP.js +0 -893
  148. package/dist/cli-errors-DsA-K9uP.js.map +0 -1
  149. package/dist/cli-flags-BglvpjHv.js.map +0 -1
  150. package/dist/cli-output-CNdMql-L.js +0 -47
  151. package/dist/cli-output-CNdMql-L.js.map +0 -1
  152. package/dist/collection-index-DdnsxiJ_.js +0 -286
  153. package/dist/collection-index-DdnsxiJ_.js.map +0 -1
  154. package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
  155. package/dist/doctor-C-sYWbmt.js.map +0 -1
  156. package/dist/ensure-collection-policies-B1ureSIV.js +0 -160
  157. package/dist/ensure-collection-policies-B1ureSIV.js.map +0 -1
  158. package/dist/ensure-collection-tables-kkkHk8oo.js.map +0 -1
  159. package/dist/ensure-tables-BmI_tRxc.js.map +0 -1
  160. package/dist/generate-drizzle-schema-93M0lUxK.js +0 -123
  161. package/dist/generate-drizzle-schema-93M0lUxK.js.map +0 -1
  162. package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +0 -1
  163. package/dist/introspect-db-logic-kCETE8TY.js.map +0 -1
  164. package/dist/introspect-db-queries-C_Q5VgQw.js.map +0 -1
  165. package/dist/plan-schema-DU9exq6C.js.map +0 -1
  166. package/dist/policy-drift-B-J2hhm0.js.map +0 -1
  167. package/dist/render-ddl-Ds2t_d9V.js.map +0 -1
  168. package/dist/rls-bootstrap-sql-_KNnjanK.js.map +0 -1
  169. package/dist/rls-enforcement-CfXOJJaW.js.map +0 -1
  170. package/dist/source-Br7L7GOI.js +0 -410
  171. package/dist/source-Br7L7GOI.js.map +0 -1
  172. package/dist/utils/sql-redaction.d.ts +0 -21
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensure-collection-tables-BhsU6qUE.js","names":[],"sources":["../src/schema/ensure-collection-tables.ts"],"sourcesContent":["/**\n * Applying a schema plan to a database that is already running.\n *\n * ## What this module is\n *\n * A managed runtime boots someone else's compiled project against a database it\n * has never seen. Auth tables are ensured at boot already, but collection tables\n * were not created by anything: the platform ran the app and every `/api/data/*`\n * request answered 500 on a missing relation. `rebase db push` cannot help — it\n * is an Atlas-driven CLI command, and the runtime image ships no CLI.\n *\n * Two halves, and only one of them is here. *What the collections ask for* is\n * `schema/plan/plan-schema.ts`, shared with `db push` and the generated Drizzle\n * file; *what the database already has, and which of those statements are safe\n * to run against it* is `schema/plan/diff-plan.ts`. This module reads the\n * catalogue, runs the statements, and reports what happened.\n *\n * ## Why additive-only, forever\n *\n * This runs unattended, against a database with customers' data in it, with no\n * human reading a diff. So it may only ever do things that cannot lose data.\n * See `diff-plan.ts` for the whole of that argument.\n */\nimport {\n declaredDatabaseExtensions,\n REBASE_SCHEMA,\n type CollectionConfig\n} from \"@rebasepro/types\";\nimport { getTableName, relationalCollections } from \"@rebasepro/common\";\nimport { logger, isConcurrentDdlRace, isDuplicateObjectRace } from \"@rebasepro/server\";\nimport { SEARCH_STAMP_PREFIX } from \"./search-column\";\nimport { vectorExtensionHint } from \"./vector-index\";\nimport { planJunctionTables, quoteSqlLiteral } from \"./generate-postgres-ddl-logic\";\nimport { quoteRelation } from \"./introspect-db-queries\";\nimport { planSchema } from \"./plan/plan-schema\";\nimport type { SchemaPlan } from \"./plan/types\";\nimport {\n assertSafeIdentifier,\n describeHelperFunction,\n diffPlanAgainstCatalogue,\n helperFunctionKey,\n HELPER_FUNCTIONS,\n type ConstraintPolicy,\n type DeferredIndexBuild,\n type DiffOptions,\n type EnsureAction,\n type EnsurePlan,\n type ExistingSchema,\n type LegacyForeignKey,\n type OrphanedRequiredColumn,\n type SearchColumnDrift,\n type WithheldConstraint\n} from \"./plan/diff-plan\";\nimport { extractCauseMessage, extractPgError } from \"../utils/pg-error-utils\";\nimport { atomicBatch, isLockTimeout, isMultiStatementRefusal, joinStatements, sleep } from \"./boot-ddl\";\nimport {\n buildDeferredIndexes,\n TABLE_BUSY_REASON,\n type DeferredIndexOutcome,\n type OpenIndexBuildSession\n} from \"./deferred-index-build\";\nimport { columnTypeDriftMessage, type ColumnTypeDrift } from \"./column-type-drift\";\n\nexport type {\n ConstraintPolicy,\n DeferredIndexBuild,\n EnsureAction,\n EnsurePlan,\n ExistingSchema,\n LegacyForeignKey,\n OrphanedRequiredColumn,\n SearchColumnDrift,\n WithheldConstraint\n};\n\n/**\n * The subset of a database handle this needs: run a statement, get rows back.\n *\n * Deliberately parameterless. Everything here is DDL or catalogue reads keyed by\n * schema name, and schema names are identifiers — they cannot be bound as\n * parameters anyway. They are validated by `assertSafeIdentifier` before they\n * reach a statement, so a config that somehow carried a quote is refused rather\n * than concatenated.\n */\nexport interface Queryable {\n query<T = unknown>(sql: string): Promise<{ rows: T[] }>;\n}\n\nexport interface EnsureOptions extends DiffOptions {\n /**\n * Server extensions the project's databases gave Rebase leave to install —\n * `declaredDatabaseExtensions()`. Absent means none, which is a refusal and\n * is the right default for a planner given no configuration at all.\n *\n * Explicit rather than read from the resource registry in here, because the\n * planner is pure and several callers plan against fixtures: reaching for a\n * process-wide registry would make the plan depend on whatever some other\n * module happened to import. `ensureCollectionTables` is the boundary that\n * reads the world.\n */\n databaseExtensions?: readonly string[];\n /**\n * Run each statement that changes an existing table under this\n * `lock_timeout` (ms), retrying a timeout with backoff — see\n * `schema/boot-ddl`. The boot sets it; absent, statements wait on locks for\n * as long as the connection's `statement_timeout` allows, as the live schema\n * editor's confirmed changes do.\n */\n lockTimeoutMs?: number;\n /**\n * Apply the statements that build tables this run creates — and the enum\n * types they use — in transactions of up to {@link NEW_TABLE_BATCH}\n * statements instead of one autocommit each. The boot sets it; see\n * `ensureCollectionTables`.\n */\n batchNewTables?: boolean;\n /**\n * Leave the indexes a live table is missing (or holds INVALID) to the\n * caller, in {@link EnsureOutcome.deferredIndexes}, instead of building\n * them before returning. The boot sets it and builds them in the\n * background once it serves — see `deferred-index-build`. A UNIQUE one is\n * built before returning either way: see {@link DeferredIndexBuild.unique}.\n */\n deferIndexBuilds?: boolean;\n /**\n * The connection those builds run on when they are not deferred. Absent,\n * they share `client` — fine for a small table, and cut short by the\n * pool's `statement_timeout` on a large one, which is why the boot hands\n * over a dedicated connection.\n */\n openIndexBuildSession?: OpenIndexBuildSession;\n}\n\n/**\n * Most statements in one new-table transaction. Bounds how many locks one\n * transaction holds — each new table is several relations (the heap, its TOAST\n * table, its key index) — well inside the default `max_locks_per_transaction`.\n */\nconst NEW_TABLE_BATCH = 100;\n\nexport interface EnsureOutcome extends EnsurePlan {\n /**\n * Actions that could not be applied and are non-fatal by nature.\n *\n * Two kinds qualify. A foreign key can only fail on data that already\n * violates it, and the column it would police exists either way, so the\n * collection still serves; refusing to boot over one would turn a\n * pre-existing data problem into an outage. A column comment is the search\n * fingerprint, which needs table ownership — losing it costs drift\n * detection on the next boot, not the deployment. Both are reported loudly.\n */\n failures: { kind: EnsureAction[\"kind\"]; target: string; error: string }[];\n /**\n * What happened to the indexes built here rather than left to the caller —\n * every one of {@link EnsurePlan.deferredIndexes}, or under\n * `deferIndexBuilds` the UNIQUE ones only; absent when there were none.\n * An index on a table that already exists reports here, not in\n * {@link failures}.\n */\n indexBuilds?: DeferredIndexOutcome;\n}\n\n/** The schema a collection lives in, validated before it reaches any DDL. */\nfunction schemaOf(collection: CollectionConfig): string {\n const schema = (collection as { schema?: string }).schema || \"public\";\n return assertSafeIdentifier(schema, \"schema name\");\n}\n\n/**\n * Decide what to add. Pure — the caller supplies what exists and runs the\n * result.\n *\n * A plan and a diff: the collections are read once, by `planSchema`, into the\n * same {@link SchemaPlan} `db push` renders `schema.sql` from, and the diff\n * subtracts what the database already has. Before that split this function had\n * its own reading of every `Property`, and the audit found twelve places where\n * it disagreed with the two generators — a required `author_id` that was\n * NOT NULL after a push and nullable after a boot among them, on the one path\n * with no developer in the loop.\n */\nexport function planCollectionSchemaEnsure(\n allCollections: CollectionConfig[],\n existing: ExistingSchema,\n options: EnsureOptions = {}\n): EnsurePlan {\n const plan = planSchema(allCollections, { databaseExtensions: options.databaseExtensions });\n return diffPlanAgainstCatalogue(plan, existing, { constraints: options.constraints });\n}\n\nexport interface ReadSchemaOptions {\n /**\n * The existing tables worth probing for rows, given every table's columns.\n *\n * Whether a table holds rows decides one thing: whether a NOT NULL or a\n * UNIQUE can be added with a column *being added* to it. So the boot asks\n * only about tables gaining a column — none at all on a boot that changes\n * nothing — rather than probing every table in the schema, adopted and\n * unmanaged ones included, on every boot. A table that is not probed is\n * reported as holding rows: the answer that never plans a constraint that\n * could fail against data. Absent, every table is probed.\n */\n probeTables?: (tables: ReadonlyMap<string, ReadonlySet<string>>) => Iterable<string>;\n}\n\n/** Read what the database has, for the schemas the collections live in. */\nexport async function readExistingSchema(\n client: Queryable,\n schemas: string[],\n options: ReadSchemaOptions = {}\n): Promise<ExistingSchema> {\n const tables = new Map<string, Set<string>>();\n const enums = new Set<string>();\n if (schemas.length === 0) return { tables, enums };\n\n const inList = schemas\n .map(schema => `'${assertSafeIdentifier(schema, \"schema name\")}'`)\n .join(\", \");\n\n const notNullColumns = new Set<string>();\n // `udt_name` rather than `data_type`: `data_type` collapses every array to\n // the literal string `ARRAY` and every extension type to `USER-DEFINED`,\n // which cannot be compared with anything. `udt_name` names the actual type\n // — `int4`, `jsonb`, `_numeric`, `vector` — which is what the drift check\n // needs.\n const columnTypes = new Map<string, string>();\n // A NOT NULL column that has a DEFAULT is not a problem for a write that\n // omits it, so orphan detection needs the default as well as the nullability\n // — otherwise every `created_at DEFAULT now()` on a table whose collection\n // does not declare it would be reported as a blocker.\n const columnDefaults = new Set<string>();\n const { rows: columns } = await client.query<{\n table_schema: string;\n table_name: string;\n column_name: string;\n is_nullable: string;\n udt_name: string | null;\n column_default: string | null;\n numeric_precision: number | null;\n numeric_scale: number | null;\n }>(\n // `numeric_precision`/`numeric_scale` because `udt_name` is `numeric`\n // for both `NUMERIC` and `NUMERIC(10, 2)`, and a property that declares\n // a precision means it: money stored in an unbounded column keeps the\n // third decimal the rounding was supposed to remove. Only carried for\n // `numeric`, where the modifier changes what a value *is*; a `varchar`\n // width is a limit on the same family and `typesAgree` ignores it.\n `SELECT table_schema, table_name, column_name, is_nullable, udt_name, column_default,\n numeric_precision, numeric_scale\n FROM information_schema.columns\n WHERE table_schema IN (${inList})`\n );\n for (const row of columns) {\n const key = `${row.table_schema}.${row.table_name}`;\n if (!tables.has(key)) tables.set(key, new Set());\n tables.get(key)!.add(row.column_name);\n if (row.is_nullable === \"NO\") notNullColumns.add(`${key}.${row.column_name}`);\n if (row.udt_name) {\n const modifier = row.udt_name === \"numeric\" && row.numeric_precision !== null\n ? `(${row.numeric_precision},${row.numeric_scale ?? 0})`\n : \"\";\n columnTypes.set(`${key}.${row.column_name}`, `${row.udt_name}${modifier}`);\n }\n if (row.column_default !== null) columnDefaults.add(`${key}.${row.column_name}`);\n }\n\n // Trigger names, so `autoValue: \"on_update\"` is installed once rather than\n // re-issued on every boot. `tgisinternal` excludes the ones Postgres itself\n // creates to enforce foreign keys.\n const triggers = new Set<string>();\n const { rows: triggerRows } = await client.query<{ schema: string; table: string; name: string }>(\n `SELECT n.nspname AS schema, c.relname AS table, t.tgname AS name\n FROM pg_trigger t\n JOIN pg_class c ON t.tgrelid = c.oid\n JOIN pg_namespace n ON c.relnamespace = n.oid\n WHERE NOT t.tgisinternal AND n.nspname IN (${inList})`\n );\n for (const row of triggerRows) triggers.add(`${row.schema}.${row.table}.${row.name}`);\n\n // Which tables hold rows. This is the only fact that decides whether a\n // NOT NULL can be added without reading the data, so it is worth a query.\n //\n // `reltuples` would be cheaper and is wrong for this: it is a planner\n // estimate, it is -1 on a table that has never been analyzed, and a table\n // that was full an hour ago still reads as full after a DELETE. A wrong\n // \"empty\" here means a boot that aborts on a constraint violation, so the\n // estimate is not good enough. `EXISTS … LIMIT 1` stops at the first row,\n // which makes the true cost one page read per table.\n //\n // Restricted to ordinary and partitioned tables: `information_schema.columns`\n // also lists views and materialized views, and probing those runs whatever\n // query defines them.\n //\n // Indexes come back from the same read: an index already in the catalogue\n // under its planned name is not planned again (see `diffPlanAgainstCatalogue`).\n const populatedTables = new Set<string>();\n const indexes = new Set<string>();\n const { rows: relations } = await client.query<{ schema: string; name: string; kind: string }>(\n `SELECT n.nspname AS schema, c.relname AS name, c.relkind::text AS kind\n FROM pg_class c\n JOIN pg_namespace n ON c.relnamespace = n.oid\n WHERE c.relkind IN ('r', 'p', 'i', 'I') AND n.nspname IN (${inList})`\n );\n const allRealTables = relations.filter(row => row.kind === \"r\" || row.kind === \"p\");\n for (const row of relations) {\n if (row.kind === \"i\" || row.kind === \"I\") indexes.add(`${row.schema}.${row.name}`);\n }\n // Only the tables the caller will ask about; every other one reads as\n // holding rows — see `ReadSchemaOptions.probeTables`.\n const wanted = options.probeTables ? new Set(options.probeTables(tables)) : undefined;\n const realTables = wanted\n ? allRealTables.filter(row => wanted.has(`${row.schema}.${row.name}`))\n : allRealTables;\n if (wanted) {\n for (const row of allRealTables) {\n const key = `${row.schema}.${row.name}`;\n if (!wanted.has(key)) populatedTables.add(key);\n }\n }\n // The names come from `pg_class`, not from the configuration, so on an\n // adopted database they include tables nobody declared — `2024_archive`,\n // `order-items`, `Sales Data`. Those are quoted, not refused: refusing a\n // name here refuses the boot over a table Rebase will never touch.\n if (realTables.length > 0) {\n const probes = realTables.map(row =>\n `SELECT ${quoteSqlLiteral(`${row.schema}.${row.name}`)} AS key, ` +\n `EXISTS(SELECT 1 FROM ${quoteRelation(row.schema, row.name)} LIMIT 1) AS populated`\n );\n const { rows: populationRows } = await client.query<{ key: string; populated: boolean }>(\n probes.join(\" UNION ALL \")\n );\n for (const row of populationRows) {\n if (row.populated) populatedTables.add(row.key);\n }\n }\n\n const enumValues = new Map<string, string[]>();\n const { rows: enumValueRows } = await client.query<{\n schema: string;\n name: string;\n value: string;\n }>(\n // Ordered by `enumsortorder`, not by label: an enum's order is part of\n // its meaning (it is what `<` compares), and reading it back sorted\n // alphabetically would make a correct type look drifted.\n `SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value\n FROM pg_enum e\n JOIN pg_type t ON e.enumtypid = t.oid\n JOIN pg_namespace n ON t.typnamespace = n.oid\n WHERE n.nspname IN (${inList})\n ORDER BY t.typname, e.enumsortorder`\n );\n for (const row of enumValueRows) {\n const key = `${row.schema}.${row.name}`;\n if (!enumValues.has(key)) enumValues.set(key, []);\n enumValues.get(key)!.push(row.value);\n }\n\n const { rows: enumRows } = await client.query<{ schema: string; name: string }>(\n `SELECT n.nspname AS schema, t.typname AS name\n FROM pg_type t\n JOIN pg_namespace n ON t.typnamespace = n.oid\n WHERE t.typtype = 'e' AND n.nspname IN (${inList})`\n );\n for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);\n\n // `ADD CONSTRAINT` has no IF NOT EXISTS, so an existing foreign key is\n // skipped by name rather than guarded in SQL.\n const constraints = new Set<string>();\n const { rows: constraintRows } = await client.query<{\n schema: string;\n table: string;\n name: string;\n }>(\n `SELECT n.nspname AS schema, c.relname AS table, con.conname AS name\n FROM pg_constraint con\n JOIN pg_class c ON con.conrelid = c.oid\n JOIN pg_namespace n ON c.relnamespace = n.oid\n WHERE n.nspname IN (${inList})`\n );\n for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);\n\n // Indexes a `CREATE INDEX CONCURRENTLY` left INVALID. They are in\n // `indexes` under their name, so without this a husk reads as built and\n // `IF NOT EXISTS` keeps skipping it — see `deferred-index-build`.\n const invalidIndexes = new Set<string>();\n const { rows: invalidRows } = await client.query<{ schema: string; name: string }>(\n `SELECT n.nspname AS schema, c.relname AS name\n FROM pg_index i\n JOIN pg_class c ON c.oid = i.indexrelid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE NOT i.indisvalid AND n.nspname IN (${inList})`\n );\n for (const row of invalidRows) invalidIndexes.add(`${row.schema}.${row.name}`);\n\n // Column comments, which is where a generated search column records the\n // expression it was built from. `objsubid > 0` is what makes a row a\n // *column* comment rather than the table's own.\n const columnComments = new Map<string, string>();\n const { rows: commentRows } = await client.query<{\n schema: string;\n table: string;\n column: string;\n comment: string | null;\n }>(\n `SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment\n FROM pg_description d\n JOIN pg_class c ON d.objoid = c.oid\n JOIN pg_namespace n ON c.relnamespace = n.oid\n JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid\n WHERE d.objsubid > 0 AND n.nspname IN (${inList})`\n );\n for (const row of commentRows) {\n if (row.comment == null) continue;\n columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);\n }\n\n // The helper functions the plan `CREATE OR REPLACE`s, as the catalogue has\n // them, so an unchanged one is not rewritten on every boot. Looked up by\n // qualified name rather than by the schemas read above: `set_updated_at`\n // lives in `rebase`, which is auth's schema and not in that list.\n const functions = new Map<string, string>();\n const helperNames = HELPER_FUNCTIONS\n .map(qualified => qualified.split(\".\"))\n .map(([schema, name]) => `(${quoteSqlLiteral(schema)}, ${quoteSqlLiteral(name)})`)\n .join(\", \");\n const { rows: functionRows } = await client.query<{\n schema: string;\n name: string;\n args: string;\n returns: string;\n language: string;\n volatility: string;\n strict: boolean;\n parallel: string;\n security_definer: boolean;\n leakproof: boolean;\n cost: number | string;\n has_config: boolean;\n body: string;\n }>(\n `SELECT n.nspname AS schema, p.proname AS name, pg_get_function_identity_arguments(p.oid) AS args,\n format_type(p.prorettype, NULL) AS returns, l.lanname AS language,\n p.provolatile::text AS volatility, p.proisstrict AS strict, p.proparallel::text AS parallel,\n p.prosecdef AS security_definer, p.proleakproof AS leakproof, p.procost AS cost,\n p.proconfig IS NOT NULL AS has_config, p.prosrc AS body\n FROM pg_proc p\n JOIN pg_namespace n ON p.pronamespace = n.oid\n JOIN pg_language l ON p.prolang = l.oid\n WHERE (n.nspname, p.proname) IN (${helperNames})`\n );\n for (const row of functionRows) {\n functions.set(helperFunctionKey(row.schema, row.name, row.args), describeHelperFunction({\n returns: row.returns,\n language: row.language,\n volatility: row.volatility,\n strict: row.strict,\n parallel: row.parallel,\n securityDefiner: row.security_definer,\n leakproof: row.leakproof,\n cost: Number(row.cost),\n hasConfig: row.has_config,\n body: row.body\n }));\n }\n\n const extensions = new Set<string>();\n const { rows: extensionRows } = await client.query<{ name: string }>(\"SELECT extname AS name FROM pg_extension\");\n for (const row of extensionRows) extensions.add(row.name);\n\n return {\n tables, enums, constraints, columnComments, enumValues, notNullColumns, populatedTables, columnTypes,\n columnDefaults, triggers, indexes, invalidIndexes, extensions, functions\n };\n}\n\n/**\n * The planned tables that exist and lack at least one planned column — the\n * only ones whose emptiness the diff consults (a NOT NULL or UNIQUE arriving\n * with a new column). A superset, never a subset: a column the plan renames\n * from a legacy name reads as missing here too.\n */\nfunction tablesGainingColumns(plan: SchemaPlan, catalogue: ReadonlyMap<string, ReadonlySet<string>>): string[] {\n const gaining: string[] = [];\n for (const table of plan.tables) {\n const columns = catalogue.get(table.qualified);\n if (columns && table.columns.some(column => !columns.has(column.column))) gaining.push(table.qualified);\n }\n return gaining;\n}\n\n/**\n * What to tell an operator whose `search` block no longer matches its column.\n *\n * Every line here is doing work: naming the collection is not enough, because\n * the symptom (a search that finds nothing) points at the data, not the schema;\n * and the remediation has to be exact, because it is a table rewrite the\n * operator is being asked to schedule rather than discover.\n */\nfunction searchDriftMessage(drift: SearchColumnDrift[]): string {\n const blocks = drift.map(d =>\n ` \"${d.table}\".\"${d.column}\" was generated from a different \\`search\\` block ` +\n `(recorded ${d.found}, current ${d.expected}).\\n` +\n d.rebuild.map(s => ` ${s}`).join(\"\\n\")\n );\n return (\n \"The `search` block changed after its generated column was created, and Postgres cannot alter a \" +\n \"generated expression in place.\\n\" +\n \"Rebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole \" +\n \"table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path \" +\n \"may not schedule on your behalf.\\n\" +\n \"Until it is rebuilt the column keeps indexing the previous fields, weights and language — searches for \" +\n \"anything added since return nothing, which reads from outside as \\\"no such row\\\".\\n\" +\n \"Run these (or revert the block to what the column was built from), then boot again:\\n\" +\n blocks.join(\"\\n\") +\n \"\\n The GIN index is dropped with the column and recreated concurrently on the next boot.\"\n );\n}\n\n\n/**\n * Read what the database looks like, for the schemas a set of collections\n * lives in.\n *\n * The same read `ensureCollectionTables` does at boot, exposed on its own for\n * the callers that want to *plan* against a real database without changing it —\n * the live schema editor, which has to tell somebody what a change would do\n * before they agree to it.\n */\nexport async function readSchemaFactsFor(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<ExistingSchema> {\n const relational = relationalCollections(collections);\n const junctions = planJunctionTables(relational);\n const schemas = Array.from(new Set([\n ...relational.map(schemaOf),\n ...junctions.map(junction => junction.schema)\n ]));\n // Row probes for the declared tables only: the classifier asks about the\n // collections it is given, never about a table nobody declared.\n const declared = [\n ...relational.map(collection => `${schemaOf(collection)}.${getTableName(collection)}`),\n ...junctions.map(junction => `${junction.schema}.${junction.table}`)\n ];\n return readExistingSchema(client, schemas, { probeTables: () => declared });\n}\n\n/**\n * Bring the database up to date. Returns what it did.\n *\n * Each statement runs on its own rather than in one transaction: they are all\n * independently safe and idempotent, and a single failure (an enum label that\n * cannot be added, say) should not roll back the tables that were created fine.\n * The error is surfaced with the statement that caused it.\n */\nexport async function ensureCollectionTables(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void,\n options: EnsureOptions = {}\n): Promise<EnsureOutcome> {\n // This is the boundary, so this is where the world is read: the planner\n // itself takes the permission as an argument and never reaches for the\n // registry. By the time boot gets here `loadBundleResourceGraph` has\n // evaluated the project's `resources.ts`, so the declarations are there —\n // and a caller that already knows them can still say so.\n const schema = planSchema(collections, {\n databaseExtensions: options.databaseExtensions ?? declaredDatabaseExtensions()\n });\n\n // Junctions live alongside the collections that declare them, so their\n // schema has to be read too — otherwise an existing junction reads as\n // missing and its constraints as unplanned. Taken off the plan, which lists\n // both kinds of table.\n const schemas = Array.from(new Set(schema.tables.map(table => table.schema)));\n for (const name of schemas) {\n assertSafeIdentifier(name, \"schema name\");\n if (name !== \"public\") {\n await client.query(`CREATE SCHEMA IF NOT EXISTS \"${name}\";`);\n }\n }\n // `rebase` is where the `updated_at` trigger's function lives. Every real\n // boot has the schema by the time this runs (auth ensures it first) and\n // `db push` creates it in `schema.sql`, but this path is also driven by the\n // live schema editor and by tests, where \"the function's schema happens to\n // exist already\" is not a thing to rely on. Only when something needs it,\n // so a project with no trigger issues no statement — and not added to the\n // read set: what is *in* `rebase` is auth's business, not this planner's.\n if (schema.tables.some(table => table.triggers.length > 0)) {\n await client.query(`CREATE SCHEMA IF NOT EXISTS \"${REBASE_SCHEMA}\";`);\n }\n\n const existing = await readExistingSchema(client, schemas, {\n probeTables: catalogue => tablesGainingColumns(schema, catalogue)\n });\n // Every index on a table that already exists goes to the index builder,\n // which this function runs or hands to the caller — see `buildIndexes`.\n const plan = diffPlanAgainstCatalogue(schema, existing, { constraints: options.constraints, deferLiveIndexes: true });\n const failures: EnsureOutcome[\"failures\"] = [];\n\n // Reported, not warned: this is a rename the ensure is about to perform, and\n // the operator should be able to see in the log why a column changed name.\n // The interesting case is the one that no longer happens — before this,\n // ensure added the new column empty beside the populated old one, every\n // statement succeeded, and the relation read the empty one.\n for (const legacy of plan.legacyForeignKeys) {\n const message =\n `Renaming \"${legacy.table}\".\"${legacy.legacy}\" to \"${legacy.expected}\". The old name is ` +\n \"the one Rebase derived for this relation before it singularized properly; the column \" +\n \"keeps its data, indexes and constraints. To keep the old name instead, set \" +\n `\\`localKey: \"${legacy.legacy}\"\\` on the relation and this will stop.`;\n logger.info(`[schema] ${message}`);\n log?.(message);\n }\n\n // Before anything is applied: a `search` block that changed after its column\n // was generated cannot be honoured by an additive plan, and serving the old\n // index while the config describes a new one is the silent failure this\n // check exists to end. Refusing is the loud half — boot is fatal on purpose\n // (see `ensureCollectionSchema` in the server's boot) and the message\n // carries the exact statements that resolve it.\n if (plan.searchDrift.length > 0) {\n throw new Error(searchDriftMessage(plan.searchDrift));\n }\n\n // Said once per column, at the moment the stamp is applied: from here on a\n // change is detected, but whether *this* column matches the block it is\n // being stamped with is not knowable — it predates the stamp.\n for (const adopted of plan.searchAdopted) {\n const message =\n `Adopting the existing generated column \"${adopted.table}\".\"${adopted.column}\" and recording what the ` +\n \"current `search` block would generate. Any later change to that block will be detected and refused; a \" +\n \"change made *before* this version was deployed cannot be, so if search has been missing content, \" +\n `rebuild the column once: ALTER TABLE \"${adopted.table.split(\".\").join('\".\"')}\" DROP COLUMN \"${adopted.column}\"; and boot again.`;\n logger.info(`[schema] ${message}`);\n log?.(message);\n }\n\n // Said once per column, every boot: an unindexed vector column and an\n // indexed one behave identically apart from latency, so the only way anyone\n // learns which one they have is if the boot says so.\n for (const skip of plan.vectorIndexSkipped) {\n const message = `No ANN index on \"${skip.table}\".\"${skip.column}\": ${skip.reason}`;\n logger.warn(`[schema] ${message}`);\n log?.(message);\n }\n\n // Said once per column, every boot, because the alternative is what this\n // whole feature exists to end: a column the configuration calls required,\n // sitting there nullable, with every surface reporting success. The boot\n // does not fail over it — the column is usable and the data is intact — but\n // it stops being invisible.\n for (const withheld of plan.withheldConstraints) {\n const message =\n `No ${withheld.kind === \"unique\" ? \"UNIQUE\" : \"NOT NULL\"} on \"${withheld.target}\": ` +\n `${withheld.reason} ${withheld.remedy}`;\n logger.warn(`[schema] ${message}`);\n log?.(message);\n }\n\n // Said once per drifted column, every boot, and this is the one report here\n // that describes a system which currently *looks* healthy. Everything else\n // in this function explains a statement that did not run; this explains a\n // statement that was never planned, on a deploy that reported success, and\n // whose first symptom is a write rejected long afterwards.\n for (const drift of plan.columnTypeDrift) {\n const message = columnTypeDriftMessage(drift);\n logger.warn(`[schema] ${message}`);\n log?.(message);\n }\n\n // Said once per column, every boot, and it matters more than the rest here\n // because the table is not merely imperfect — it is unwritable, and the\n // error the API returns names a field the author has already deleted.\n // This is what a renamed property looks like to an additive provisioner.\n for (const orphan of plan.orphanedRequiredColumns) {\n const [schema, table] = orphan.table.split(\".\");\n const message =\n `\"${orphan.table}\".\"${orphan.column}\" is NOT NULL with no default, and the \"${orphan.slug}\" ` +\n \"collection no longer declares it. Every insert will be rejected for a field you cannot set \" +\n `(PG_23502, naming \"${orphan.column}\"). This is what a renamed property looks like: the new ` +\n \"column was added, and the old one cannot be dropped unattended. Fix it with ONE of:\\n\" +\n ` ALTER TABLE \"${schema}\".\"${table}\" DROP COLUMN \"${orphan.column}\"; -- the rename is complete\\n` +\n ` ALTER TABLE \"${schema}\".\"${table}\" ALTER COLUMN \"${orphan.column}\" DROP NOT NULL; -- keep the data, stop requiring it\\n` +\n \" ...or declare the property again, if the column is still wanted.\";\n logger.warn(`[schema] ${message}`);\n log?.(message);\n }\n\n // The indexes of tables that already exist. Built here unless the caller\n // defers them — and a UNIQUE one is built here regardless, before this\n // returns, so nothing serves the collection without the promise it makes.\n const builtHere = options.deferIndexBuilds\n ? plan.deferredIndexes.filter(index => index.unique === true)\n : plan.deferredIndexes;\n const leftToCaller = options.deferIndexBuilds\n ? plan.deferredIndexes.filter(index => index.unique !== true)\n : plan.deferredIndexes;\n\n // After every action, so the columns they index exist — whether this run\n // added them or a previous one did.\n const buildIndexes = async (): Promise<DeferredIndexOutcome | undefined> => {\n if (builtHere.length === 0) return undefined;\n const outcome = await buildDeferredIndexes(\n options.openIndexBuildSession ?? (async () => ({\n query: <T>(sql: string) => client.query<T>(sql),\n dedicated: false,\n release: () => undefined\n })),\n builtHere,\n { log, lockTimeoutMs: options.lockTimeoutMs }\n );\n assertUniqueIndexesBuilt(builtHere, outcome);\n return outcome;\n };\n const finish = async (): Promise<EnsureOutcome> => {\n const indexBuilds = await buildIndexes();\n return { ...plan, deferredIndexes: leftToCaller, failures, indexBuilds };\n };\n\n if (plan.actions.length === 0) {\n if (plan.deferredIndexes.length === 0) log?.(\"Schema is up to date; nothing to create.\");\n return finish();\n }\n\n const lockBudget = options.lockTimeoutMs !== undefined\n ? { lockTimeoutMs: options.lockTimeoutMs, atomic: true }\n : undefined;\n // Whether the handle takes a multi-statement string; shared with the lock\n // budget, which needs the same thing.\n const multi = lockBudget ?? { atomic: true };\n\n // A table this run creates has no rows, no readers and no writers, so its\n // statements — the CREATE, the ENABLE, every column, its triggers, the\n // indexes on it — can share a transaction: one commit instead of one per\n // statement. A first deploy of 200 collections was 3,767 autocommits, 11.7 s\n // on a disk that really flushes. If a batch fails for any reason, its\n // statements are applied again one at a time below, exactly as before, so\n // the races and survivable failures that path handles end the same way.\n const createdTables = new Set(plan.actions.filter(a => a.kind === \"create-table\").map(a => a.target));\n const batchable = (action: EnsureAction): boolean =>\n options.batchNewTables === true &&\n multi.atomic &&\n (action.kind === \"create-enum\" || createdTables.has(action.target.split(\".\").slice(0, 2).join(\".\"))) &&\n action.kind !== \"add-enum-value\" &&\n !CONCURRENTLY_RE.test(action.sql);\n\n const applyBatch = async (group: EnsureAction[]): Promise<boolean> => {\n const statements = group.map(action => action.sql);\n try {\n await client.query(lockBudget\n ? atomicBatch(statements, lockBudget.lockTimeoutMs)\n : joinStatements(statements));\n return true;\n } catch (err) {\n if (isMultiStatementRefusal(err)) {\n multi.atomic = false;\n } else {\n log?.(`${group.length} statement(s) for new tables failed as one transaction ` +\n `(${describeDriverError(err).split(\"\\n\")[0]}); applying them one at a time`);\n }\n return false;\n }\n };\n\n const applyOne = async (action: EnsureAction): Promise<void> => {\n try {\n if (await applyAction(client, action, lockBudget)) {\n log?.(`${action.kind}: ${action.target}`);\n } else {\n log?.(`${action.kind}: ${action.target} (already created by a peer)`);\n }\n } catch (err) {\n const message = describeDriverError(err);\n // A foreign key is the only action that can fail on the customer's\n // data rather than on the schema. The column it polices is already\n // there, so the collection serves either way — record it and carry\n // on rather than crash-looping the deployment.\n //\n // A comment is metadata about a column that was just created\n // successfully, and it can only fail on ownership (COMMENT requires\n // owning the table, which an adopted table may not grant). Losing\n // the stamp costs drift detection on the next boot; it must not cost\n // the deployment.\n //\n // An index that is not UNIQUE is an optimization, and losing an\n // optimization must not cost the deployment. This was the difference\n // between a slow collection and a tenant that would not boot: on a\n // managed runtime the throw killed the process, the pod never went\n // ready, and the deploy reported a 300-second readiness timeout — for\n // an ANN index whose absence changes latency and nothing else.\n //\n // UNIQUE is excluded because it is not an optimization. It is the\n // collection declaring that two rows cannot share a value, and\n // serving without it is serving a promise the database is not\n // keeping.\n const survivable =\n action.kind === \"add-constraint\" ||\n action.kind === \"comment-column\" ||\n (action.kind === \"create-index\" && !/CREATE\\s+UNIQUE\\s+INDEX/i.test(action.sql));\n if (survivable) {\n failures.push({ kind: action.kind, target: action.target, error: message });\n return;\n }\n const lockHint = isLockTimeout(err)\n ? \"\\n hint: another session held this table's lock for every attempt (a long transaction, a \" +\n \"report or a dump). Nothing was changed; boot again once it has finished.\"\n : \"\";\n throw new Error(\n `Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}${lockHint}\\n ${action.sql}`\n );\n }\n };\n\n for (let i = 0; i < plan.actions.length;) {\n let end = i;\n while (end < plan.actions.length && end - i < NEW_TABLE_BATCH && batchable(plan.actions[end])) end++;\n if (end - i > 1 && await applyBatch(plan.actions.slice(i, end))) {\n for (const action of plan.actions.slice(i, end)) log?.(`${action.kind}: ${action.target}`);\n i = end;\n continue;\n }\n const last = Math.max(end, i + 1);\n for (; i < last; i++) await applyOne(plan.actions[i]);\n }\n return finish();\n}\n\n/**\n * Fail the ensure for a UNIQUE index on a live table that could not be built,\n * exactly as a failed UNIQUE `create-index` action always has (see `applyOne`).\n *\n * \"Another index on the table is being built\" counts: the table's lock is\n * held by a build that may run for minutes, and serving the collection\n * meanwhile would be serving a promise the database is not keeping. Another\n * instance building *this* index does not: once its build commits the\n * constraint holds, which is also what a peer's `IF NOT EXISTS` race meant.\n */\nfunction assertUniqueIndexesBuilt(indexes: readonly DeferredIndexBuild[], outcome: DeferredIndexOutcome): void {\n for (const index of indexes) {\n if (index.unique !== true) continue;\n const qualified = `${index.schema}.${index.indexName}`;\n const reason = outcome.failed.find(failure => failure.index === qualified)?.error\n ?? outcome.skipped.find(skip => skip.index === qualified && skip.reason === TABLE_BUSY_REASON)?.reason;\n if (reason === undefined) continue;\n throw new Error(`Failed to create-index ${index.schema}.${index.table}: ${qualified}: ${reason}\\n ${index.sql}`);\n }\n}\n\n/**\n * What actually went wrong, rather than what the ORM said about it.\n *\n * Drizzle wraps every driver error, and its `message` is literally\n * ``Failed query: ${sql}\\nparams: ${params}`` — the statement we already know,\n * and not one word of Postgres's answer. Reporting `err.message` therefore\n * produced a failure that named the statement twice and the *reason* zero\n * times, so four unrelated boot failures — a missing extension, a wrong\n * privilege, a bad index method, a constraint the rows violate — all read\n * identically to whoever had to fix them.\n *\n * Two consequences, both observed in the field before this existed:\n *\n * 1. On a managed deployment the reason never reaches anybody. Boot dies, the\n * pod never goes ready, and the deploy reports a readiness timeout — so the\n * one line that says why is the one line that was thrown away here.\n * 2. {@link vectorExtensionHint} matches on the error text. Against the\n * wrapper it matched nothing, so the hint written specifically to explain a\n * missing pgvector never fired on the path that raises it.\n *\n * `detail` and `hint` come along because they are the fields Postgres uses to\n * say which object it meant and what to do about it — `CREATE EXTENSION` names\n * the missing library there, and a rejected constraint names the row. This\n * writes into the tenant's own boot log, about the tenant's own database.\n */\nexport function describeDriverError(err: unknown): string {\n const pg = extractPgError(err);\n if (pg) {\n const parts = [pg.code ? `${pg.message} [${pg.code}]` : pg.message];\n if (pg.detail) parts.push(` detail: ${pg.detail}`);\n if (pg.hint) parts.push(` hint: ${pg.hint}`);\n return parts.join(\"\\n\");\n }\n // No SQLSTATE anywhere in the chain: not a database answer at all — a\n // socket that closed, a bug in this file. The deepest cause still beats the\n // wrapper, and the wrapper still beats nothing.\n const cause = extractCauseMessage(err);\n if (cause) return cause;\n return err instanceof Error ? err.message : String(err);\n}\n\n/** ` CONCURRENTLY ` as it appears in a rendered `CREATE [UNIQUE] INDEX`. */\nconst CONCURRENTLY_RE = /\\sCONCURRENTLY\\s/i;\n\n/**\n * `25001 active_sql_transaction` — \"cannot run inside a transaction block\".\n *\n * Only ever raised here by `CREATE INDEX CONCURRENTLY`, and only when the\n * connection we were handed is inside a transaction. Deliberately narrow: it\n * decides to retry a *different* statement, so it must not fire on anything it\n * has not been reasoned about.\n */\nfunction isTransactionBlockRefusal(err: unknown): boolean {\n return extractPgError(err)?.code === \"25001\";\n}\n\n/** Attempts per action, including the first. Matches the server's bootstraps. */\nconst DDL_ATTEMPTS = 4;\n\n/**\n * Attempts at an action whose table lock is held, including the first.\n *\n * Unlike a policy, a schema change cannot be deferred — the collection's code\n * expects the column — so a lock that never frees still fails the boot, as a\n * statement timeout did. Ten one-second attempts with backoff between them wait\n * about as long as the old 30 s statement timeout did, but a live query on that\n * table now waits at most one second at a time instead of thirty.\n */\nconst LOCK_ATTEMPTS = 10;\n\n/**\n * Whether an action may be sent as `SET LOCAL lock_timeout; <statement>` — one\n * implicit transaction. Not `CREATE INDEX CONCURRENTLY`, which Postgres refuses\n * inside any transaction block (and which waits on no lock a reader holds), and\n * not `ALTER TYPE … ADD VALUE`, which Postgres before 12 refuses there too and\n * which locks a type, not a table.\n */\nconst takesLockBudget = (action: EnsureAction, sql: string): boolean =>\n action.kind !== \"add-enum-value\" && !CONCURRENTLY_RE.test(sql);\n\n/**\n * Run one planned statement, surviving a simultaneous boot.\n *\n * Every statement in a plan is written to be idempotent, and that is not the\n * same as being safe to run concurrently: `CREATE … IF NOT EXISTS` reads the\n * catalog and then writes to it as two steps, so peers starting together both\n * see \"absent\" and the loser gets a duplicate key on a *catalog* index. Measured\n * against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is\n * worse, because Postgres has no `IF NOT EXISTS` for it at all.\n *\n * What made that fatal here rather than merely noisy is the loop this sits in.\n * A losing statement threw, and the throw abandoned **every remaining action in\n * the plan** — so a replica that lost one race came up missing tables it never\n * attempted, and the boot log blamed the one statement that failed.\n *\n * @returns `true` if this process applied the statement, `false` if a peer had\n * already created the object. The distinction is only for the log; both mean\n * the object is now there.\n * @throws the original error for anything that is not a race — a syntax error, a\n * permission failure, a unique constraint the customer's own rows violate.\n */\nasync function applyAction(\n client: Queryable,\n action: EnsureAction,\n lockBudget?: { lockTimeoutMs: number; atomic: boolean }\n): Promise<boolean> {\n let sqlText = action.sql;\n let lockAttempts = 0;\n for (let attempt = 1; ; attempt++) {\n const budgeted = lockBudget?.atomic === true && takesLockBudget(action, sqlText);\n try {\n await client.query(budgeted ? atomicBatch([sqlText], lockBudget.lockTimeoutMs) : sqlText);\n return true;\n } catch (err) {\n // A handle that takes one statement per query (extended protocol)\n // cannot carry the `SET LOCAL`. Nothing ran; say it once and send\n // every statement as it is from here on.\n if (budgeted && isMultiStatementRefusal(err)) {\n lockBudget.atomic = false;\n attempt--;\n continue;\n }\n // Something held the table for longer than the budget — a long\n // transaction, a report, a dump. The statement did nothing; let\n // the queue behind it drain, then ask again.\n if (budgeted && isLockTimeout(err) && ++lockAttempts < LOCK_ATTEMPTS) {\n logger.debug(\n `[schema] ${action.kind} ${action.target}: the table's lock is held by another session ` +\n `(attempt ${lockAttempts}/${LOCK_ATTEMPTS}) — retrying`\n );\n attempt--;\n await sleep(Math.min(500 * lockAttempts, 2_000) * (1 + Math.random()));\n continue;\n }\n // `CREATE INDEX CONCURRENTLY` is the one statement here Postgres\n // refuses inside a transaction block (25001), and whether we are in\n // one is not ours to know: the client is whatever handle the caller\n // bootstrapped with, and an application that builds its own adapter\n // can hand us a connection already inside a transaction. The plain\n // form is always correct and never refused — it takes a write lock\n // for the build, which is the cost of getting the index at all.\n if (isTransactionBlockRefusal(err) && CONCURRENTLY_RE.test(sqlText)) {\n sqlText = sqlText.replace(CONCURRENTLY_RE, \" \");\n logger.warn(\n `[schema] ${action.kind} ${action.target}: this connection is inside a transaction, ` +\n \"so the index is being built with a write lock rather than concurrently. Writes to \" +\n \"this table block until it finishes.\"\n );\n continue;\n }\n // Already there. Not \"retry\" — the end state this statement wanted\n // is the end state the database is in, so carry on to the next\n // action rather than spending three more attempts proving it.\n if (isDuplicateObjectRace(err)) {\n logger.debug(\n `[schema] ${action.kind} ${action.target}: already created by another instance`\n );\n return false;\n }\n // Retryable but not yet satisfied — a deadlock between two boots\n // taking catalog locks in step. The statement did nothing; run it\n // again after a jittered pause so peers that collided once do not\n // collide again in lockstep.\n if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {\n logger.debug(\n `[schema] ${action.kind} ${action.target}: lost a race with another instance ` +\n `(attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`\n );\n await new Promise(resolve => setTimeout(resolve, 40 * attempt * (1 + Math.random())));\n continue;\n }\n throw err;\n }\n }\n}\n"],"mappings":"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"}
@@ -1,8 +1,10 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
4
+ import { o as internalTablesGrantedToUserRole } from "./rls-enforcement-DvzqlaL1.js";
4
5
  import { r as authUsersColumnSql, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-C72EMoDJ.js";
5
- import { n as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-_KNnjanK.js";
6
+ import { n as BOOT_LOCK_TIMEOUT_MS } from "./boot-ddl-DVpsFN6r.js";
7
+ import { i as rlsRuntimeIsCurrent, n as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-RQUljnJ1.js";
6
8
  import { logger } from "@rebasepro/server";
7
9
  import { sql } from "drizzle-orm";
8
10
  import { revokeInternalTableAccess } from "@rebasepro/common";
@@ -162,7 +164,83 @@ async function probeAuthSchema(db, authSchema) {
162
164
  }
163
165
  //#endregion
164
166
  //#region src/auth/ensure-tables.ts
165
- var ensure_tables_exports = /* @__PURE__ */ __exportAll({ ensureAuthTablesExist: () => ensureAuthTablesExist });
167
+ var ensure_tables_exports = /* @__PURE__ */ __exportAll({
168
+ dropDuplicateTokenHashIndexesSql: () => dropDuplicateTokenHashIndexesSql,
169
+ ensureAuthTablesExist: () => ensureAuthTablesExist
170
+ });
171
+ /** A string literal for a catalogue lookup. The names come from config, never from a request. */
172
+ var literal = (value) => `'${value.replace(/'/g, "''")}'`;
173
+ /**
174
+ * Every index name in these schemas, as `schema.name`.
175
+ *
176
+ * `CREATE INDEX IF NOT EXISTS` is not free when the index exists: Postgres
177
+ * takes the table's SHARE lock — which blocks writers, and queues behind any
178
+ * open write transaction — before it looks the name up. So the names are read
179
+ * once and only a missing index is created. Read before anything is created:
180
+ * this function never drops an index, so present then means present now.
181
+ */
182
+ async function readIndexNames(db, schemas) {
183
+ const names = /* @__PURE__ */ new Set();
184
+ const list = [...new Set(schemas)];
185
+ if (list.length === 0) return names;
186
+ const result = await db.execute(sql.raw(`SELECT n.nspname AS schema, c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('i', 'I') AND n.nspname IN (${list.map(literal).join(", ")})`));
187
+ for (const row of result.rows) names.add(`${row.schema}.${row.name}`);
188
+ return names;
189
+ }
190
+ /**
191
+ * A column default as `information_schema` reports it, reduced to what decides
192
+ * equality: `(gen_random_uuid())::text` and `gen_random_uuid()::text` are the
193
+ * same default, written two ways.
194
+ */
195
+ var sameDefault = (actual, expected) => actual !== null && actual.toLowerCase().replace(/[\s()]/g, "") === expected.toLowerCase().replace(/[\s()]/g, "");
196
+ /**
197
+ * The plain `token_hash` indexes every era created next to the UNIQUE
198
+ * constraint's own index on the same column. No longer created; dropped where
199
+ * found — see {@link dropDuplicateTokenHashIndexesSql}.
200
+ */
201
+ var DUPLICATE_TOKEN_HASH_INDEXES = [
202
+ "idx_refresh_tokens_hash",
203
+ "idx_password_reset_tokens_hash",
204
+ "idx_magic_link_tokens_hash"
205
+ ];
206
+ /**
207
+ * One statement that drops each named index — but only one that covers a
208
+ * single column that a valid, unique, non-partial btree on the same table also
209
+ * leads with. That is the UNIQUE (token_hash) constraint's index, which serves
210
+ * every `token_hash = $1` lookup; a table that somehow lacks it keeps its
211
+ * plain index, so no lookup is ever left without one.
212
+ *
213
+ * `DROP INDEX` takes the table's ACCESS EXCLUSIVE lock, on the tables every
214
+ * sign-in and token refresh touches, so it runs under the boot lock budget
215
+ * (`lock_timeout`, transaction-local to this statement — see `boot-ddl`).
216
+ */
217
+ function dropDuplicateTokenHashIndexesSql(schema, names) {
218
+ return `
219
+ DO $rebase_drop_hash_index$
220
+ DECLARE duplicate record;
221
+ BEGIN
222
+ PERFORM set_config('lock_timeout', '${BOOT_LOCK_TIMEOUT_MS}ms', true);
223
+ FOR duplicate IN
224
+ SELECT ic.relname AS name
225
+ FROM pg_index d
226
+ JOIN pg_class ic ON ic.oid = d.indexrelid
227
+ JOIN pg_namespace n ON n.oid = ic.relnamespace
228
+ WHERE n.nspname = ${literal(schema)}
229
+ AND ic.relname IN (${names.map(literal).join(", ")})
230
+ AND d.indnatts = 1
231
+ AND EXISTS (
232
+ SELECT 1 FROM pg_index o
233
+ JOIN pg_class oc ON oc.oid = o.indexrelid
234
+ JOIN pg_am am ON am.oid = oc.relam
235
+ WHERE o.indrelid = d.indrelid AND o.indexrelid <> d.indexrelid
236
+ AND o.indisvalid AND o.indisunique AND o.indpred IS NULL AND o.indexprs IS NULL
237
+ AND am.amname = 'btree' AND o.indkey[0] = d.indkey[0])
238
+ LOOP
239
+ EXECUTE format('DROP INDEX IF EXISTS %I.%I', ${literal(schema)}, duplicate.name);
240
+ END LOOP;
241
+ END
242
+ $rebase_drop_hash_index$`.trim();
243
+ }
166
244
  /**
167
245
  * Auto-create auth tables if they don't exist.
168
246
  *
@@ -210,6 +288,13 @@ async function ensureAuthTablesExist(db, collection) {
210
288
  if (usersSchema !== "public") await db.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.raw(usersSchema)}`);
211
289
  await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
212
290
  const authSchema = usersSchema === "public" ? "rebase" : usersSchema;
291
+ const indexNames = await readIndexNames(db, [usersSchema, authSchema]);
292
+ const runRaw = async (text) => (await db.execute(sql.raw(text))).rows;
293
+ /** Create an index only when it is missing — see {@link readIndexNames}. */
294
+ const ensureIndex = async (schema, name, statement) => {
295
+ if (indexNames.has(`${schema}.${name}`)) return;
296
+ await db.execute(statement);
297
+ };
213
298
  const userIdentitiesTable = `"${authSchema}"."user_identities"`;
214
299
  const refreshTokensTableName = `"${authSchema}"."refresh_tokens"`;
215
300
  const passwordResetTokensTableName = `"${authSchema}"."password_reset_tokens"`;
@@ -305,8 +390,8 @@ async function ensureAuthTablesExist(db, collection) {
305
390
  UNIQUE(provider, provider_id)
306
391
  )
307
392
  `);
308
- await db.execute(sql`
309
- CREATE INDEX IF NOT EXISTS idx_user_identities_user
393
+ await ensureIndex(authSchema, "idx_user_identities_user", sql`
394
+ CREATE INDEX IF NOT EXISTS idx_user_identities_user
310
395
  ON ${sql.raw(userIdentitiesTable)}(uid)
311
396
  `);
312
397
  await db.execute(sql`
@@ -324,12 +409,8 @@ async function ensureAuthTablesExist(db, collection) {
324
409
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
325
410
  )
326
411
  `);
327
- await db.execute(sql`
328
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash
329
- ON ${sql.raw(refreshTokensTableName)}(token_hash)
330
- `);
331
- await db.execute(sql`
332
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
412
+ await ensureIndex(authSchema, "idx_refresh_tokens_user", sql`
413
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
333
414
  ON ${sql.raw(refreshTokensTableName)}(uid)
334
415
  `);
335
416
  await db.execute(sql`
@@ -342,12 +423,8 @@ async function ensureAuthTablesExist(db, collection) {
342
423
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
343
424
  )
344
425
  `);
345
- await db.execute(sql`
346
- CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash
347
- ON ${sql.raw(passwordResetTokensTableName)}(token_hash)
348
- `);
349
- await db.execute(sql`
350
- CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
426
+ await ensureIndex(authSchema, "idx_password_reset_tokens_user", sql`
427
+ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
351
428
  ON ${sql.raw(passwordResetTokensTableName)}(uid)
352
429
  `);
353
430
  const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
@@ -361,14 +438,16 @@ async function ensureAuthTablesExist(db, collection) {
361
438
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
362
439
  )
363
440
  `);
364
- await db.execute(sql`
365
- CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash
366
- ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
367
- `);
368
- await db.execute(sql`
369
- CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
441
+ await ensureIndex(authSchema, "idx_magic_link_tokens_user", sql`
442
+ CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
370
443
  ON ${sql.raw(magicLinkTokensTableName)}(uid)
371
444
  `);
445
+ const duplicateHashIndexes = DUPLICATE_TOKEN_HASH_INDEXES.filter((name) => indexNames.has(`${authSchema}.${name}`));
446
+ if (duplicateHashIndexes.length > 0) try {
447
+ await db.execute(sql.raw(dropDuplicateTokenHashIndexesSql(authSchema, duplicateHashIndexes)));
448
+ } catch (err) {
449
+ logger.warn(`⚠️ Left the duplicate token-hash index(es) ${duplicateHashIndexes.join(", ")} in place for now: ` + (err instanceof Error ? err.message : String(err)));
450
+ }
372
451
  await db.execute(sql`
373
452
  CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
374
453
  key TEXT PRIMARY KEY,
@@ -376,22 +455,28 @@ async function ensureAuthTablesExist(db, collection) {
376
455
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
377
456
  )
378
457
  `);
379
- await db.transaction(async (tx) => {
458
+ if (!await rlsRuntimeIsCurrent(runRaw).catch(() => false)) await db.transaction(async (tx) => {
380
459
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
381
460
  for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
382
461
  });
462
+ const readUsersColumns = async () => (await db.execute(sql`
463
+ SELECT column_name, data_type, is_nullable, column_default
464
+ FROM information_schema.columns
465
+ WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
466
+ `)).rows;
467
+ let usersColumnRows = await readUsersColumns();
468
+ const usersColumnsBefore = new Set(usersColumnRows.map((row) => row.column_name));
469
+ let usersColumnsAdded = false;
383
470
  for (const spec of AUTH_USERS_COLUMNS) {
384
471
  if (spec.column === "email") continue;
472
+ if (usersColumnsBefore.has(spec.column)) continue;
385
473
  await db.execute(sql`
386
474
  ALTER TABLE ${sql.raw(usersTableName)}
387
475
  ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
388
476
  `);
477
+ usersColumnsAdded = true;
389
478
  }
390
- const usersColumnRows = (await db.execute(sql`
391
- SELECT column_name, data_type, is_nullable, column_default
392
- FROM information_schema.columns
393
- WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
394
- `)).rows;
479
+ if (usersColumnsAdded) usersColumnRows = await readUsersColumns();
395
480
  const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
396
481
  const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
397
482
  for (const spec of AUTH_USERS_COLUMNS) {
@@ -435,11 +520,7 @@ async function ensureAuthTablesExist(db, collection) {
435
520
  logger.info(`🔧 Widened ${usersTableName}.${column} from VARCHAR(n) to TEXT`);
436
521
  }
437
522
  if (usersColumnTypes.has("email")) {
438
- if ((await db.execute(sql`
439
- SELECT 1 FROM pg_class c
440
- JOIN pg_namespace n ON n.oid = c.relnamespace
441
- WHERE n.nspname = ${usersSchema} AND c.relname = ${emailLowerUniqueIndex} AND c.relkind = 'i'
442
- `)).rows.length === 0) {
523
+ if (!indexNames.has(`${usersSchema}.${emailLowerUniqueIndex}`)) {
443
524
  const collisions = await db.execute(sql`
444
525
  SELECT lower(email) AS normalized, count(*)::int AS occurrences
445
526
  FROM ${sql.raw(usersTableName)}
@@ -479,12 +560,12 @@ async function ensureAuthTablesExist(db, collection) {
479
560
  ADD CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320) NOT VALID
480
561
  `);
481
562
  }
482
- if (usersColumnTypes.has("email_verification_token")) await db.execute(sql`
563
+ if (usersColumnTypes.has("email_verification_token")) await ensureIndex(usersSchema, verificationTokenIndex, sql`
483
564
  CREATE INDEX IF NOT EXISTS ${sql.raw(`"${verificationTokenIndex}"`)}
484
565
  ON ${sql.raw(usersTableName)} (email_verification_token)
485
566
  WHERE email_verification_token IS NOT NULL
486
567
  `);
487
- if (usersColumnTypes.has("email_change_token")) await db.execute(sql`
568
+ if (usersColumnTypes.has("email_change_token")) await ensureIndex(usersSchema, emailChangeTokenIndex, sql`
488
569
  CREATE INDEX IF NOT EXISTS ${sql.raw(`"${emailChangeTokenIndex}"`)}
489
570
  ON ${sql.raw(usersTableName)} (email_change_token)
490
571
  WHERE email_change_token IS NOT NULL
@@ -496,34 +577,44 @@ async function ensureAuthTablesExist(db, collection) {
496
577
  WHERE table_name = 'refresh_tokens'
497
578
  `)).rows;
498
579
  logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
580
+ const rtSchemas = found.map((r) => r.table_schema);
581
+ const rtColumnRows = rtSchemas.length === 0 ? [] : (await db.execute(sql.raw(`SELECT table_schema, column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'refresh_tokens' AND table_schema IN (${rtSchemas.map(literal).join(", ")})`))).rows;
582
+ const rtDeviceConstraint = new Set(rtSchemas.length === 0 ? [] : (await db.execute(sql.raw(`SELECT n.nspname AS schema FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace WHERE t.relname = 'refresh_tokens' AND c.conname = 'unique_device_session' AND n.nspname IN (${rtSchemas.map(literal).join(", ")})`))).rows.map((row) => row.schema));
583
+ const rtIndexNames = await readIndexNames(db, rtSchemas);
499
584
  for (const { table_schema } of found) {
500
585
  const qualified = `"${table_schema}"."refresh_tokens"`;
586
+ const columns = new Map(rtColumnRows.filter((row) => row.table_schema === table_schema).map((row) => [row.column_name, row]));
587
+ /** Missing, or present and nullable: a column the back-fill and SET NOT NULL still have to reach. */
588
+ const nullable = (column) => columns.get(column)?.is_nullable !== "NO";
589
+ const addColumn = async (column, statement) => {
590
+ if (!columns.has(column)) await db.execute(statement);
591
+ };
501
592
  try {
502
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
503
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
504
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
505
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
506
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
507
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS method TEXT`);
508
- await db.execute(sql`
509
- UPDATE ${sql.raw(qualified)}
510
- SET session_id = gen_random_uuid()::text
511
- WHERE session_id IS NULL
512
- `);
513
- await db.execute(sql`
514
- UPDATE ${sql.raw(qualified)}
515
- SET session_started_at = COALESCE(created_at, NOW())
516
- WHERE session_started_at IS NULL
517
- `);
518
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
519
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
520
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
521
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
522
- await db.execute(sql`
523
- CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
524
- ON ${sql.raw(qualified)}(session_id)
525
- `);
526
- await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
593
+ await addColumn("session_id", sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
594
+ await addColumn("revoked", sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
595
+ await addColumn("rotated_at", sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
596
+ await addColumn("session_started_at", sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
597
+ await addColumn("aal", sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
598
+ await addColumn("method", sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS method TEXT`);
599
+ if (nullable("session_id")) await db.execute(sql`
600
+ UPDATE ${sql.raw(qualified)}
601
+ SET session_id = gen_random_uuid()::text
602
+ WHERE session_id IS NULL
603
+ `);
604
+ if (nullable("session_started_at")) await db.execute(sql`
605
+ UPDATE ${sql.raw(qualified)}
606
+ SET session_started_at = COALESCE(created_at, NOW())
607
+ WHERE session_started_at IS NULL
608
+ `);
609
+ if (!sameDefault(columns.get("session_id")?.column_default ?? null, "gen_random_uuid()::text")) await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
610
+ if (!sameDefault(columns.get("session_started_at")?.column_default ?? null, "now()")) await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
611
+ if (nullable("session_id")) await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
612
+ if (nullable("session_started_at")) await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
613
+ if (!rtIndexNames.has(`${table_schema}.idx_refresh_tokens_session`)) await db.execute(sql`
614
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
615
+ ON ${sql.raw(qualified)}(session_id)
616
+ `);
617
+ if (rtDeviceConstraint.has(table_schema)) await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
527
618
  logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
528
619
  } catch (perTableError) {
529
620
  logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
@@ -572,7 +663,7 @@ async function ensureAuthTablesExist(db, collection) {
572
663
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
573
664
  )
574
665
  `);
575
- await db.execute(sql`
666
+ await ensureIndex(authSchema, "idx_mfa_factors_user", sql`
576
667
  CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
577
668
  ON ${sql.raw(mfaFactorsTableName)}(uid)
578
669
  `);
@@ -587,13 +678,20 @@ async function ensureAuthTablesExist(db, collection) {
587
678
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL
588
679
  )
589
680
  `);
590
- await db.execute(sql`
681
+ await ensureIndex(authSchema, "idx_mfa_challenges_factor", sql`
591
682
  CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
592
683
  ON ${sql.raw(mfaChallengesTableName)}(factor_id)
593
684
  `);
594
685
  try {
595
- await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
596
- await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
686
+ const mfaColumns = new Set((await db.execute(sql`
687
+ SELECT table_name || '.' || column_name AS key
688
+ FROM information_schema.columns
689
+ WHERE table_schema = ${authSchema}
690
+ AND table_name IN ('mfa_factors', 'mfa_challenges')
691
+ AND column_name IN ('last_used_counter', 'attempts')
692
+ `)).rows.map((row) => row.key));
693
+ if (!mfaColumns.has("mfa_factors.last_used_counter")) await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
694
+ if (!mfaColumns.has("mfa_challenges.attempts")) await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
597
695
  } catch (mfaMigrationError) {
598
696
  logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
599
697
  }
@@ -606,7 +704,7 @@ async function ensureAuthTablesExist(db, collection) {
606
704
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
607
705
  )
608
706
  `);
609
- await db.execute(sql`
707
+ await ensureIndex(authSchema, "idx_recovery_codes_user", sql`
610
708
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
611
709
  ON ${sql.raw(recoveryCodesTableName)}(uid)
612
710
  `);
@@ -623,14 +721,8 @@ async function ensureAuthTablesExist(db, collection) {
623
721
  [authSchema, "recovery_codes"],
624
722
  [authSchema, "schema_meta"]
625
723
  ];
626
- for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
627
- SELECT 1
628
- FROM pg_class c
629
- JOIN pg_namespace n ON n.oid = c.relnamespace
630
- WHERE n.nspname = ${schemaName}
631
- AND c.relname = ${tableName}
632
- AND c.relforcerowsecurity
633
- `)).rows.length > 0) {
724
+ const forced = new Set((await db.execute(sql.raw("SELECT n.nspname AS schema, c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relforcerowsecurity AND (n.nspname, c.relname) IN (" + authTablePairs.map(([schemaName, tableName]) => `(${literal(schemaName)}, ${literal(tableName)})`).join(", ") + ")"))).rows.map((row) => `${row.schema}.${row.name}`));
725
+ for (const [schemaName, tableName] of authTablePairs) if (forced.has(`${schemaName}.${tableName}`)) {
634
726
  await db.execute(sql`
635
727
  ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
636
728
  NO FORCE ROW LEVEL SECURITY
@@ -641,9 +733,13 @@ async function ensureAuthTablesExist(db, collection) {
641
733
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
642
734
  }
643
735
  await stampAuthSchemaVersion(db, authSchema);
644
- await revokeInternalTableAccess(async (text) => {
736
+ const stillGranted = await internalTablesGrantedToUserRole(runRaw, authSchema).catch(() => void 0);
737
+ if (stillGranted === void 0 || stillGranted.length > 0) await revokeInternalTableAccess(async (text) => {
645
738
  await db.execute(sql.raw(text));
646
- }, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
739
+ }, authSchema, {
740
+ tables: stillGranted,
741
+ onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err)))
742
+ });
647
743
  logger.debug("✅ Auth tables ready");
648
744
  } catch (error) {
649
745
  if (error instanceof AuthSchemaVersionError) throw error;
@@ -652,6 +748,6 @@ async function ensureAuthTablesExist(db, collection) {
652
748
  }
653
749
  }
654
750
  //#endregion
655
- export { resolveAuthSchema as i, ensure_tables_exports as n, probeAuthSchema as r, ensureAuthTablesExist as t };
751
+ export { resolveAuthSchema as a, probeAuthSchema as i, ensure_tables_exports as n, assertAuthSchemaCompatible as r, ensureAuthTablesExist as t };
656
752
 
657
- //# sourceMappingURL=ensure-tables-BmI_tRxc.js.map
753
+ //# sourceMappingURL=ensure-tables-CVm_yvzb.js.map