@rebasepro/server-postgres 0.23.0 → 0.24.1-canary.g19596ef

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 (208) hide show
  1. package/README.md +16 -0
  2. package/dist/{BranchService-DGPL6G_C.js → BranchService-UgKa416x.js} +47 -19
  3. package/dist/BranchService-UgKa416x.js.map +1 -0
  4. package/dist/PostgresBackendDriver.d.ts +187 -13
  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 +78 -14
  8. package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
  9. package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
  10. package/dist/backup/backup-cli.d.ts +75 -2
  11. package/dist/backup/backup-service.d.ts +2 -2
  12. package/dist/backup/pg-binary.d.ts +5 -0
  13. package/dist/{backup-cli-24v4OlSp.js → backup-cli-yiKd1GsC.js} +168 -75
  14. package/dist/backup-cli-yiKd1GsC.js.map +1 -0
  15. package/dist/{backup-service-HQ9GC4tN.js → backup-service-AZWc2wvI.js} +104 -35
  16. package/dist/backup-service-AZWc2wvI.js.map +1 -0
  17. package/dist/boot-ddl-B9ZmBJ75.js +105 -0
  18. package/dist/boot-ddl-B9ZmBJ75.js.map +1 -0
  19. package/dist/cli-console-DGee3Zl7.js +225 -0
  20. package/dist/cli-console-DGee3Zl7.js.map +1 -0
  21. package/dist/cli-console.d.ts +39 -0
  22. package/dist/cli-errors-oVUuD8k0.js +2180 -0
  23. package/dist/cli-errors-oVUuD8k0.js.map +1 -0
  24. package/dist/cli-errors.d.ts +133 -9
  25. package/dist/{cli-flags-BglvpjHv.js → cli-flags-DnPHXjRH.js} +56 -6
  26. package/dist/cli-flags-DnPHXjRH.js.map +1 -0
  27. package/dist/cli-flags.d.ts +15 -0
  28. package/dist/cli-helpers.d.ts +88 -34
  29. package/dist/cli-output.d.ts +16 -28
  30. package/dist/{arg-Dni7MzLB.js → cli-prompt-egtNKkZl.js} +49 -3
  31. package/dist/cli-prompt-egtNKkZl.js.map +1 -0
  32. package/dist/cli-prompt.d.ts +19 -0
  33. package/dist/cli-push.d.ts +87 -0
  34. package/dist/cli-scratch-database.d.ts +33 -8
  35. package/dist/cli.js +826 -230
  36. package/dist/cli.js.map +1 -1
  37. package/dist/{column-plan-helpers-DF-8dTVa.js → column-plan-helpers-D_V4oaZh.js} +43 -27
  38. package/dist/column-plan-helpers-D_V4oaZh.js.map +1 -0
  39. package/dist/{databasePoolManager-Bj5FbeAs.js → connection-C938nR3E.js} +25 -113
  40. package/dist/connection-C938nR3E.js.map +1 -0
  41. package/dist/connection.d.ts +13 -2
  42. package/dist/data-transformer.d.ts +0 -8
  43. package/dist/databasePoolManager-DZgnBOgj.js +119 -0
  44. package/dist/databasePoolManager-DZgnBOgj.js.map +1 -0
  45. package/dist/deferred-index-build-BqmEHGaD.js +1051 -0
  46. package/dist/deferred-index-build-BqmEHGaD.js.map +1 -0
  47. package/dist/{doctor-Cb2thZ8s.js → doctor-CUAAOFZm.js} +281 -89
  48. package/dist/doctor-CUAAOFZm.js.map +1 -0
  49. package/dist/ensure-collection-policies-Bb51V2OV.js +541 -0
  50. package/dist/ensure-collection-policies-Bb51V2OV.js.map +1 -0
  51. package/dist/ensure-collection-tables-egjGBjhv.js +532 -0
  52. package/dist/ensure-collection-tables-egjGBjhv.js.map +1 -0
  53. package/dist/{ensure-tables-Dhn9KM3B.js → ensure-tables-DMPWhJ6R.js} +180 -77
  54. package/dist/ensure-tables-DMPWhJ6R.js.map +1 -0
  55. package/dist/enum-labels-kept-Msq5gMcb.js +56 -0
  56. package/dist/enum-labels-kept-Msq5gMcb.js.map +1 -0
  57. package/dist/generate-drizzle-schema-DrA-YgXv.js +57 -0
  58. package/dist/generate-drizzle-schema-DrA-YgXv.js.map +1 -0
  59. package/dist/{generate-drizzle-schema-logic-BfzK7UQd.js → generate-drizzle-schema-logic-BaT0H7Gq.js} +29 -11
  60. package/dist/generate-drizzle-schema-logic-BaT0H7Gq.js.map +1 -0
  61. package/dist/generate-files-OoRJLEdk.js +199 -0
  62. package/dist/generate-files-OoRJLEdk.js.map +1 -0
  63. package/dist/generate-postgres-ddl-logic-B4k9djAV.js +152 -0
  64. package/dist/generate-postgres-ddl-logic-B4k9djAV.js.map +1 -0
  65. package/dist/{generated-schema-staleness-Di9c4ZxN.js → generated-schema-staleness-BWN_PsMi.js} +2 -2
  66. package/dist/{generated-schema-staleness-Di9c4ZxN.js.map → generated-schema-staleness-BWN_PsMi.js.map} +1 -1
  67. package/dist/generated-sql.d.ts +34 -0
  68. package/dist/history/HistoryService.d.ts +64 -5
  69. package/dist/history/ensure-history-table.d.ts +63 -4
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.es.js +139 -16738
  72. package/dist/index.es.js.map +1 -1
  73. package/dist/{introspect-db-logic-WMuAfxvw.js → introspect-db-logic-BCc1Jym2.js} +654 -353
  74. package/dist/introspect-db-logic-BCc1Jym2.js.map +1 -0
  75. package/dist/introspect-db-queries-BhhJg0b8.js +364 -0
  76. package/dist/introspect-db-queries-BhhJg0b8.js.map +1 -0
  77. package/dist/open-rebase-CAjWpPjs.js +20895 -0
  78. package/dist/open-rebase-CAjWpPjs.js.map +1 -0
  79. package/dist/open-rebase.d.ts +94 -0
  80. package/dist/pg-error-utils-Bs_Ue4wn.js +395 -0
  81. package/dist/pg-error-utils-Bs_Ue4wn.js.map +1 -0
  82. package/dist/{plan-schema-C0fxM8dY.js → plan-schema-CsNgK0qi.js} +279 -534
  83. package/dist/plan-schema-CsNgK0qi.js.map +1 -0
  84. package/dist/{policy-drift-DljYdrpW.js → policy-drift-BKC50y2p.js} +33 -9
  85. package/dist/policy-drift-BKC50y2p.js.map +1 -0
  86. package/dist/push-index-prebuild-C_qhyoJa.js +45 -0
  87. package/dist/push-index-prebuild-C_qhyoJa.js.map +1 -0
  88. package/dist/push-legacy-renames-5g9h3HOa.js +34 -0
  89. package/dist/push-legacy-renames-5g9h3HOa.js.map +1 -0
  90. package/dist/{generate-postgres-ddl-logic-Bt2d2mRH.js → render-ddl-CrfEe__B.js} +24 -157
  91. package/dist/render-ddl-CrfEe__B.js.map +1 -0
  92. package/dist/{rls-bootstrap-sql-B8EclDyM.js → rls-bootstrap-sql-Bd-hJk08.js} +125 -1101
  93. package/dist/rls-bootstrap-sql-Bd-hJk08.js.map +1 -0
  94. package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-D7P4-ZYX.js} +138 -18
  95. package/dist/rls-enforcement-D7P4-ZYX.js.map +1 -0
  96. package/dist/{__vite-browser-external-BnuHet1e.js → rolldown-runtime-V7rFbMRe.js} +1 -8
  97. package/dist/schema/atlas-exclude.d.ts +42 -0
  98. package/dist/schema/auth-schema.d.ts +172 -2
  99. package/dist/schema/boot-ddl.d.ts +47 -0
  100. package/dist/schema/carved-out-migration.d.ts +5 -4
  101. package/dist/schema/classify-change.d.ts +29 -1
  102. package/dist/schema/collection-index.d.ts +45 -0
  103. package/dist/schema/column-plan-helpers.d.ts +38 -21
  104. package/dist/schema/deferred-index-build.d.ts +131 -0
  105. package/dist/schema/destructive-sql.d.ts +50 -2
  106. package/dist/schema/doctor-cli.js +110 -6
  107. package/dist/schema/doctor-cli.js.map +1 -1
  108. package/dist/schema/doctor-search-scale.d.ts +45 -0
  109. package/dist/schema/doctor.d.ts +53 -1
  110. package/dist/schema/ensure-collection-policies.d.ts +95 -35
  111. package/dist/schema/ensure-collection-tables.d.ts +56 -3
  112. package/dist/schema/enum-labels-kept.d.ts +26 -0
  113. package/dist/schema/generate-drizzle-schema.js +1 -1
  114. package/dist/schema/generate-files.d.ts +44 -0
  115. package/dist/schema/generate-next-step.d.ts +0 -13
  116. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  117. package/dist/schema/generate-postgres-ddl.js +10 -81
  118. package/dist/schema/generate-postgres-ddl.js.map +1 -1
  119. package/dist/schema/generate-schema-commit.d.ts +12 -0
  120. package/dist/schema/generated-schema-diff.d.ts +7 -0
  121. package/dist/schema/introspect-db-logic.d.ts +63 -0
  122. package/dist/schema/introspect-db-queries.d.ts +21 -2
  123. package/dist/schema/introspect-db-search.d.ts +22 -0
  124. package/dist/schema/introspect-db-storage.d.ts +83 -0
  125. package/dist/schema/introspect-db-structure.d.ts +10 -0
  126. package/dist/schema/introspect-db.js +29 -21
  127. package/dist/schema/introspect-db.js.map +1 -1
  128. package/dist/schema/migrate-baseline.d.ts +33 -0
  129. package/dist/schema/migration-post-process.d.ts +34 -0
  130. package/dist/schema/plan/diff-plan.d.ts +127 -1
  131. package/dist/schema/plan/plan-schema.d.ts +16 -8
  132. package/dist/schema/plan/render-ddl.d.ts +8 -1
  133. package/dist/schema/plan/types.d.ts +45 -11
  134. package/dist/schema/push-index-prebuild.d.ts +25 -0
  135. package/dist/schema/push-legacy-renames.d.ts +20 -0
  136. package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
  137. package/dist/schema/undeclarable-constraints.d.ts +59 -0
  138. package/dist/search-column-BM-GV6vH.js +442 -0
  139. package/dist/search-column-BM-GV6vH.js.map +1 -0
  140. package/dist/security/policy-drift.d.ts +1 -1
  141. package/dist/security/rls-enforcement.d.ts +39 -4
  142. package/dist/services/BranchService.d.ts +29 -1
  143. package/dist/services/FetchService.d.ts +63 -12
  144. package/dist/services/PersistService.d.ts +15 -0
  145. package/dist/services/RelationService.d.ts +106 -3
  146. package/dist/services/cdc/CdcListener.d.ts +26 -10
  147. package/dist/services/cdc/identity-columns.d.ts +19 -0
  148. package/dist/services/cdc/trigger-cdc.d.ts +180 -18
  149. package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
  150. package/dist/services/channel-bus/index.d.ts +1 -1
  151. package/dist/services/channel-history.d.ts +32 -5
  152. package/dist/services/collection-helpers.d.ts +26 -1
  153. package/dist/services/field-viewer.d.ts +15 -1
  154. package/dist/services/pg-notify-listener.d.ts +95 -6
  155. package/dist/services/realtimeService.d.ts +467 -53
  156. package/dist/services/row-pipeline.d.ts +16 -9
  157. package/dist/services/socket-liveness.d.ts +85 -0
  158. package/dist/services/soft-delete.d.ts +12 -0
  159. package/dist/services/sql-script.d.ts +275 -0
  160. package/dist/services/vector-iterative-scan.d.ts +40 -0
  161. package/dist/services/write-depth.d.ts +22 -0
  162. package/dist/services/write-transaction-scope.d.ts +15 -3
  163. package/dist/socket-opt-ins.d.ts +64 -0
  164. package/dist/source-DB6xJv3p.js +873 -0
  165. package/dist/source-DB6xJv3p.js.map +1 -0
  166. package/dist/tsx-loader.d.ts +14 -0
  167. package/dist/utils/connection-string.d.ts +31 -0
  168. package/dist/utils/drizzle-conditions.d.ts +63 -3
  169. package/dist/utils/error-cause.d.ts +7 -0
  170. package/dist/utils/pg-error-utils.d.ts +2 -4
  171. package/dist/utils/result-rows.d.ts +10 -0
  172. package/dist/{vector-index-CMJKb3C9.js → vector-index-BFhyEaiz.js} +2 -2
  173. package/dist/{vector-index-CMJKb3C9.js.map → vector-index-BFhyEaiz.js.map} +1 -1
  174. package/dist/websocket-DUiiwQd2.js +1632 -0
  175. package/dist/websocket-DUiiwQd2.js.map +1 -0
  176. package/dist/websocket.d.ts +63 -18
  177. package/package.json +7 -7
  178. package/dist/BranchService-DGPL6G_C.js.map +0 -1
  179. package/dist/__vite-browser-external-BnuHet1e.js.map +0 -1
  180. package/dist/arg-Dni7MzLB.js.map +0 -1
  181. package/dist/backup-cli-24v4OlSp.js.map +0 -1
  182. package/dist/backup-service-HQ9GC4tN.js.map +0 -1
  183. package/dist/cli-errors-B8qHg02P.js +0 -798
  184. package/dist/cli-errors-B8qHg02P.js.map +0 -1
  185. package/dist/cli-flags-BglvpjHv.js.map +0 -1
  186. package/dist/cli-output-CNdMql-L.js +0 -47
  187. package/dist/cli-output-CNdMql-L.js.map +0 -1
  188. package/dist/collection-index-DdnsxiJ_.js +0 -286
  189. package/dist/collection-index-DdnsxiJ_.js.map +0 -1
  190. package/dist/column-plan-helpers-DF-8dTVa.js.map +0 -1
  191. package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
  192. package/dist/doctor-Cb2thZ8s.js.map +0 -1
  193. package/dist/ensure-collection-policies-RHUcEp4v.js +0 -105
  194. package/dist/ensure-collection-policies-RHUcEp4v.js.map +0 -1
  195. package/dist/ensure-collection-tables-BEEjn5cn.js +0 -857
  196. package/dist/ensure-collection-tables-BEEjn5cn.js.map +0 -1
  197. package/dist/ensure-tables-Dhn9KM3B.js.map +0 -1
  198. package/dist/generate-drizzle-schema-logic-BfzK7UQd.js.map +0 -1
  199. package/dist/generate-drizzle-schema-yzY_BLhr.js +0 -123
  200. package/dist/generate-drizzle-schema-yzY_BLhr.js.map +0 -1
  201. package/dist/generate-postgres-ddl-logic-Bt2d2mRH.js.map +0 -1
  202. package/dist/introspect-db-logic-WMuAfxvw.js.map +0 -1
  203. package/dist/plan-schema-C0fxM8dY.js.map +0 -1
  204. package/dist/policy-drift-DljYdrpW.js.map +0 -1
  205. package/dist/rls-bootstrap-sql-B8EclDyM.js.map +0 -1
  206. package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
  207. package/dist/source-Br7L7GOI.js +0 -410
  208. package/dist/source-Br7L7GOI.js.map +0 -1
@@ -0,0 +1,47 @@
1
+ /** `lock_timeout` for each attempt at a boot DDL statement or batch. See the module comment. */
2
+ export declare const BOOT_LOCK_TIMEOUT_MS = 1000;
3
+ /** Attempts per table, including the first, before a contended table is given up on. */
4
+ export declare const BOOT_LOCK_ATTEMPTS = 3;
5
+ /**
6
+ * The most lock waiting one boot step spends on *retries*, in total.
7
+ *
8
+ * Without a cap, a holder that covers the whole database — a `pg_dump` takes
9
+ * `ACCESS SHARE` on every table it dumps, for as long as it runs — costs
10
+ * tables × attempts × timeout: four minutes at 240 tables. Retrying is for the
11
+ * holder that is about to finish (a report, a transaction), and ten seconds is
12
+ * plenty to wait that out; past it, waiting longer only delays the boot.
13
+ */
14
+ export declare const BOOT_LOCK_RETRY_BUDGET_MS = 10000;
15
+ export interface BootLockOptions {
16
+ /** Per-attempt `lock_timeout`, ms. Default {@link BOOT_LOCK_TIMEOUT_MS}. */
17
+ lockTimeoutMs?: number;
18
+ /** Attempts per table, including the first. Default {@link BOOT_LOCK_ATTEMPTS}. */
19
+ attempts?: number;
20
+ /** Total retry waiting per step, ms. Default {@link BOOT_LOCK_RETRY_BUDGET_MS}. */
21
+ retryBudgetMs?: number;
22
+ /** Pause before retry round `round` (1-based), ms. Default: jittered, growing. */
23
+ backoffMs?: (round: number) => number;
24
+ }
25
+ export interface ResolvedBootLockOptions {
26
+ lockTimeoutMs: number;
27
+ attempts: number;
28
+ retryBudgetMs: number;
29
+ backoffMs: (round: number) => number;
30
+ }
31
+ export declare const resolveBootLockOptions: (options?: BootLockOptions) => ResolvedBootLockOptions;
32
+ /** `55P03 lock_not_available` — what `lock_timeout` raises. */
33
+ export declare const isLockTimeout: (err: unknown) => boolean;
34
+ /**
35
+ * `42601` "cannot insert multiple commands into a prepared statement": the
36
+ * handle runs every query over the extended protocol, which carries exactly one
37
+ * statement. Raised before anything runs, so nothing needs undoing.
38
+ */
39
+ export declare const isMultiStatementRefusal: (err: unknown) => boolean;
40
+ /**
41
+ * `SET LOCAL lock_timeout`, then the statements: one string, one implicit
42
+ * transaction. See the module comment for why this and not `BEGIN … COMMIT`.
43
+ */
44
+ export declare const atomicBatch: (statements: readonly string[], lockTimeoutMs: number) => string;
45
+ /** Statements as one multi-statement string — one implicit transaction — each `;`-terminated. */
46
+ export declare const joinStatements: (statements: readonly string[]) => string;
47
+ export declare const sleep: (ms: number) => Promise<void>;
@@ -49,10 +49,11 @@ export interface StripResult {
49
49
  empty: boolean;
50
50
  }
51
51
  /**
52
- * Split `schema.table.object` patterns. Anything not in three parts is
53
- * ignored rather than guessed at — the two-part form is the one Atlas itself
54
- * silently mis-reads, and repeating that mistake here would be worse than
55
- * doing nothing.
52
+ * Split `schema.table.object` patterns back into the names they were built
53
+ * from (`excludePatternParts`, the inverse of how every exclude is spelled).
54
+ * Anything not in three parts is ignored rather than guessed at — the two-part
55
+ * form is the one Atlas itself silently mis-reads, and repeating that mistake
56
+ * here would be worse than doing nothing.
56
57
  */
57
58
  export declare function parseExcludePatterns(patterns: string[]): CarvedOutObject[];
58
59
  /**
@@ -30,6 +30,23 @@
30
30
  * `diverges` is the whole point of this module: an editor that reports them as
31
31
  * safe is worse than one that refuses them.
32
32
  *
33
+ * ## Read from the plan, not from the properties
34
+ *
35
+ * Whether an edit changes the database is decided by comparing what
36
+ * `planSchema` — the one reading of a `Property` every emitter renders — makes
37
+ * of the collections before and after it: each table's columns, their types,
38
+ * nullability, defaults, uniqueness and foreign keys, the junction tables,
39
+ * indexes and triggers. It used to be decided by a hand-picked list of property
40
+ * fields, which knew nothing of `validation.integer`, of a string becoming an
41
+ * enum, of an array's element type or of anything about a relation beyond its
42
+ * name — and every one of those was reported "safe, no change", committed, and
43
+ * never applied. A difference in the plan cannot be missed that way: a new kind
44
+ * of edit either changes the plan, and is classified here from the difference,
45
+ * or it does not change the database.
46
+ *
47
+ * The property-level reading is still here, for the parts only it can name: a
48
+ * property added or removed, a required flag, an enum's values, a key renamed.
49
+ *
33
50
  * ## Why refusing is the right default for the rest
34
51
  *
35
52
  * Dropping a column, narrowing a type, changing a primary key: each is
@@ -37,7 +54,7 @@
37
54
  * somebody wrote and read. `needs-migration` says exactly that, and naming the
38
55
  * change is more useful than attempting it.
39
56
  */
40
- import type { CollectionConfig, SchemaChange, SchemaChangeKind, SchemaChangeVerdict, ClassifiedSchemaChanges } from "@rebasepro/types";
57
+ import { type CollectionConfig, type SchemaChange, type SchemaChangeKind, type SchemaChangeVerdict, type ClassifiedSchemaChanges } from "@rebasepro/types";
41
58
  /**
42
59
  * The vocabulary lives in `@rebasepro/types` so that `@rebasepro/server`, which
43
60
  * cannot import this package, can still describe a change. Re-exported here
@@ -66,6 +83,10 @@ export interface SchemaFacts {
66
83
  notNullColumns?: Set<string>;
67
84
  /** `schema.typename` → the values that type currently holds. */
68
85
  enumValues?: Map<string, string[]>;
86
+ /** `schema.table.column` → the type Postgres reports for it (`udt_name`). */
87
+ columnTypes?: Map<string, string>;
88
+ /** `schema.table.column` of every column that has a DEFAULT. */
89
+ columnDefaults?: Set<string>;
69
90
  }
70
91
  /**
71
92
  * Classify the difference between two collection sets.
@@ -78,5 +99,12 @@ export interface SchemaFacts {
78
99
  * get the conservative reading.
79
100
  */
80
101
  export declare function classifyCollectionChanges(before: CollectionConfig[], after: CollectionConfig[], facts?: SchemaFacts): ClassifiedChanges;
102
+ /**
103
+ * The statements a default change needs that the additive ensure does not
104
+ * plan: it sets a DEFAULT only on a column that has none, and never changes or
105
+ * drops one. Both are metadata-only — a DEFAULT binds future writes and touches
106
+ * no row — so a reviewed change may make them.
107
+ */
108
+ export declare function defaultChangeStatements(before: CollectionConfig[], after: CollectionConfig[], existingTables: Map<string, Set<string>>): string[];
81
109
  /** A one-line summary, for a log or a refusal message. */
82
110
  export declare function summarizeChanges(classified: ClassifiedChanges): string;
@@ -71,6 +71,16 @@ export interface CollectionIndexSpec {
71
71
  reason: string;
72
72
  /** Derived by {@link deriveIndexName}. Frozen — see the module comment. */
73
73
  indexName: string;
74
+ /**
75
+ * Set when Rebase derived this index for a foreign key rather than anybody
76
+ * declaring it — see {@link foreignKeyIndexSpec}. Never enters the name, so
77
+ * the derived index and a declared one over the same column are one object.
78
+ *
79
+ * Read by exactly one consumer: boot-ensure, which builds these in the
80
+ * background on a table that already exists instead of holding the boot
81
+ * for the build. Every renderer treats them like any other index.
82
+ */
83
+ derived?: "foreign-key";
74
84
  }
75
85
  /**
76
86
  * A declaration that cannot become an index.
@@ -189,3 +199,38 @@ export declare const buildCollectionIndexPlan: (collections: readonly Collection
189
199
  * is a second order the moment either is edited.
190
200
  */
191
201
  export declare const sortIndexSpecs: (specs: readonly CollectionIndexSpec[]) => CollectionIndexSpec[];
202
+ /**
203
+ * The `reason` a derived foreign-key index carries. Never part of the name —
204
+ * see {@link indexFingerprint} — so rewording it renames nothing.
205
+ */
206
+ export declare const FOREIGN_KEY_INDEX_REASON = "Derived for a foreign key: Postgres indexes the key a constraint points at, never the column holding it.";
207
+ /**
208
+ * Whether an index already serves an equality lookup on `column` alone.
209
+ *
210
+ * A btree whose *leading* key is the column does, unique or not — that is the
211
+ * prefix rule, and it is why a composite `(author_id, created_at)` makes a
212
+ * second index on `author_id` pure write amplification. A partial index does
213
+ * not: it serves only the rows its predicate admits, and a foreign-key lookup
214
+ * has to reach every row. Nor does GIN or BRIN, which answer different
215
+ * questions than "the rows whose key is one of these".
216
+ */
217
+ export declare const leadsWithColumn: (spec: Pick<CollectionIndexSpec, "method" | "predicate" | "keys">, column: string) => boolean;
218
+ /**
219
+ * The btree a foreign-key column gets when nothing else already leads with it.
220
+ *
221
+ * Postgres creates the index a foreign key *references* (the target's primary
222
+ * key) and never the one on the column *holding* it. Every read from the other
223
+ * side of the link filters on that column — a `hasMany`/`hasOne` include, a
224
+ * nested listing (`/authors/5/posts`, page and count), the reverse side of a
225
+ * many-to-many — and so does the `ON DELETE` check when a target row goes. With
226
+ * no index each of those is a sequential scan of the child table: measured at
227
+ * 13 ms for 25 parents over 100k children, and about 250 ms per nested listing
228
+ * at a million.
229
+ *
230
+ * Named by {@link deriveIndexName} like every other index, so it is a
231
+ * Rebase-managed name (`_ix_<fingerprint>`), it is in the Atlas diff, and it
232
+ * is byte-for-byte the name an author's own plain index on the column would
233
+ * derive. That identity is what makes the derived index and a declared one the
234
+ * same object rather than two.
235
+ */
236
+ export declare const foreignKeyIndexSpec: (schema: string, table: string, column: string) => CollectionIndexSpec;
@@ -16,9 +16,9 @@
16
16
  * this is where they belong.
17
17
  *
18
18
  * Everything here is pure and synchronous, and it throws rather than guesses:
19
- * an enum with no values, a `cuid` id on Postgres and a second `isId` are all
20
- * configurations that produce a broken database in silence, so they are refused
21
- * where every path passes.
19
+ * an enum with no values, a `cuid` id on Postgres and a foreign key into a
20
+ * composite key are all configurations that produce a broken database in
21
+ * silence, so they are refused where every path passes.
22
22
  */
23
23
  import type { CollectionConfig, Property } from "@rebasepro/types";
24
24
  /**
@@ -29,21 +29,14 @@ import type { CollectionConfig, Property } from "@rebasepro/types";
29
29
  */
30
30
  export declare const resolveColumnName: (propName: string, prop?: Property | null) => string;
31
31
  /**
32
- * Refuse a collection that declares two primary keys.
32
+ * The properties marked `isId`, in declaration order.
33
33
  *
34
- * Postgres allows a composite primary key; Rebase does not model one, and the
35
- * three emitters each invented a different wrong answer for it. The Drizzle
36
- * generator appended `.primaryKey()` to both columns (two primary keys, which
37
- * drizzle-kit turns into an error at push time), the DDL generator wrote two
38
- * inline `PRIMARY KEY` clauses in one `CREATE TABLE` (Postgres: "multiple
39
- * primary keys for table are not allowed"), and boot-ensure created the table
40
- * with the *first* id column and silently never added the second — so a managed
41
- * tenant got a table missing a column its own collection reads.
42
- *
43
- * None of those is recoverable at runtime, and two of them fail long after the
44
- * config was written. Refused here instead, where the collection has a name.
34
+ * Several of them are one composite primary key: the table carries a single
35
+ * `PRIMARY KEY (a, b)` constraint over their columns, and a row's address lists
36
+ * the parts in this same order (`buildCompositeId`), which is the order the
37
+ * admin reads them in too.
45
38
  */
46
- export declare const assertSinglePrimaryKey: (collection: CollectionConfig) => void;
39
+ export declare const primaryKeyPropertyNames: (collection: CollectionConfig) => string[];
47
40
  /**
48
41
  * A collection's primary key, by both of its names.
49
42
  *
@@ -64,11 +57,30 @@ export interface PrimaryKey {
64
57
  /** The declaring property; absent for the implicit `id TEXT` of a collection that declares none. */
65
58
  prop?: Property;
66
59
  }
67
- export declare const getPrimaryKeyProp: (collection: CollectionConfig) => PrimaryKey;
68
- /** The primary key's property key. What a Drizzle relation's `references` names. */
69
- export declare const getPrimaryKeyName: (collection: CollectionConfig) => string;
70
- /** The primary key's column. What a foreign key's `REFERENCES` names. */
71
- export declare const getPrimaryKeyColumn: (collection: CollectionConfig) => string;
60
+ /**
61
+ * The one column a link into this collection points at.
62
+ *
63
+ * A foreign key column references a single column, and Postgres only accepts
64
+ * that column when it is unique on its own — `REFERENCES t (a)` against a
65
+ * composite key `(a, b)` fails with "there is no unique constraint matching
66
+ * given keys for referenced table". So a link into a collection whose key
67
+ * spans several columns has no column to point at, and it is refused here,
68
+ * naming the link, rather than emitted as a constraint `db push` or boot would
69
+ * then fail to create.
70
+ *
71
+ * @param via the link that needs the column, for the refusal: `relation "posts.author"`.
72
+ */
73
+ export declare const getPrimaryKeyProp: (collection: CollectionConfig, via: string) => PrimaryKey;
74
+ /**
75
+ * The primary key's property key. What a Drizzle relation's `references`
76
+ * names. Refused on the same terms as {@link getPrimaryKeyProp}.
77
+ */
78
+ export declare const getPrimaryKeyName: (collection: CollectionConfig, via: string) => string;
79
+ /**
80
+ * The primary key's column. What a foreign key's `REFERENCES` names. Refused
81
+ * on the same terms as {@link getPrimaryKeyProp}.
82
+ */
83
+ export declare const getPrimaryKeyColumn: (collection: CollectionConfig, via: string) => string;
72
84
  export declare const isIdProperty: (propName: string, prop: Property, collection: CollectionConfig) => boolean;
73
85
  /**
74
86
  * The labels an `enum` declares, in order, whichever form it was written in.
@@ -142,3 +154,8 @@ export type IdColumnDefault =
142
154
  * write itself in one line of SQL.
143
155
  */
144
156
  export declare const idColumnDefault: (propName: string, prop: Property, collection: CollectionConfig) => IdColumnDefault | undefined;
157
+ /**
158
+ * The SQL inside a template-form expression (``sql`now()` `` → `now()`); a
159
+ * bare expression is returned as it is.
160
+ */
161
+ export declare const sqlExpressionText: (value: string) => string;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Building the indexes a live table is missing — or holds INVALID — without
3
+ * holding anything up: the derived foreign-key indexes, and the declared,
4
+ * search and vector ones the boot-time ensure finds missing on a table that
5
+ * already exists.
6
+ *
7
+ * ## Why not a boot action like an index on a new table
8
+ *
9
+ * A table that already exists may hold millions of rows, and an index over
10
+ * them takes minutes to build. Boot-ensure's actions run before the server
11
+ * listens, so an index planned there is minutes of a deployment not serving —
12
+ * on the first boot after an upgrade that adds one to every `belongsTo` in the
13
+ * project, that is every large table at once. So these are planned apart
14
+ * (`DeferredIndexBuild`) and built here, after the boot, one at a time. A
15
+ * UNIQUE one is the exception: `ensureCollectionTables` builds it here too, but
16
+ * before it returns.
17
+ *
18
+ * ## The build
19
+ *
20
+ * `CREATE INDEX CONCURRENTLY`, outside any transaction (Postgres refuses it
21
+ * inside one), on a connection of its own with `statement_timeout` lifted: the
22
+ * pool's 30 s would cancel any build worth deferring. `CONCURRENTLY` takes
23
+ * `SHARE UPDATE EXCLUSIVE`, which conflicts with no read and no write — only
24
+ * with other DDL, `VACUUM` and `ANALYZE` — so live traffic never queues behind
25
+ * it, however long it runs.
26
+ *
27
+ * The boot's lock budget still applies, to the one wait that matters: getting
28
+ * the table at all. A table something else holds — another instance's build,
29
+ * an `ALTER TABLE`, a `VACUUM FULL` — is probed for `BOOT_LOCK_TIMEOUT_MS` at a
30
+ * time within the boot's retry budget, and left for the next boot when it
31
+ * stays held. Once the build has the table, it waits for the transactions
32
+ * already running against it with no deadline: a timeout there would only turn
33
+ * a slow build into a failed one.
34
+ *
35
+ * ## The husk a failed build leaves
36
+ *
37
+ * A cancelled `CONCURRENTLY` — a timeout, a killed process, a deploy that
38
+ * replaced the pod mid-build, two instances racing on one name — leaves the
39
+ * index behind marked `indisvalid = false`: maintained on every write, used by
40
+ * no query. And `IF NOT EXISTS` is a *name* check, so every later attempt
41
+ * skips it and reports success. This reads `pg_index` instead: an invalid index
42
+ * under our name is dropped (`DROP INDEX CONCURRENTLY`) and built again, once.
43
+ * One that another backend is still building (`pg_stat_progress_create_index`)
44
+ * is left to it.
45
+ *
46
+ * Nothing here is fatal. A failure is logged with the index named and the
47
+ * reason, and the next boot plans the index again.
48
+ */
49
+ import type { Pool } from "pg";
50
+ import { type DeferredIndexBuild } from "./plan/diff-plan.js";
51
+ import { type BootLockOptions } from "./boot-ddl.js";
52
+ /**
53
+ * A connection the builder may hold for as long as an index takes.
54
+ *
55
+ * `dedicated` says whether consecutive queries reach the same backend. Only
56
+ * then can a session setting be relied on — and be reset afterwards: on a
57
+ * pool, `SET statement_timeout = 0` would land on whichever connection served
58
+ * it and stay there for the next request to inherit.
59
+ */
60
+ export interface IndexBuildSession {
61
+ query<T = unknown>(sql: string): Promise<{
62
+ rows: T[];
63
+ }>;
64
+ readonly dedicated: boolean;
65
+ /** Hand the connection back. Called once, after the last index. */
66
+ release(): void | Promise<void>;
67
+ }
68
+ export type OpenIndexBuildSession = () => Promise<IndexBuildSession>;
69
+ /** A `pg.Pool` — what `connect()` checks a client out of — and not a `pg.Client`, whose `connect()` opens a socket. */
70
+ export declare const isPgPool: (value: unknown) => value is Pick<Pool, "connect" | "totalCount">;
71
+ /**
72
+ * Sessions checked out of a `pg.Pool`, one connection each.
73
+ *
74
+ * Its own connection rather than the pool's next free one, because a build
75
+ * has to outlive both of a pool's timeouts: the server-side
76
+ * `statement_timeout` (30 s on this framework's pools), which the builder
77
+ * lifts for the session — something it may only do on a connection nobody
78
+ * else is handed until it is reset — and the client-side read timeout, which
79
+ * is lifted here per query.
80
+ */
81
+ export declare const poolIndexBuildSession: (pool: Pick<Pool, "connect">) => OpenIndexBuildSession;
82
+ export interface DeferredIndexOptions extends BootLockOptions {
83
+ /** Progress, one line per index. Failures are also `logger.warn`ed. */
84
+ log?: (message: string) => void;
85
+ /** Checked between indexes; once true, nothing further is started. */
86
+ cancelled?: () => boolean;
87
+ }
88
+ export interface DeferredIndexOutcome {
89
+ /** `schema.index` this run built. */
90
+ built: string[];
91
+ /** Left alone, and why — already valid, or another instance is on it. */
92
+ skipped: {
93
+ index: string;
94
+ reason: string;
95
+ }[];
96
+ /** Not built. Logged; the next boot plans them again. */
97
+ failed: {
98
+ index: string;
99
+ error: string;
100
+ }[];
101
+ }
102
+ /** Why an index is skipped while a build of another index holds its table. */
103
+ export declare const TABLE_BUSY_REASON = "another index on the table is being built";
104
+ /**
105
+ * Build every deferred index, one at a time, on one connection.
106
+ *
107
+ * Never throws for an index: each failure is recorded and the next one is
108
+ * attempted. Throws only when no connection can be had at all.
109
+ */
110
+ export declare function buildDeferredIndexes(openSession: OpenIndexBuildSession, indexes: readonly DeferredIndexBuild[], options?: DeferredIndexOptions): Promise<DeferredIndexOutcome>;
111
+ /**
112
+ * Start {@link buildDeferredIndexes} later, in the background.
113
+ *
114
+ * `start()` begins it after `delayMs`; the fallback timer begins it after
115
+ * `fallbackMs` if nothing calls `start()` — so a process whose boot never
116
+ * reaches the step that would have started it still builds. Both timers are
117
+ * unref'd: a pending build never keeps a process alive, and a process that
118
+ * exits mid-build leaves a husk the next boot rebuilds.
119
+ */
120
+ export interface ScheduledIndexBuild {
121
+ /** Begin after the schedule's `delayMs`, instead of waiting for the fallback timer. */
122
+ start(): void;
123
+ /** Start nothing further. An index already building runs to completion. */
124
+ cancel(): void;
125
+ /** Settles when the run ends — `undefined` if it never ran or could not open a connection. */
126
+ done: Promise<DeferredIndexOutcome | undefined>;
127
+ }
128
+ export declare function scheduleDeferredIndexBuild(openSession: OpenIndexBuildSession, indexes: readonly DeferredIndexBuild[], options?: DeferredIndexOptions & {
129
+ delayMs?: number;
130
+ fallbackMs?: number;
131
+ }): ScheduledIndexBuild;
@@ -9,16 +9,27 @@
9
9
  * here, and refuse to auto-approve anything destructive without an explicit
10
10
  * opt-in.
11
11
  *
12
- * A type change is the one destructive plan that names no DROP. `ALTER COLUMN
12
+ * A type change is a destructive plan that names no DROP. `ALTER COLUMN
13
13
  * "price" TYPE integer` rounds every 19.99 to 20, and `TYPE date` throws away
14
14
  * every timestamp's time of day, in a statement that reads like a routine
15
15
  * alter. So a type change is gated too, unless it is a widening every value
16
16
  * survives unchanged — which needs the column's current type, read from the
17
17
  * catalogue by the caller.
18
18
  *
19
+ * A primary key change names no DROP of data either: `DROP CONSTRAINT
20
+ * "<table>_pkey", ADD PRIMARY KEY ("id", "locale")` keeps every value and
21
+ * gives every row a new address. It is gated like a drop.
22
+ *
23
+ * So is the drop of a CHECK or EXCLUDE constraint. It keeps every row and
24
+ * stops refusing the ones the rule was there to refuse. `db push` keeps Atlas
25
+ * away from those constraints (`schema/undeclarable-constraints.ts`), so a plan
26
+ * that still drops one is a pattern that missed, and the gate is the second
27
+ * line. Which constraints those are is read from the catalogue by the caller.
28
+ *
19
29
  * Everything in this file is side-effect free so it can be unit-tested
20
30
  * without Atlas or a database.
21
31
  */
32
+ import type { ExistingConstraint } from "./undeclarable-constraints.js";
22
33
  /**
23
34
  * Split a SQL script into individual statements, dropping blank lines and
24
35
  * `--` comment lines. Deliberately simple: Atlas emits one plain statement
@@ -26,6 +37,19 @@
26
37
  * plan, so a naive split is safe and keeps this dependency-free.
27
38
  */
28
39
  export declare function splitSqlStatements(sql: string): string[];
40
+ /**
41
+ * The statements of an Atlas plan, each exactly once, without Atlas's
42
+ * rendering and without the trailing `;`. See {@link readPlan} for the shape
43
+ * it reads; `test/fixtures/atlas-dry-run/` holds real captures of it.
44
+ */
45
+ export declare function extractPlanStatements(plan: string): string[];
46
+ /**
47
+ * Whether Atlas answered the dry-run with "Schema is synced" and nothing else.
48
+ *
49
+ * Positive evidence only: an empty or unreadable report also yields no
50
+ * statements, and is not the same answer.
51
+ */
52
+ export declare function isSyncedPlan(plan: string): boolean;
29
53
  export interface DestructiveStatement {
30
54
  /** The offending statement (trimmed, without the trailing `;`). */
31
55
  statement: string;
@@ -60,8 +84,21 @@ export declare const COLUMN_TYPES_SQL = "\n SELECT n.nspname AS \"schema\",\n
60
84
  * `columnTypes` is what the database holds now. A type change to a column it
61
85
  * does not list is flagged: not knowing what a column is means not knowing
62
86
  * whether the change keeps its values.
87
+ *
88
+ * `constraints` are the CHECK and EXCLUDE constraints the database holds now.
89
+ * A `DROP CONSTRAINT` of one of them is flagged; of any other constraint (a
90
+ * foreign key, a `UNIQUE`) it is not, as before.
91
+ */
92
+ export declare function detectDestructiveStatements(planSql: string, columnTypes?: readonly ExistingColumnType[], constraints?: readonly ExistingConstraint[]): DestructiveStatement[];
93
+ /**
94
+ * The statement from its `ALTER TABLE` on, wherever on a line that starts.
95
+ *
96
+ * {@link readPlan} hands over clean statements. This is the second line of
97
+ * defence for text it could not clean — a rendering it does not know yet —
98
+ * where a line of prose above the statement must not hide the statement, which
99
+ * is exactly how an anchored match once read Atlas's whole plan as safe.
63
100
  */
64
- export declare function detectDestructiveStatements(planSql: string, columnTypes?: readonly ExistingColumnType[]): DestructiveStatement[];
101
+ export declare function fromAlterTable(statement: string): string;
65
102
  /**
66
103
  * Is `ALTER COLUMN … TYPE to` a widening, one that keeps every value exactly
67
104
  * as it was?
@@ -83,6 +120,17 @@ export declare function detectDestructiveStatements(planSql: string, columnTypes
83
120
  */
84
121
  export declare function isLosslessTypeChange(from: string, to: string): boolean;
85
122
  export type PushDecision = "apply" | "confirm" | "refuse";
123
+ /**
124
+ * The flag that opted a push into destructive changes, as it was given —
125
+ * `--allow-destructive`, or its alias `--yes` / `-y` — or `undefined` for
126
+ * none. The line that says why a destructive push went ahead names the one
127
+ * the user typed: "because --allow-destructive was passed" to someone who
128
+ * typed `--yes` reads like a flag they never set.
129
+ */
130
+ export declare function destructiveOptInFlag(args: {
131
+ "--allow-destructive"?: boolean;
132
+ "--yes"?: boolean;
133
+ }): "--allow-destructive" | "--yes" | undefined;
86
134
  /**
87
135
  * Decide how `db push` should proceed given the plan's destructiveness and
88
136
  * the invocation context.
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire as __createRequire } from "module";
3
3
  __createRequire(import.meta.url);
4
- import { i as diagnoseDbError } from "../cli-errors-B8qHg02P.js";
5
- import { o as validatePolicyPgRoles, s as warnOnAnonymousGrants } from "../rls-enforcement-C6Xk0lA6.js";
6
- import { t as chalk } from "../source-Br7L7GOI.js";
7
- import { n as loadCollections, r as runDoctor } from "../doctor-Cb2thZ8s.js";
8
- import { n as formatPolicyDrift, r as hasDrift, t as checkPolicyDrift } from "../policy-drift-DljYdrpW.js";
4
+ import { a as buildSearchColumnSpec } from "../search-column-BM-GV6vH.js";
5
+ import { i as diagnoseDbError } from "../cli-errors-oVUuD8k0.js";
6
+ import { l as validatePolicyPgRoles, u as warnOnAnonymousGrants } from "../rls-enforcement-D7P4-ZYX.js";
7
+ import { n as describeFailure, t as chalk } from "../source-DB6xJv3p.js";
8
+ import { n as loadCollections, r as runDoctor } from "../doctor-CUAAOFZm.js";
9
+ import { n as formatPolicyDrift, r as hasDrift, t as checkPolicyDrift } from "../policy-drift-BKC50y2p.js";
10
+ import { isPostgresCollectionConfig } from "@rebasepro/types";
11
+ import { getTableName, relationalCollections } from "@rebasepro/common";
9
12
  import path from "path";
10
13
  //#region src/schema/doctor-policy-checks.ts
11
14
  /**
@@ -80,6 +83,106 @@ async function runPolicyChecks(collectionsPath, databaseUrl) {
80
83
  return problems ? "problems" : "ok";
81
84
  }
82
85
  //#endregion
86
+ //#region src/schema/doctor-search-scale.ts
87
+ /**
88
+ * `rebase doctor`: collections searched by a full scan of a large table.
89
+ *
90
+ * Without a `search` block, a search — the admin's search box, `?searchString=`,
91
+ * `.search()` — is `ILIKE '%term%'` over every text column. A leading `%`
92
+ * defeats a B-tree, so each one reads the whole table: on 100k posts a search
93
+ * took 326 ms, half for the page and half for its count. Nothing reports it;
94
+ * the box just gets slower as the table grows. This names the collections
95
+ * where that has started to matter, and points at the block that fixes it.
96
+ *
97
+ * A hint, not a failure: it never changes doctor's exit code, and the database
98
+ * is not changed — Rebase does not add a search index on its own.
99
+ *
100
+ * Lives beside `doctor-cli.ts` rather than inside it because that file runs
101
+ * `main()` on import.
102
+ */
103
+ /** Above this many rows an unindexed search is worth a word. */
104
+ var UNINDEXED_SEARCH_ROWS = 1e4;
105
+ /**
106
+ * The collections a search reaches without an index: no `search` block (or
107
+ * one that does not build), and at least one property the default search
108
+ * reads — a `string`, not an enum, not a uuid key (the rule in
109
+ * `DrizzleConditionBuilder.buildSearchConditions`).
110
+ */
111
+ function ilikeSearchedCollections(collections) {
112
+ const found = [];
113
+ for (const collection of relationalCollections(collections)) {
114
+ if (!isPostgresCollectionConfig(collection)) continue;
115
+ let indexed;
116
+ try {
117
+ indexed = buildSearchColumnSpec(collection) !== void 0;
118
+ } catch {
119
+ indexed = false;
120
+ }
121
+ if (indexed) continue;
122
+ const fields = Object.entries(collection.properties ?? {}).filter(([, property]) => property.type === "string" && !property.enum && property.isId !== "uuid").map(([key]) => key);
123
+ const table = getTableName(collection);
124
+ if (fields.length === 0 || !table) continue;
125
+ found.push({
126
+ slug: collection.slug,
127
+ schema: collection.schema ?? "public",
128
+ table,
129
+ fields
130
+ });
131
+ }
132
+ return found;
133
+ }
134
+ /** Those of `candidates` whose table holds more than `threshold` rows, by estimate. */
135
+ function unindexedSearchFindings(candidates, rowEstimates, threshold = UNINDEXED_SEARCH_ROWS) {
136
+ return candidates.map((candidate) => ({
137
+ ...candidate,
138
+ rows: rowEstimates.get(`${candidate.schema}.${candidate.table}`) ?? 0
139
+ })).filter((candidate) => candidate.rows > threshold);
140
+ }
141
+ /**
142
+ * Row counts by `schema.table`, estimated — `pg_class.reltuples`, or the
143
+ * statistics collector's live-tuple count when the table was never analysed.
144
+ * Nothing is scanned: counting the tables this is about would be the cost it
145
+ * warns of.
146
+ */
147
+ async function estimateRowCounts(query, schemas) {
148
+ const { rows } = await query(`SELECT n.nspname AS schema, c.relname AS table,
149
+ GREATEST(c.reltuples, COALESCE(s.n_live_tup, 0), 0)::bigint AS rows
150
+ FROM pg_class c
151
+ JOIN pg_namespace n ON n.oid = c.relnamespace
152
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
153
+ WHERE c.relkind IN ('r', 'p') AND n.nspname = ANY($1)`, [schemas]);
154
+ return new Map(rows.map((row) => [`${row.schema}.${row.table}`, Number(row.rows)]));
155
+ }
156
+ /** The report, one warning per collection. */
157
+ function formatUnindexedSearches(findings) {
158
+ if (findings.length === 0) return [];
159
+ const lines = ["", chalk.yellow(` ⚠ ${findings.length} collection(s) searched by reading the whole table:`)];
160
+ for (const finding of findings) lines.push(chalk.yellow(` • ${finding.slug} — about ${finding.rows.toLocaleString("en-US")} rows, no \`search\` block: a search is ILIKE '%term%' over ${finding.fields.join(", ")}`));
161
+ lines.push(chalk.gray(" Each search reads every row, and its count reads them again. A `search` block indexes it\n (full-text, or `mode: \"hybrid\"` for substrings too): https://rebase.pro/docs/backend/search"));
162
+ return lines;
163
+ }
164
+ /**
165
+ * Print the warning for the project at `collectionsPath`, against
166
+ * `databaseUrl`. Never throws and never fails the run: it is a hint, and a
167
+ * check that could not run says so in one grey line.
168
+ */
169
+ async function runSearchScaleCheck(collectionsPath, databaseUrl) {
170
+ if (!databaseUrl) return;
171
+ const { Client } = await import("pg");
172
+ const client = new Client({ connectionString: databaseUrl });
173
+ try {
174
+ const candidates = ilikeSearchedCollections(await loadCollections(path.resolve(process.cwd(), collectionsPath)));
175
+ if (candidates.length === 0) return;
176
+ await client.connect();
177
+ const estimates = await estimateRowCounts((text, values) => client.query(text, values), [...new Set(candidates.map((candidate) => candidate.schema))]);
178
+ for (const line of formatUnindexedSearches(unindexedSearchFindings(candidates, estimates))) console.log(line);
179
+ } catch (err) {
180
+ console.log(chalk.gray(` ⏭ Search on large tables: not checked (${err instanceof Error ? err.message : String(err)})`));
181
+ } finally {
182
+ await client.end().catch(() => void 0);
183
+ }
184
+ }
185
+ //#endregion
83
186
  //#region src/schema/doctor-cli.ts
84
187
  /**
85
188
  * CLI entry point for `rebase doctor`.
@@ -111,6 +214,7 @@ async function main() {
111
214
  databaseUrl: databaseUrl ?? void 0
112
215
  });
113
216
  const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);
217
+ await runSearchScaleCheck(collectionsPath, databaseUrl);
114
218
  const blocked = [
115
219
  report.collectionsToSchema,
116
220
  report.schemaToDatabase,
@@ -121,7 +225,7 @@ async function main() {
121
225
  main().catch((err) => {
122
226
  const banner = diagnoseDbError(err, process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING);
123
227
  if (banner) console.error(banner);
124
- else console.error(chalk.red(" ✗ Doctor failed"), err instanceof Error ? err.stack ?? err.message : String(err));
228
+ else console.error(chalk.red(` ✗ Doctor failed: ${describeFailure(err)}`));
125
229
  process.exit(1);
126
230
  });
127
231
  //#endregion