@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
@@ -2,16 +2,13 @@ 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
4
  import { h as searchIndexStatements, n as SEARCH_TEXT_FN, r as SEARCH_UNACCENT_FN, u as searchColumnStamps } from "./search-column-BM-GV6vH.js";
5
- import { d as extractPgError, u as extractCauseMessage } from "./cli-errors-DsA-K9uP.js";
6
- import { s as vectorExtensionHint, u as vectorIndexStatement } from "./vector-index-CMJKb3C9.js";
7
- import { c as sortIndexSpecs, n as collectionIndexStatement } from "./collection-index-DdnsxiJ_.js";
8
- import { a as triggerStatements, i as SET_UPDATED_AT_FN, n as planSchema, r as quoteSqlLiteral } from "./plan-schema-DU9exq6C.js";
9
- import { n as renderEnableRls, r as renderPgType, s as renderPrimaryKeyConstraint, t as renderColumnDefinition } from "./render-ddl-Ds2t_d9V.js";
10
- import { c as planJunctionTables } from "./generate-postgres-ddl-logic-BJsLaVNX.js";
11
- import { r as quoteRelation } from "./introspect-db-queries-C_Q5VgQw.js";
12
- import { isConcurrentDdlRace, isDuplicateObjectRace, logger } from "@rebasepro/server";
13
- import { REBASE_SCHEMA, declaredDatabaseExtensions } from "@rebasepro/types";
14
- import { relationalCollections } from "@rebasepro/common";
5
+ import { n as extractPgError } from "./pg-error-utils-vJezlXdI.js";
6
+ import { u as vectorIndexStatement } from "./vector-index-CMJKb3C9.js";
7
+ import { h as sortIndexSpecs, s as collectionIndexStatement } from "./source-DBDbBTL4.js";
8
+ import { a as triggerStatements, i as SET_UPDATED_AT_FN, r as quoteSqlLiteral } from "./plan-schema-B1YF_tYq.js";
9
+ import { n as renderEnableRls, r as renderPgType, s as renderPrimaryKeyConstraint, t as renderColumnDefinition } from "./render-ddl-CtC3y9Ol.js";
10
+ import { a as isLockTimeout, c as resolveBootLockOptions, l as sleep, o as isMultiStatementRefusal, r as atomicBatch } from "./boot-ddl-DVpsFN6r.js";
11
+ import { logger } from "@rebasepro/server";
15
12
  //#region src/schema/column-type-drift.ts
16
13
  /**
17
14
  * Types that mean the same thing to everything above the driver.
@@ -159,6 +156,68 @@ function assertSafeIdentifier(value, what) {
159
156
  return value;
160
157
  }
161
158
  /**
159
+ * The functions the plan creates with `CREATE OR REPLACE`, which the catalogue
160
+ * reader looks up so a boot that finds them unchanged plans nothing.
161
+ *
162
+ * A helper missing from this list is not wrong, only re-created on every boot,
163
+ * which is what all of them were before.
164
+ */
165
+ var HELPER_FUNCTIONS = [
166
+ SEARCH_TEXT_FN,
167
+ SEARCH_UNACCENT_FN,
168
+ SET_UPDATED_AT_FN
169
+ ];
170
+ var helperFunctionKey = (schema, name, args) => `${schema}.${name}(${args.replace(/\s+/g, " ").trim().toLowerCase()})`;
171
+ var describeHelperFunction = (facts) => JSON.stringify([
172
+ facts.returns.toLowerCase(),
173
+ facts.language.toLowerCase(),
174
+ facts.volatility,
175
+ facts.strict,
176
+ facts.parallel,
177
+ facts.securityDefiner,
178
+ facts.leakproof,
179
+ Number(facts.cost),
180
+ facts.hasConfig,
181
+ facts.body
182
+ ]);
183
+ var HELPER_FUNCTION_RE = /* @__PURE__ */ new RegExp("^\\s*CREATE OR REPLACE FUNCTION\\s+([A-Za-z_]\\w*)\\.([A-Za-z_]\\w*)\\(([^)]*)\\)\\s+RETURNS\\s+([\\w\\[\\]]+)\\s+LANGUAGE\\s+(\\w+)((?:\\s+(?:IMMUTABLE|STABLE|VOLATILE|STRICT|PARALLEL\\s+(?:SAFE|RESTRICTED|UNSAFE)))*)\\s+AS\\s+\\$\\$([\\s\\S]*)\\$\\$;?\\s*$", "i");
184
+ /**
185
+ * What a helper's `CREATE OR REPLACE FUNCTION` statement would leave in the
186
+ * catalogue, or `undefined` for a statement this does not recognise — which is
187
+ * then planned unconditionally, as every statement was before.
188
+ *
189
+ * Only ever given statements this package renders (`searchHelperFunctions`,
190
+ * `setUpdatedAtFunction`), so a strict pattern is the right tool: anything it
191
+ * does not match exactly falls back to the safe behaviour.
192
+ */
193
+ function parseHelperFunction(statement) {
194
+ const match = HELPER_FUNCTION_RE.exec(statement);
195
+ if (!match) return void 0;
196
+ const [, schema, name, args, returns, language, attributes, body] = match;
197
+ const words = attributes.toUpperCase();
198
+ const parallel = /PARALLEL\s+SAFE/.test(words) ? "s" : /PARALLEL\s+RESTRICTED/.test(words) ? "r" : "u";
199
+ const volatility = /\bIMMUTABLE\b/.test(words) ? "i" : /\bSTABLE\b/.test(words) ? "s" : "v";
200
+ return {
201
+ key: helperFunctionKey(schema, name, args),
202
+ definition: describeHelperFunction({
203
+ returns,
204
+ language,
205
+ volatility,
206
+ strict: /\bSTRICT\b/.test(words),
207
+ parallel,
208
+ securityDefiner: false,
209
+ leakproof: false,
210
+ cost: 100,
211
+ hasConfig: false,
212
+ body
213
+ })
214
+ };
215
+ }
216
+ /** The name a rendered `CREATE [UNIQUE] INDEX [CONCURRENTLY] IF NOT EXISTS "<name>"` creates. */
217
+ var indexNameOf = (statement) => /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?IF\s+NOT\s+EXISTS\s+"([^"]+)"/i.exec(statement)?.[1];
218
+ /** The extension a rendered `CREATE EXTENSION IF NOT EXISTS <name> …` installs. */
219
+ var extensionNameOf = (statement) => /^\s*CREATE\s+EXTENSION\s+IF\s+NOT\s+EXISTS\s+"?([\w-]+)"?/i.exec(statement)?.[1];
220
+ /**
162
221
  * Whether a column is part of the table's primary key.
163
222
  *
164
223
  * Those are created with the table, in step 2, and never added afterwards: a
@@ -170,6 +229,12 @@ function assertSafeIdentifier(value, what) {
170
229
  * key would be created twice.
171
230
  */
172
231
  var isKeyColumn = (table, column) => !column.columnOwnedByProperty && table.primaryKey.includes(column.column);
232
+ /**
233
+ * A declared key whose default is the built-in uuid generator: the default
234
+ * `isId: "uuid"` plans. It is the one declared-key default boot sets on an
235
+ * existing table, because it is the one that cannot fail there.
236
+ */
237
+ var isUuidKeyDefault = (column) => column.source.kind === "property" && column.default?.kind === "sql" && column.default.expression === "gen_random_uuid()";
173
238
  /** Which pass a column belongs to. The order below is the order they run in. */
174
239
  var isScalarProperty = (table, column) => (column.source.kind === "property" || column.source.kind === "implicit-id") && !isKeyColumn(table, column);
175
240
  /**
@@ -222,16 +287,24 @@ function diffPlanAgainstCatalogue(plan, existing, options = {}) {
222
287
  sql: `CREATE TYPE "${enumPlan.schema}"."${enumPlan.name}" AS ENUM (${enumPlan.labels.map(quoteSqlLiteral).join(", ")});`
223
288
  });
224
289
  }
225
- for (const statement of plan.extensions) actions.push({
226
- kind: "create-extension",
227
- target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
228
- sql: statement
229
- });
230
- for (const statement of plan.functions) actions.push({
231
- kind: "create-function",
232
- target: functionTarget(statement),
233
- sql: statement
234
- });
290
+ for (const statement of plan.extensions) {
291
+ const extension = extensionNameOf(statement);
292
+ if (extension && existing.extensions?.has(extension)) continue;
293
+ actions.push({
294
+ kind: "create-extension",
295
+ target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
296
+ sql: statement
297
+ });
298
+ }
299
+ for (const statement of plan.functions) {
300
+ const helper = parseHelperFunction(statement);
301
+ if (helper && existing.functions?.get(helper.key) === helper.definition) continue;
302
+ actions.push({
303
+ kind: "create-function",
304
+ target: functionTarget(statement),
305
+ sql: statement
306
+ });
307
+ }
235
308
  const created = /* @__PURE__ */ new Set();
236
309
  for (const table of collectionTables) {
237
310
  if (existing.tables.has(table.qualified) || created.has(table.qualified)) continue;
@@ -418,7 +491,8 @@ function diffPlanAgainstCatalogue(plan, existing, options = {}) {
418
491
  planColumn(table, column);
419
492
  }
420
493
  for (const column of table.columns) {
421
- if (column.source.kind !== "implicit-id" || !column.primaryKey) continue;
494
+ if (!column.primaryKey) continue;
495
+ if (column.source.kind !== "implicit-id" && !isUuidKeyDefault(column)) continue;
422
496
  if (existing.tables.get(table.qualified)?.has(column.column) !== true) continue;
423
497
  planMissingDefault(table, column);
424
498
  }
@@ -494,32 +568,92 @@ function diffPlanAgainstCatalogue(plan, existing, options = {}) {
494
568
  });
495
569
  }
496
570
  const concurrent = (schema, table) => !created.has(`${schema}.${table}`);
571
+ const indexExists = (schema, name) => name !== void 0 && existing.indexes?.has(`${schema}.${name}`) === true;
572
+ const deferredIndexes = [];
573
+ const toBuilder = (schema, table, derived) => concurrent(schema, table) && (derived || options.deferLiveIndexes === true);
574
+ const deferIndex = (build) => {
575
+ const invalid = existing.invalidIndexes?.has(`${build.schema}.${build.indexName}`) === true;
576
+ if (indexExists(build.schema, build.indexName) && !invalid) return;
577
+ deferredIndexes.push({
578
+ ...build,
579
+ invalid
580
+ });
581
+ };
582
+ /** A search or vector statement in its concurrent form, or undefined if it has no plain `CREATE INDEX IF NOT EXISTS`. */
583
+ const concurrentForm = (statement) => {
584
+ const rewritten = statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS");
585
+ return rewritten === statement ? void 0 : rewritten;
586
+ };
497
587
  for (const table of collectionTables) {
498
588
  if (!table.search) continue;
499
- for (const statement of searchIndexStatements(table.search)) actions.push({
500
- kind: "create-index",
501
- target: table.qualified,
502
- sql: concurrent(table.schema, table.table) ? statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : statement
503
- });
589
+ const { schema, table: tableName } = table.search;
590
+ for (const statement of searchIndexStatements(table.search)) {
591
+ const name = indexNameOf(statement);
592
+ const sql = concurrentForm(statement);
593
+ if (name !== void 0 && sql !== void 0 && toBuilder(schema, tableName, false)) {
594
+ deferIndex({
595
+ schema,
596
+ table: tableName,
597
+ indexName: name,
598
+ sql
599
+ });
600
+ continue;
601
+ }
602
+ if (indexExists(table.schema, name)) continue;
603
+ actions.push({
604
+ kind: "create-index",
605
+ target: table.qualified,
606
+ sql: concurrent(table.schema, table.table) ? statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : statement
607
+ });
608
+ }
504
609
  }
505
610
  const vectorIndexSkipped = [];
506
611
  for (const table of collectionTables) {
507
612
  if (!table.vector) continue;
508
- for (const spec of table.vector.specs) actions.push({
613
+ for (const spec of table.vector.specs) {
614
+ const sql = concurrentForm(vectorIndexStatement(spec));
615
+ if (sql !== void 0 && toBuilder(spec.schema, spec.table, false)) {
616
+ deferIndex({
617
+ schema: spec.schema,
618
+ table: spec.table,
619
+ indexName: spec.indexName,
620
+ sql
621
+ });
622
+ continue;
623
+ }
624
+ if (indexExists(spec.schema, spec.indexName)) continue;
625
+ actions.push({
626
+ kind: "create-index",
627
+ target: `${spec.schema}.${spec.table}`,
628
+ sql: concurrent(spec.schema, spec.table) ? vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : vectorIndexStatement(spec)
629
+ });
630
+ }
631
+ vectorIndexSkipped.push(...table.vector.skipped);
632
+ }
633
+ for (const spec of sortIndexSpecs([...collectionTables, ...junctionTables].flatMap((t) => t.indexes))) {
634
+ if (toBuilder(spec.schema, spec.table, spec.derived === "foreign-key")) {
635
+ deferIndex({
636
+ schema: spec.schema,
637
+ table: spec.table,
638
+ indexName: spec.indexName,
639
+ sql: collectionIndexStatement(spec, {
640
+ concurrently: true,
641
+ ifNotExists: true
642
+ }),
643
+ ...spec.unique ? { unique: true } : {}
644
+ });
645
+ continue;
646
+ }
647
+ if (indexExists(spec.schema, spec.indexName)) continue;
648
+ actions.push({
509
649
  kind: "create-index",
510
650
  target: `${spec.schema}.${spec.table}`,
511
- sql: concurrent(spec.schema, spec.table) ? vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : vectorIndexStatement(spec)
651
+ sql: collectionIndexStatement(spec, {
652
+ concurrently: concurrent(spec.schema, spec.table),
653
+ ifNotExists: true
654
+ })
512
655
  });
513
- vectorIndexSkipped.push(...table.vector.skipped);
514
656
  }
515
- for (const spec of sortIndexSpecs(collectionTables.flatMap((t) => t.indexes))) actions.push({
516
- kind: "create-index",
517
- target: `${spec.schema}.${spec.table}`,
518
- sql: collectionIndexStatement(spec, {
519
- concurrently: concurrent(spec.schema, spec.table),
520
- ifNotExists: true
521
- })
522
- });
523
657
  for (const table of collectionTables) for (const trigger of table.triggers) {
524
658
  if (existing.triggers?.has(`${table.qualified}.${trigger.name}`)) continue;
525
659
  for (const statement of triggerStatements(trigger)) actions.push({
@@ -558,7 +692,8 @@ function diffPlanAgainstCatalogue(plan, existing, options = {}) {
558
692
  vectorIndexSkipped,
559
693
  withheldConstraints,
560
694
  columnTypeDrift,
561
- orphanedRequiredColumns
695
+ orphanedRequiredColumns,
696
+ deferredIndexes
562
697
  };
563
698
  }
564
699
  /** What a `CREATE FUNCTION` statement is called, for the action log. */
@@ -567,347 +702,350 @@ var functionTarget = (statement) => {
567
702
  return statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN;
568
703
  };
569
704
  //#endregion
570
- //#region src/schema/ensure-collection-tables.ts
705
+ //#region src/schema/deferred-index-build.ts
706
+ var deferred_index_build_exports = /* @__PURE__ */ __exportAll({
707
+ TABLE_BUSY_REASON: () => TABLE_BUSY_REASON,
708
+ buildDeferredIndexes: () => buildDeferredIndexes,
709
+ isPgPool: () => isPgPool,
710
+ poolIndexBuildSession: () => poolIndexBuildSession,
711
+ scheduleDeferredIndexBuild: () => scheduleDeferredIndexBuild
712
+ });
713
+ /** A client-side read timeout no index build reaches. A pool's is 60 s. */
714
+ var INDEX_BUILD_READ_TIMEOUT_MS = 1440 * 60 * 1e3;
715
+ /** A `pg.Pool` — what `connect()` checks a client out of — and not a `pg.Client`, whose `connect()` opens a socket. */
716
+ var isPgPool = (value) => typeof value === "object" && value !== null && "connect" in value && typeof value.connect === "function" && "totalCount" in value;
571
717
  /**
572
- * Applying a schema plan to a database that is already running.
573
- *
574
- * ## What this module is
575
- *
576
- * A managed runtime boots someone else's compiled project against a database it
577
- * has never seen. Auth tables are ensured at boot already, but collection tables
578
- * were not created by anything: the platform ran the app and every `/api/data/*`
579
- * request answered 500 on a missing relation. `rebase db push` cannot help — it
580
- * is an Atlas-driven CLI command, and the runtime image ships no CLI.
581
- *
582
- * Two halves, and only one of them is here. *What the collections ask for* is
583
- * `schema/plan/plan-schema.ts`, shared with `db push` and the generated Drizzle
584
- * file; *what the database already has, and which of those statements are safe
585
- * to run against it* is `schema/plan/diff-plan.ts`. This module reads the
586
- * catalogue, runs the statements, and reports what happened.
718
+ * Sessions checked out of a `pg.Pool`, one connection each.
587
719
  *
588
- * ## Why additive-only, forever
589
- *
590
- * This runs unattended, against a database with customers' data in it, with no
591
- * human reading a diff. So it may only ever do things that cannot lose data.
592
- * See `diff-plan.ts` for the whole of that argument.
720
+ * Its own connection rather than the pool's next free one, because a build
721
+ * has to outlive both of a pool's timeouts: the server-side
722
+ * `statement_timeout` (30 s on this framework's pools), which the builder
723
+ * lifts for the session — something it may only do on a connection nobody
724
+ * else is handed until it is reset — and the client-side read timeout, which
725
+ * is lifted here per query.
593
726
  */
594
- var ensure_collection_tables_exports = /* @__PURE__ */ __exportAll({
595
- describeDriverError: () => describeDriverError,
596
- ensureCollectionTables: () => ensureCollectionTables,
597
- planCollectionSchemaEnsure: () => planCollectionSchemaEnsure,
598
- readExistingSchema: () => readExistingSchema,
599
- readSchemaFactsFor: () => readSchemaFactsFor
600
- });
601
- /** The schema a collection lives in, validated before it reaches any DDL. */
602
- function schemaOf(collection) {
603
- return assertSafeIdentifier(collection.schema || "public", "schema name");
604
- }
727
+ var poolIndexBuildSession = (pool) => async () => {
728
+ const client = await pool.connect();
729
+ return {
730
+ dedicated: true,
731
+ async query(text) {
732
+ const config = {
733
+ text,
734
+ query_timeout: INDEX_BUILD_READ_TIMEOUT_MS
735
+ };
736
+ return { rows: (await client.query(config)).rows };
737
+ },
738
+ release: () => client.release()
739
+ };
740
+ };
741
+ /** `42P01 undefined_table` — `pg_stat_progress_create_index` before Postgres 12. */
742
+ var isUndefinedTable = (err) => extractPgError(err)?.code === "42P01";
743
+ /** `25001 active_sql_transaction` — `CONCURRENTLY` on a connection inside a transaction block. */
744
+ var isTransactionBlockRefusal = (err) => extractPgError(err)?.code === "25001";
605
745
  /**
606
- * Decide what to add. Pure — the caller supplies what exists and runs the
607
- * result.
608
- *
609
- * A plan and a diff: the collections are read once, by `planSchema`, into the
610
- * same {@link SchemaPlan} `db push` renders `schema.sql` from, and the diff
611
- * subtracts what the database already has. Before that split this function had
612
- * its own reading of every `Property`, and the audit found twelve places where
613
- * it disagreed with the two generators — a required `author_id` that was
614
- * NOT NULL after a push and nullable after a boot among them, on the one path
615
- * with no developer in the loop.
746
+ * The rows themselves violate a UNIQUE index being built: `23505` naming the
747
+ * index (a lost catalogue race names a `pg_` index instead). Building it again
748
+ * would only fail again, so it is not retried.
616
749
  */
617
- function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
618
- return diffPlanAgainstCatalogue(planSchema(allCollections, { databaseExtensions: options.databaseExtensions }), existing, { constraints: options.constraints });
750
+ var isDuplicateRows = (err, index) => {
751
+ const pg = extractPgError(err);
752
+ return pg?.code === "23505" && (pg.constraint === index.indexName || /could not create unique index/i.test(pg.message));
753
+ };
754
+ var CONCURRENTLY_RE = /\sCONCURRENTLY\s/i;
755
+ /** Why an index is skipped while a build of another index holds its table. */
756
+ var TABLE_BUSY_REASON = "another index on the table is being built";
757
+ /**
758
+ * Run a `CONCURRENTLY` statement — or its plain form, when the connection
759
+ * turns out to be inside a transaction block. Only a shared handle can be: an
760
+ * application that builds its own adapter may hand over a connection already
761
+ * in one. The plain form is always accepted and takes a write lock for the
762
+ * build, which is the cost of getting the index at all — the same fallback the
763
+ * ensure's own index actions have.
764
+ */
765
+ async function runConcurrently(session, sql, qualified) {
766
+ try {
767
+ await session.query(sql);
768
+ } catch (err) {
769
+ if (!isTransactionBlockRefusal(err) || !CONCURRENTLY_RE.test(sql)) throw err;
770
+ logger.warn(`[schema] ${qualified}: this connection is inside a transaction, so the index is built with a write lock rather than concurrently. Writes to the table block until it finishes.`);
771
+ await session.query(sql.replace(CONCURRENTLY_RE, " "));
772
+ }
619
773
  }
620
- /** Read what the database has, for the schemas the collections live in. */
621
- async function readExistingSchema(client, schemas) {
622
- const tables = /* @__PURE__ */ new Map();
623
- const enums = /* @__PURE__ */ new Set();
624
- if (schemas.length === 0) return {
625
- tables,
626
- enums
774
+ async function readIndexState(session, index) {
775
+ const schema = quoteSqlLiteral(index.schema);
776
+ const name = quoteSqlLiteral(index.indexName);
777
+ const { rows } = await session.query(`SELECT i.indisvalid AS valid
778
+ FROM pg_index i
779
+ JOIN pg_class c ON c.oid = i.indexrelid
780
+ JOIN pg_namespace n ON n.oid = c.relnamespace
781
+ WHERE n.nspname = ${schema} AND c.relname = ${name}`);
782
+ const valid = rows.length === 0 ? null : rows[0].valid === true;
783
+ if (valid !== false) return {
784
+ valid,
785
+ building: false
627
786
  };
628
- const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
629
- const notNullColumns = /* @__PURE__ */ new Set();
630
- const columnTypes = /* @__PURE__ */ new Map();
631
- const columnDefaults = /* @__PURE__ */ new Set();
632
- const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name, is_nullable, udt_name, column_default,
633
- numeric_precision, numeric_scale
634
- FROM information_schema.columns
635
- WHERE table_schema IN (${inList})`);
636
- for (const row of columns) {
637
- const key = `${row.table_schema}.${row.table_name}`;
638
- if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
639
- tables.get(key).add(row.column_name);
640
- if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
641
- if (row.udt_name) {
642
- const modifier = row.udt_name === "numeric" && row.numeric_precision !== null ? `(${row.numeric_precision},${row.numeric_scale ?? 0})` : "";
643
- columnTypes.set(`${key}.${row.column_name}`, `${row.udt_name}${modifier}`);
644
- }
645
- if (row.column_default !== null) columnDefaults.add(`${key}.${row.column_name}`);
646
- }
647
- const triggers = /* @__PURE__ */ new Set();
648
- const { rows: triggerRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, t.tgname AS name
649
- FROM pg_trigger t
650
- JOIN pg_class c ON t.tgrelid = c.oid
651
- JOIN pg_namespace n ON c.relnamespace = n.oid
652
- WHERE NOT t.tgisinternal AND n.nspname IN (${inList})`);
653
- for (const row of triggerRows) triggers.add(`${row.schema}.${row.table}.${row.name}`);
654
- const populatedTables = /* @__PURE__ */ new Set();
655
- const { rows: realTables } = await client.query(`SELECT n.nspname AS schema, c.relname AS name
656
- FROM pg_class c
657
- JOIN pg_namespace n ON c.relnamespace = n.oid
658
- WHERE c.relkind IN ('r', 'p') AND n.nspname IN (${inList})`);
659
- if (realTables.length > 0) {
660
- const probes = realTables.map((row) => `SELECT ${quoteSqlLiteral(`${row.schema}.${row.name}`)} AS key, EXISTS(SELECT 1 FROM ${quoteRelation(row.schema, row.name)} LIMIT 1) AS populated`);
661
- const { rows: populationRows } = await client.query(probes.join(" UNION ALL "));
662
- for (const row of populationRows) if (row.populated) populatedTables.add(row.key);
663
- }
664
- const enumValues = /* @__PURE__ */ new Map();
665
- const { rows: enumValueRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value
666
- FROM pg_enum e
667
- JOIN pg_type t ON e.enumtypid = t.oid
668
- JOIN pg_namespace n ON t.typnamespace = n.oid
669
- WHERE n.nspname IN (${inList})
670
- ORDER BY t.typname, e.enumsortorder`);
671
- for (const row of enumValueRows) {
672
- const key = `${row.schema}.${row.name}`;
673
- if (!enumValues.has(key)) enumValues.set(key, []);
674
- enumValues.get(key).push(row.value);
675
- }
676
- const { rows: enumRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name
677
- FROM pg_type t
678
- JOIN pg_namespace n ON t.typnamespace = n.oid
679
- WHERE t.typtype = 'e' AND n.nspname IN (${inList})`);
680
- for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
681
- const constraints = /* @__PURE__ */ new Set();
682
- const { rows: constraintRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
683
- FROM pg_constraint con
684
- JOIN pg_class c ON con.conrelid = c.oid
685
- JOIN pg_namespace n ON c.relnamespace = n.oid
686
- WHERE n.nspname IN (${inList})`);
687
- for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
688
- const columnComments = /* @__PURE__ */ new Map();
689
- const { rows: commentRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
690
- FROM pg_description d
691
- JOIN pg_class c ON d.objoid = c.oid
692
- JOIN pg_namespace n ON c.relnamespace = n.oid
693
- JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
694
- WHERE d.objsubid > 0 AND n.nspname IN (${inList})`);
695
- for (const row of commentRows) {
696
- if (row.comment == null) continue;
697
- columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
698
- }
699
787
  return {
700
- tables,
701
- enums,
702
- constraints,
703
- columnComments,
704
- enumValues,
705
- notNullColumns,
706
- populatedTables,
707
- columnTypes,
708
- columnDefaults,
709
- triggers
788
+ valid,
789
+ building: await tableIsBeingIndexed(session, index)
710
790
  };
711
791
  }
712
792
  /**
713
- * What to tell an operator whose `search` block no longer matches its column.
793
+ * Wait, within the boot's lock budget, until the table's lock is free.
714
794
  *
715
- * Every line here is doing work: naming the collection is not enough, because
716
- * the symptom (a search that finds nothing) points at the data, not the schema;
717
- * and the remediation has to be exact, because it is a table rewrite the
718
- * operator is being asked to schedule rather than discover.
795
+ * A probe rather than a `lock_timeout` on the build itself: the build waits on
796
+ * lock *queues* in three phases, and only the first — getting the table — is
797
+ * a wait worth bounding. The other two wait for transactions that were
798
+ * already running, which block nobody while they run and must simply be let
799
+ * finish. `true` when the table was free (or cannot be probed on this
800
+ * handle), `false` when something held it for the whole budget.
719
801
  */
720
- function searchDriftMessage(drift) {
721
- return "The `search` block changed after its generated column was created, and Postgres cannot alter a generated expression in place.\nRebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path may not schedule on your behalf.\nUntil it is rebuilt the column keeps indexing the previous fields, weights and language — searches for anything added since return nothing, which reads from outside as \"no such row\".\nRun these (or revert the block to what the column was built from), then boot again:\n" + drift.map((d) => ` "${d.table}"."${d.column}" was generated from a different \`search\` block (recorded ${d.found}, current ${d.expected}).\n` + d.rebuild.map((s) => ` ${s}`).join("\n")).join("\n") + "\n The GIN index is dropped with the column and recreated concurrently on the next boot.";
802
+ async function tableLockIsFree(session, index, options) {
803
+ const lock = resolveBootLockOptions(options);
804
+ const probe = atomicBatch([`LOCK TABLE "${index.schema}"."${index.table}" IN SHARE UPDATE EXCLUSIVE MODE`], lock.lockTimeoutMs);
805
+ const deadline = Date.now() + (options.retryBudgetMs ?? 1e4);
806
+ for (let round = 1;; round++) try {
807
+ await session.query(probe);
808
+ return true;
809
+ } catch (err) {
810
+ if (isMultiStatementRefusal(err)) return true;
811
+ if (!isLockTimeout(err)) throw err;
812
+ if (round >= lock.attempts || Date.now() >= deadline) return false;
813
+ await sleep(Math.min(lock.backoffMs(round), Math.max(0, deadline - Date.now())));
814
+ }
815
+ }
816
+ /** Whether another backend is running `CREATE INDEX` on the index's table right now. */
817
+ async function tableIsBeingIndexed(session, index) {
818
+ const table = quoteSqlLiteral(`"${index.schema}"."${index.table}"`);
819
+ try {
820
+ const { rows } = await session.query(`SELECT EXISTS (
821
+ SELECT 1 FROM pg_stat_progress_create_index
822
+ WHERE relid = to_regclass(${table}) AND pid <> pg_backend_pid()
823
+ ) AS building`);
824
+ return rows[0]?.building === true;
825
+ } catch (err) {
826
+ if (isUndefinedTable(err)) return false;
827
+ throw err;
828
+ }
722
829
  }
723
830
  /**
724
- * Read what the database looks like, for the schemas a set of collections
725
- * lives in.
831
+ * The advisory-lock key one index is claimed under, across every instance.
726
832
  *
727
- * The same read `ensureCollectionTables` does at boot, exposed on its own for
728
- * the callers that want to *plan* against a real database without changing it —
729
- * the live schema editor, which has to tell somebody what a change would do
730
- * before they agree to it.
833
+ * Without a claim, instances booting together all issue the same
834
+ * `CREATE INDEX CONCURRENTLY`: one wins, the others skip by name — reporting
835
+ * success while the winner is still building — or wait on each other's
836
+ * transactions until Postgres breaks the deadlock by cancelling one, which
837
+ * leaves a husk. With it, one instance builds and the rest step aside.
731
838
  */
732
- async function readSchemaFactsFor(client, collections) {
733
- const relational = relationalCollections(collections);
734
- return readExistingSchema(client, Array.from(/* @__PURE__ */ new Set([...relational.map(schemaOf), ...planJunctionTables(relational).map((junction) => junction.schema)])));
735
- }
839
+ var claimKey = (index) => `hashtext(${quoteSqlLiteral(`rebase_fk_index:${index.schema}.${index.indexName}`)})`;
736
840
  /**
737
- * Bring the database up to date. Returns what it did.
738
- *
739
- * Each statement runs on its own rather than in one transaction: they are all
740
- * independently safe and idempotent, and a single failure (an enum label that
741
- * cannot be added, say) should not roll back the tables that were created fine.
742
- * The error is surfaced with the statement that caused it.
841
+ * Build one index under its claim — session-level, so only on a connection
842
+ * of our own. On a shared handle the claim could be taken on one backend and
843
+ * released on another, so there is none, and concurrent instances fall back
844
+ * on the state checks below.
743
845
  */
744
- async function ensureCollectionTables(client, collections, log, options = {}) {
745
- const schema = planSchema(collections, { databaseExtensions: options.databaseExtensions ?? declaredDatabaseExtensions() });
746
- const schemas = Array.from(new Set(schema.tables.map((table) => table.schema)));
747
- for (const name of schemas) {
748
- assertSafeIdentifier(name, "schema name");
749
- if (name !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${name}";`);
750
- }
751
- if (schema.tables.some((table) => table.triggers.length > 0)) await client.query(`CREATE SCHEMA IF NOT EXISTS "${REBASE_SCHEMA}";`);
752
- const plan = diffPlanAgainstCatalogue(schema, await readExistingSchema(client, schemas), { constraints: options.constraints });
753
- const failures = [];
754
- for (const legacy of plan.legacyForeignKeys) {
755
- const message = `Renaming "${legacy.table}"."${legacy.legacy}" to "${legacy.expected}". The old name is the one Rebase derived for this relation before it singularized properly; the column keeps its data, indexes and constraints. To keep the old name instead, set \`localKey: "${legacy.legacy}"\` on the relation and this will stop.`;
756
- logger.info(`[schema] ${message}`);
757
- log?.(message);
758
- }
759
- if (plan.searchDrift.length > 0) throw new Error(searchDriftMessage(plan.searchDrift));
760
- for (const adopted of plan.searchAdopted) {
761
- const message = `Adopting the existing generated column "${adopted.table}"."${adopted.column}" and recording what the current \`search\` block would generate. Any later change to that block will be detected and refused; a change made *before* this version was deployed cannot be, so if search has been missing content, rebuild the column once: ALTER TABLE "${adopted.table.split(".").join("\".\"")}" DROP COLUMN "${adopted.column}"; and boot again.`;
762
- logger.info(`[schema] ${message}`);
763
- log?.(message);
764
- }
765
- for (const skip of plan.vectorIndexSkipped) {
766
- const message = `No ANN index on "${skip.table}"."${skip.column}": ${skip.reason}`;
767
- logger.warn(`[schema] ${message}`);
768
- log?.(message);
769
- }
770
- for (const withheld of plan.withheldConstraints) {
771
- const message = `No ${withheld.kind === "unique" ? "UNIQUE" : "NOT NULL"} on "${withheld.target}": ${withheld.reason} ${withheld.remedy}`;
772
- logger.warn(`[schema] ${message}`);
773
- log?.(message);
774
- }
775
- for (const drift of plan.columnTypeDrift) {
776
- const message = columnTypeDriftMessage(drift);
777
- logger.warn(`[schema] ${message}`);
778
- log?.(message);
846
+ async function buildClaimed(session, index, options, outcome) {
847
+ if (!session.dedicated) return buildOne(session, index, options, outcome);
848
+ const { rows } = await session.query(`SELECT pg_try_advisory_lock(${claimKey(index)}) AS claimed`);
849
+ if (rows[0]?.claimed !== true) {
850
+ outcome.skipped.push({
851
+ index: `${index.schema}.${index.indexName}`,
852
+ reason: "another instance is building it"
853
+ });
854
+ options.log?.(`${index.schema}.${index.indexName}: another instance is building it — leaving it to that one`);
855
+ return;
779
856
  }
780
- for (const orphan of plan.orphanedRequiredColumns) {
781
- const [schema, table] = orphan.table.split(".");
782
- const message = `"${orphan.table}"."${orphan.column}" is NOT NULL with no default, and the "${orphan.slug}" collection no longer declares it. Every insert will be rejected for a field you cannot set (PG_23502, naming "${orphan.column}"). This is what a renamed property looks like: the new column was added, and the old one cannot be dropped unattended. Fix it with ONE of:
783
- ALTER TABLE "${schema}"."${table}" DROP COLUMN "${orphan.column}"; -- the rename is complete\n ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${orphan.column}" DROP NOT NULL; -- keep the data, stop requiring it\n ...or declare the property again, if the column is still wanted.`;
784
- logger.warn(`[schema] ${message}`);
785
- log?.(message);
857
+ try {
858
+ await buildOne(session, index, options, outcome);
859
+ } finally {
860
+ await session.query(`SELECT pg_advisory_unlock(${claimKey(index)})`);
786
861
  }
787
- if (plan.actions.length === 0) {
788
- log?.("Schema is up to date; nothing to create.");
789
- return {
790
- ...plan,
791
- failures
792
- };
793
- }
794
- for (const action of plan.actions) try {
795
- if (await applyAction(client, action)) log?.(`${action.kind}: ${action.target}`);
796
- else log?.(`${action.kind}: ${action.target} (already created by a peer)`);
797
- } catch (err) {
798
- const message = describeDriverError(err);
799
- if (action.kind === "add-constraint" || action.kind === "comment-column" || action.kind === "create-index" && !/CREATE\s+UNIQUE\s+INDEX/i.test(action.sql)) {
800
- failures.push({
801
- kind: action.kind,
802
- target: action.target,
803
- error: message
862
+ }
863
+ /** One index, from whatever state it is in to valid — or a reason it is not. */
864
+ async function buildOne(session, index, options, outcome) {
865
+ const qualified = `${index.schema}.${index.indexName}`;
866
+ const target = `"${index.schema}"."${index.indexName}"`;
867
+ const say = (message) => options.log?.(message);
868
+ for (let attempt = 1; attempt <= 2; attempt++) {
869
+ const before = await readIndexState(session, index);
870
+ if (before.valid === true) {
871
+ if (attempt === 1) outcome.skipped.push({
872
+ index: qualified,
873
+ reason: "already built"
804
874
  });
805
- continue;
875
+ else outcome.built.push(qualified);
876
+ return;
877
+ }
878
+ if (before.building) {
879
+ outcome.skipped.push({
880
+ index: qualified,
881
+ reason: "another instance is building it"
882
+ });
883
+ say(`${qualified}: another instance is building it — leaving it to that one`);
884
+ return;
885
+ }
886
+ if (!await tableLockIsFree(session, index, options)) {
887
+ if (await tableIsBeingIndexed(session, index)) {
888
+ outcome.skipped.push({
889
+ index: qualified,
890
+ reason: TABLE_BUSY_REASON
891
+ });
892
+ say(`${qualified}: another index on "${index.schema}"."${index.table}" is being built — leaving it for now`);
893
+ return;
894
+ }
895
+ outcome.failed.push({
896
+ index: qualified,
897
+ error: `"${index.schema}"."${index.table}" stayed locked by another session for the whole lock budget (an index build, a VACUUM, an ALTER TABLE). Nothing was changed; the next boot tries again.`
898
+ });
899
+ return;
900
+ }
901
+ if (before.valid === false) {
902
+ say(`${qualified}: an earlier build left it INVALID — dropping it to build it again`);
903
+ await runConcurrently(session, `DROP INDEX CONCURRENTLY IF EXISTS ${target};`, qualified);
904
+ }
905
+ const started = Date.now();
906
+ say(`${qualified}: building on "${index.schema}"."${index.table}"…`);
907
+ try {
908
+ await runConcurrently(session, index.sql, qualified);
909
+ } catch (err) {
910
+ const after = await readIndexState(session, index).catch(() => void 0);
911
+ if (after?.valid === true || after?.building) {
912
+ outcome.skipped.push({
913
+ index: qualified,
914
+ reason: "another instance built it"
915
+ });
916
+ return;
917
+ }
918
+ if (after?.valid === false && attempt === 1 && !isDuplicateRows(err, index)) {
919
+ say(`${qualified}: the build failed (${describe(err)}); retrying once`);
920
+ continue;
921
+ }
922
+ outcome.failed.push({
923
+ index: qualified,
924
+ error: describe(err)
925
+ });
926
+ return;
927
+ }
928
+ const after = await readIndexState(session, index);
929
+ if (after.valid === true) {
930
+ outcome.built.push(qualified);
931
+ say(`${qualified}: built in ${((Date.now() - started) / 1e3).toFixed(1)} s`);
932
+ return;
933
+ }
934
+ if (after.building) {
935
+ outcome.skipped.push({
936
+ index: qualified,
937
+ reason: "another instance is building it"
938
+ });
939
+ return;
940
+ }
941
+ if (attempt === 2) {
942
+ outcome.failed.push({
943
+ index: qualified,
944
+ error: "left INVALID by two builds in a row"
945
+ });
946
+ return;
806
947
  }
807
- throw new Error(`Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`);
808
948
  }
809
- return {
810
- ...plan,
811
- failures
812
- };
813
949
  }
814
- /**
815
- * What actually went wrong, rather than what the ORM said about it.
816
- *
817
- * Drizzle wraps every driver error, and its `message` is literally
818
- * ``Failed query: ${sql}\nparams: ${params}`` — the statement we already know,
819
- * and not one word of Postgres's answer. Reporting `err.message` therefore
820
- * produced a failure that named the statement twice and the *reason* zero
821
- * times, so four unrelated boot failures — a missing extension, a wrong
822
- * privilege, a bad index method, a constraint the rows violate — all read
823
- * identically to whoever had to fix them.
824
- *
825
- * Two consequences, both observed in the field before this existed:
826
- *
827
- * 1. On a managed deployment the reason never reaches anybody. Boot dies, the
828
- * pod never goes ready, and the deploy reports a readiness timeout — so the
829
- * one line that says why is the one line that was thrown away here.
830
- * 2. {@link vectorExtensionHint} matches on the error text. Against the
831
- * wrapper it matched nothing, so the hint written specifically to explain a
832
- * missing pgvector never fired on the path that raises it.
833
- *
834
- * `detail` and `hint` come along because they are the fields Postgres uses to
835
- * say which object it meant and what to do about it — `CREATE EXTENSION` names
836
- * the missing library there, and a rejected constraint names the row. This
837
- * writes into the tenant's own boot log, about the tenant's own database.
838
- */
839
- function describeDriverError(err) {
950
+ var describe = (err) => {
840
951
  const pg = extractPgError(err);
841
- if (pg) {
842
- const parts = [pg.code ? `${pg.message} [${pg.code}]` : pg.message];
843
- if (pg.detail) parts.push(` detail: ${pg.detail}`);
844
- if (pg.hint) parts.push(` hint: ${pg.hint}`);
845
- return parts.join("\n");
846
- }
847
- const cause = extractCauseMessage(err);
848
- if (cause) return cause;
952
+ if (pg) return pg.code ? `${pg.message} [${pg.code}]` : pg.message;
849
953
  return err instanceof Error ? err.message : String(err);
850
- }
851
- /** ` CONCURRENTLY ` as it appears in a rendered `CREATE [UNIQUE] INDEX`. */
852
- var CONCURRENTLY_RE = /\sCONCURRENTLY\s/i;
954
+ };
853
955
  /**
854
- * `25001 active_sql_transaction` — "cannot run inside a transaction block".
956
+ * Build every deferred index, one at a time, on one connection.
855
957
  *
856
- * Only ever raised here by `CREATE INDEX CONCURRENTLY`, and only when the
857
- * connection we were handed is inside a transaction. Deliberately narrow: it
858
- * decides to retry a *different* statement, so it must not fire on anything it
859
- * has not been reasoned about.
958
+ * Never throws for an index: each failure is recorded and the next one is
959
+ * attempted. Throws only when no connection can be had at all.
860
960
  */
861
- function isTransactionBlockRefusal(err) {
862
- return extractPgError(err)?.code === "25001";
863
- }
864
- /** Attempts per action, including the first. Matches the server's bootstraps. */
865
- var DDL_ATTEMPTS = 4;
866
- /**
867
- * Run one planned statement, surviving a simultaneous boot.
868
- *
869
- * Every statement in a plan is written to be idempotent, and that is not the
870
- * same as being safe to run concurrently: `CREATE … IF NOT EXISTS` reads the
871
- * catalog and then writes to it as two steps, so peers starting together both
872
- * see "absent" and the loser gets a duplicate key on a *catalog* index. Measured
873
- * against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is
874
- * worse, because Postgres has no `IF NOT EXISTS` for it at all.
875
- *
876
- * What made that fatal here rather than merely noisy is the loop this sits in.
877
- * A losing statement threw, and the throw abandoned **every remaining action in
878
- * the plan** — so a replica that lost one race came up missing tables it never
879
- * attempted, and the boot log blamed the one statement that failed.
880
- *
881
- * @returns `true` if this process applied the statement, `false` if a peer had
882
- * already created the object. The distinction is only for the log; both mean
883
- * the object is now there.
884
- * @throws the original error for anything that is not a race — a syntax error, a
885
- * permission failure, a unique constraint the customer's own rows violate.
886
- */
887
- async function applyAction(client, action) {
888
- let sqlText = action.sql;
889
- for (let attempt = 1;; attempt++) try {
890
- await client.query(sqlText);
891
- return true;
892
- } catch (err) {
893
- if (isTransactionBlockRefusal(err) && CONCURRENTLY_RE.test(sqlText)) {
894
- sqlText = sqlText.replace(CONCURRENTLY_RE, " ");
895
- logger.warn(`[schema] ${action.kind} ${action.target}: this connection is inside a transaction, so the index is being built with a write lock rather than concurrently. Writes to this table block until it finishes.`);
896
- continue;
897
- }
898
- if (isDuplicateObjectRace(err)) {
899
- logger.debug(`[schema] ${action.kind} ${action.target}: already created by another instance`);
900
- return false;
961
+ async function buildDeferredIndexes(openSession, indexes, options = {}) {
962
+ const outcome = {
963
+ built: [],
964
+ skipped: [],
965
+ failed: []
966
+ };
967
+ if (indexes.length === 0) return outcome;
968
+ for (const index of indexes) {
969
+ assertSafeIdentifier(index.schema, "schema name");
970
+ assertSafeIdentifier(index.table, "table name");
971
+ assertSafeIdentifier(index.indexName, "index name");
972
+ }
973
+ const session = await openSession();
974
+ try {
975
+ if (session.dedicated) await session.query("SET statement_timeout = 0");
976
+ for (const index of indexes) {
977
+ if (options.cancelled?.()) break;
978
+ try {
979
+ await buildClaimed(session, index, options, outcome);
980
+ } catch (err) {
981
+ outcome.failed.push({
982
+ index: `${index.schema}.${index.indexName}`,
983
+ error: describe(err)
984
+ });
985
+ }
901
986
  }
902
- if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {
903
- logger.debug(`[schema] ${action.kind} ${action.target}: lost a race with another instance (attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`);
904
- await new Promise((resolve) => setTimeout(resolve, 40 * attempt * (1 + Math.random())));
905
- continue;
987
+ } finally {
988
+ try {
989
+ if (session.dedicated) await session.query("RESET statement_timeout");
990
+ } finally {
991
+ await session.release();
906
992
  }
907
- throw err;
908
993
  }
994
+ for (const failure of outcome.failed) logger.warn(`[schema] Index ${failure.index} was not built: ${failure.error}. Queries it would serve scan the table until it is; the next boot tries again.`);
995
+ return outcome;
996
+ }
997
+ /** Every deferred build in this process, chained so they run one at a time. */
998
+ var queue = Promise.resolve();
999
+ function scheduleDeferredIndexBuild(openSession, indexes, options = {}) {
1000
+ let cancelled = false;
1001
+ let timer;
1002
+ let resolveDone;
1003
+ const done = new Promise((resolve) => {
1004
+ resolveDone = resolve;
1005
+ });
1006
+ const run = () => {
1007
+ if (timer) clearTimeout(timer);
1008
+ timer = void 0;
1009
+ if (cancelled) {
1010
+ resolveDone(void 0);
1011
+ return;
1012
+ }
1013
+ const job = queue.then(async () => {
1014
+ if (cancelled) return void 0;
1015
+ logger.info(`[schema] Building ${indexes.length} index(es) in the background, one at a time (CREATE INDEX CONCURRENTLY — reads and writes are not blocked).`);
1016
+ return buildDeferredIndexes(openSession, indexes, {
1017
+ ...options,
1018
+ cancelled: () => cancelled
1019
+ });
1020
+ }).catch((err) => {
1021
+ logger.warn(`[schema] Indexes were not built this boot: ${describe(err)}`);
1022
+ });
1023
+ queue = job;
1024
+ job.then(resolveDone);
1025
+ };
1026
+ const arm = (ms) => {
1027
+ if (timer) clearTimeout(timer);
1028
+ timer = setTimeout(run, ms);
1029
+ timer.unref?.();
1030
+ };
1031
+ arm(options.fallbackMs ?? 6e4);
1032
+ return {
1033
+ start: () => {
1034
+ if (cancelled || timer === void 0) return;
1035
+ arm(options.delayMs ?? 0);
1036
+ },
1037
+ cancel: () => {
1038
+ cancelled = true;
1039
+ if (timer) {
1040
+ clearTimeout(timer);
1041
+ timer = void 0;
1042
+ resolveDone(void 0);
1043
+ }
1044
+ },
1045
+ done
1046
+ };
909
1047
  }
910
1048
  //#endregion
911
- export { readSchemaFactsFor as a, readExistingSchema as i, ensure_collection_tables_exports as n, typesAgree as o, planCollectionSchemaEnsure as r, describeDriverError as t };
1049
+ export { assertSafeIdentifier as a, helperFunctionKey as c, HELPER_FUNCTIONS as i, columnTypeDriftMessage as l, buildDeferredIndexes as n, describeHelperFunction as o, deferred_index_build_exports as r, diffPlanAgainstCatalogue as s, TABLE_BUSY_REASON as t, typesAgree as u };
912
1050
 
913
- //# sourceMappingURL=ensure-collection-tables-kkkHk8oo.js.map
1051
+ //# sourceMappingURL=deferred-index-build-DP1_SNFo.js.map