@rebasepro/server-postgres 0.23.0 → 0.24.1-canary.g56af4a4
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.
- package/dist/{BranchService-DGPL6G_C.js → BranchService-Bsn2wfaj.js} +36 -18
- package/dist/BranchService-Bsn2wfaj.js.map +1 -0
- package/dist/PostgresBackendDriver.d.ts +166 -13
- package/dist/auth/ensure-tables.d.ts +12 -0
- package/dist/auth/services.d.ts +78 -14
- package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
- package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
- package/dist/backup/backup-cli.d.ts +14 -0
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/pg-binary.d.ts +5 -0
- package/dist/{backup-cli-24v4OlSp.js → backup-cli-aUfRv16h.js} +37 -29
- package/dist/backup-cli-aUfRv16h.js.map +1 -0
- package/dist/{backup-service-HQ9GC4tN.js → backup-service-BTBB7RXn.js} +60 -28
- package/dist/backup-service-BTBB7RXn.js.map +1 -0
- package/dist/boot-ddl-DVpsFN6r.js +105 -0
- package/dist/boot-ddl-DVpsFN6r.js.map +1 -0
- package/dist/cli-errors-CaI4ntyD.js +1919 -0
- package/dist/cli-errors-CaI4ntyD.js.map +1 -0
- package/dist/cli-errors.d.ts +93 -5
- package/dist/{cli-flags-BglvpjHv.js → cli-flags-B-Dawocl.js} +5 -2
- package/dist/cli-flags-B-Dawocl.js.map +1 -0
- package/dist/cli-helpers.d.ts +52 -12
- package/dist/cli-output.d.ts +16 -28
- package/dist/cli-push.d.ts +87 -0
- package/dist/cli.js +625 -190
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-DF-8dTVa.js → column-plan-helpers-drZIsSNf.js} +42 -26
- package/dist/column-plan-helpers-drZIsSNf.js.map +1 -0
- package/dist/{databasePoolManager-Bj5FbeAs.js → connection-BPoNILnD.js} +19 -110
- package/dist/connection-BPoNILnD.js.map +1 -0
- package/dist/connection.d.ts +9 -1
- package/dist/data-transformer.d.ts +0 -8
- package/dist/databasePoolManager-BkEy2w18.js +119 -0
- package/dist/databasePoolManager-BkEy2w18.js.map +1 -0
- package/dist/deferred-index-build-DP1_SNFo.js +1051 -0
- package/dist/deferred-index-build-DP1_SNFo.js.map +1 -0
- package/dist/{doctor-Cb2thZ8s.js → doctor-C4WiftHm.js} +279 -87
- package/dist/doctor-C4WiftHm.js.map +1 -0
- package/dist/ensure-collection-policies-CXpbeNag.js +541 -0
- package/dist/ensure-collection-policies-CXpbeNag.js.map +1 -0
- package/dist/ensure-collection-tables-BhsU6qUE.js +532 -0
- package/dist/ensure-collection-tables-BhsU6qUE.js.map +1 -0
- package/dist/{ensure-tables-Dhn9KM3B.js → ensure-tables-CHsgyLqg.js} +178 -75
- package/dist/ensure-tables-CHsgyLqg.js.map +1 -0
- package/dist/enum-labels-kept-D8fI1MoX.js +56 -0
- package/dist/enum-labels-kept-D8fI1MoX.js.map +1 -0
- package/dist/generate-drizzle-schema-Bz52jH2h.js +57 -0
- package/dist/generate-drizzle-schema-Bz52jH2h.js.map +1 -0
- package/dist/{generate-drizzle-schema-logic-BfzK7UQd.js → generate-drizzle-schema-logic-B74WN37S.js} +28 -10
- package/dist/generate-drizzle-schema-logic-B74WN37S.js.map +1 -0
- package/dist/generate-files-CdEckX6c.js +199 -0
- package/dist/generate-files-CdEckX6c.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-B64O2oZF.js +152 -0
- package/dist/generate-postgres-ddl-logic-B64O2oZF.js.map +1 -0
- package/dist/generated-sql.d.ts +34 -0
- package/dist/history/HistoryService.d.ts +38 -6
- package/dist/history/ensure-history-table.d.ts +42 -4
- package/dist/index.es.js +5557 -7612
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-WMuAfxvw.js → introspect-db-logic-C-Di8dA9.js} +562 -350
- package/dist/introspect-db-logic-C-Di8dA9.js.map +1 -0
- package/dist/introspect-db-queries-D6kAqReI.js +339 -0
- package/dist/introspect-db-queries-D6kAqReI.js.map +1 -0
- package/dist/pg-error-utils-vJezlXdI.js +388 -0
- package/dist/pg-error-utils-vJezlXdI.js.map +1 -0
- package/dist/{plan-schema-C0fxM8dY.js → plan-schema-B1YF_tYq.js} +278 -533
- package/dist/plan-schema-B1YF_tYq.js.map +1 -0
- package/dist/{policy-drift-DljYdrpW.js → policy-drift-Dm8i-s1J.js} +24 -7
- package/dist/policy-drift-Dm8i-s1J.js.map +1 -0
- package/dist/push-index-prebuild-Db4AQF4i.js +45 -0
- package/dist/push-index-prebuild-Db4AQF4i.js.map +1 -0
- package/dist/push-legacy-renames-BK6ilQq2.js +34 -0
- package/dist/push-legacy-renames-BK6ilQq2.js.map +1 -0
- package/dist/{generate-postgres-ddl-logic-Bt2d2mRH.js → render-ddl-CtC3y9Ol.js} +17 -151
- package/dist/render-ddl-CtC3y9Ol.js.map +1 -0
- package/dist/{rls-bootstrap-sql-B8EclDyM.js → rls-bootstrap-sql-RQUljnJ1.js} +125 -1046
- package/dist/rls-bootstrap-sql-RQUljnJ1.js.map +1 -0
- package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-DvzqlaL1.js} +137 -17
- package/dist/rls-enforcement-DvzqlaL1.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +172 -2
- package/dist/schema/boot-ddl.d.ts +47 -0
- package/dist/schema/classify-change.d.ts +29 -1
- package/dist/schema/collection-index.d.ts +45 -0
- package/dist/schema/column-plan-helpers.d.ts +38 -21
- package/dist/schema/deferred-index-build.d.ts +131 -0
- package/dist/schema/destructive-sql.d.ts +27 -1
- package/dist/schema/doctor-cli.js +110 -6
- package/dist/schema/doctor-cli.js.map +1 -1
- package/dist/schema/doctor-search-scale.d.ts +45 -0
- package/dist/schema/doctor.d.ts +53 -1
- package/dist/schema/ensure-collection-policies.d.ts +95 -35
- package/dist/schema/ensure-collection-tables.d.ts +56 -3
- package/dist/schema/enum-labels-kept.d.ts +26 -0
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-files.d.ts +44 -0
- package/dist/schema/generate-next-step.d.ts +0 -13
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
- package/dist/schema/generate-postgres-ddl.js +10 -81
- package/dist/schema/generate-postgres-ddl.js.map +1 -1
- package/dist/schema/generate-schema-commit.d.ts +12 -0
- package/dist/schema/introspect-db-logic.d.ts +43 -0
- package/dist/schema/introspect-db-queries.d.ts +12 -2
- package/dist/schema/introspect-db-search.d.ts +22 -0
- package/dist/schema/introspect-db-storage.d.ts +83 -0
- package/dist/schema/introspect-db-structure.d.ts +10 -0
- package/dist/schema/introspect-db.js +17 -6
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/migration-post-process.d.ts +34 -0
- package/dist/schema/plan/diff-plan.d.ts +127 -1
- package/dist/schema/plan/plan-schema.d.ts +16 -8
- package/dist/schema/plan/render-ddl.d.ts +6 -0
- package/dist/schema/plan/types.d.ts +45 -11
- package/dist/schema/push-index-prebuild.d.ts +25 -0
- package/dist/schema/push-legacy-renames.d.ts +20 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
- package/dist/search-column-BM-GV6vH.js +442 -0
- package/dist/search-column-BM-GV6vH.js.map +1 -0
- package/dist/security/policy-drift.d.ts +1 -1
- package/dist/security/rls-enforcement.d.ts +39 -4
- package/dist/services/BranchService.d.ts +22 -1
- package/dist/services/FetchService.d.ts +63 -12
- package/dist/services/PersistService.d.ts +15 -0
- package/dist/services/RelationService.d.ts +106 -3
- package/dist/services/cdc/CdcListener.d.ts +26 -10
- package/dist/services/cdc/identity-columns.d.ts +19 -0
- package/dist/services/cdc/trigger-cdc.d.ts +180 -18
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
- package/dist/services/channel-history.d.ts +32 -5
- package/dist/services/collection-helpers.d.ts +26 -1
- package/dist/services/field-viewer.d.ts +15 -1
- package/dist/services/pg-notify-listener.d.ts +95 -6
- package/dist/services/realtimeService.d.ts +467 -53
- package/dist/services/row-pipeline.d.ts +16 -9
- package/dist/services/socket-liveness.d.ts +85 -0
- package/dist/services/soft-delete.d.ts +12 -0
- package/dist/services/sql-script.d.ts +275 -0
- package/dist/services/vector-iterative-scan.d.ts +40 -0
- package/dist/services/write-depth.d.ts +22 -0
- package/dist/services/write-transaction-scope.d.ts +15 -3
- package/dist/socket-liveness-D3UD5_BW.js +7570 -0
- package/dist/socket-liveness-D3UD5_BW.js.map +1 -0
- package/dist/socket-opt-ins.d.ts +64 -0
- package/dist/source-DBDbBTL4.js +811 -0
- package/dist/source-DBDbBTL4.js.map +1 -0
- package/dist/tsx-loader.d.ts +14 -0
- package/dist/utils/drizzle-conditions.d.ts +63 -3
- package/dist/utils/error-cause.d.ts +7 -0
- package/dist/utils/pg-error-utils.d.ts +2 -4
- package/dist/utils/result-rows.d.ts +10 -0
- package/dist/websocket.d.ts +63 -18
- package/package.json +7 -7
- package/dist/BranchService-DGPL6G_C.js.map +0 -1
- package/dist/backup-cli-24v4OlSp.js.map +0 -1
- package/dist/backup-service-HQ9GC4tN.js.map +0 -1
- package/dist/cli-errors-B8qHg02P.js +0 -798
- package/dist/cli-errors-B8qHg02P.js.map +0 -1
- package/dist/cli-flags-BglvpjHv.js.map +0 -1
- package/dist/cli-output-CNdMql-L.js +0 -47
- package/dist/cli-output-CNdMql-L.js.map +0 -1
- package/dist/collection-index-DdnsxiJ_.js +0 -286
- package/dist/collection-index-DdnsxiJ_.js.map +0 -1
- package/dist/column-plan-helpers-DF-8dTVa.js.map +0 -1
- package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
- package/dist/doctor-Cb2thZ8s.js.map +0 -1
- package/dist/ensure-collection-policies-RHUcEp4v.js +0 -105
- package/dist/ensure-collection-policies-RHUcEp4v.js.map +0 -1
- package/dist/ensure-collection-tables-BEEjn5cn.js +0 -857
- package/dist/ensure-collection-tables-BEEjn5cn.js.map +0 -1
- package/dist/ensure-tables-Dhn9KM3B.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-BfzK7UQd.js.map +0 -1
- package/dist/generate-drizzle-schema-yzY_BLhr.js +0 -123
- package/dist/generate-drizzle-schema-yzY_BLhr.js.map +0 -1
- package/dist/generate-postgres-ddl-logic-Bt2d2mRH.js.map +0 -1
- package/dist/introspect-db-logic-WMuAfxvw.js.map +0 -1
- package/dist/plan-schema-C0fxM8dY.js.map +0 -1
- package/dist/policy-drift-DljYdrpW.js.map +0 -1
- package/dist/rls-bootstrap-sql-B8EclDyM.js.map +0 -1
- package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
- package/dist/source-Br7L7GOI.js +0 -410
- package/dist/source-Br7L7GOI.js.map +0 -1
|
@@ -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
|
|
20
|
-
* configurations that produce a broken database in
|
|
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
|
-
*
|
|
32
|
+
* The properties marked `isId`, in declaration order.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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,13 +9,17 @@
|
|
|
9
9
|
* here, and refuse to auto-approve anything destructive without an explicit
|
|
10
10
|
* opt-in.
|
|
11
11
|
*
|
|
12
|
-
* A type change is
|
|
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
|
+
*
|
|
19
23
|
* Everything in this file is side-effect free so it can be unit-tested
|
|
20
24
|
* without Atlas or a database.
|
|
21
25
|
*/
|
|
@@ -26,6 +30,19 @@
|
|
|
26
30
|
* plan, so a naive split is safe and keeps this dependency-free.
|
|
27
31
|
*/
|
|
28
32
|
export declare function splitSqlStatements(sql: string): string[];
|
|
33
|
+
/**
|
|
34
|
+
* The statements of an Atlas plan, each exactly once, without Atlas's
|
|
35
|
+
* rendering and without the trailing `;`. See {@link readPlan} for the shape
|
|
36
|
+
* it reads; `test/fixtures/atlas-dry-run/` holds real captures of it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function extractPlanStatements(plan: string): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Whether Atlas answered the dry-run with "Schema is synced" and nothing else.
|
|
41
|
+
*
|
|
42
|
+
* Positive evidence only: an empty or unreadable report also yields no
|
|
43
|
+
* statements, and is not the same answer.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isSyncedPlan(plan: string): boolean;
|
|
29
46
|
export interface DestructiveStatement {
|
|
30
47
|
/** The offending statement (trimmed, without the trailing `;`). */
|
|
31
48
|
statement: string;
|
|
@@ -62,6 +79,15 @@ export declare const COLUMN_TYPES_SQL = "\n SELECT n.nspname AS \"schema\",\n
|
|
|
62
79
|
* whether the change keeps its values.
|
|
63
80
|
*/
|
|
64
81
|
export declare function detectDestructiveStatements(planSql: string, columnTypes?: readonly ExistingColumnType[]): DestructiveStatement[];
|
|
82
|
+
/**
|
|
83
|
+
* The statement from its `ALTER TABLE` on, wherever on a line that starts.
|
|
84
|
+
*
|
|
85
|
+
* {@link readPlan} hands over clean statements. This is the second line of
|
|
86
|
+
* defence for text it could not clean — a rendering it does not know yet —
|
|
87
|
+
* where a line of prose above the statement must not hide the statement, which
|
|
88
|
+
* is exactly how an anchored match once read Atlas's whole plan as safe.
|
|
89
|
+
*/
|
|
90
|
+
export declare function fromAlterTable(statement: string): string;
|
|
65
91
|
/**
|
|
66
92
|
* Is `ALTER COLUMN … TYPE to` a widening, one that keeps every value exactly
|
|
67
93
|
* as it was?
|
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { n as
|
|
8
|
-
import { n as
|
|
4
|
+
import { a as buildSearchColumnSpec } from "../search-column-BM-GV6vH.js";
|
|
5
|
+
import { i as diagnoseDbError } from "../cli-errors-CaI4ntyD.js";
|
|
6
|
+
import { l as validatePolicyPgRoles, u as warnOnAnonymousGrants } from "../rls-enforcement-DvzqlaL1.js";
|
|
7
|
+
import { n as describeFailure, t as chalk } from "../source-DBDbBTL4.js";
|
|
8
|
+
import { n as loadCollections, r as runDoctor } from "../doctor-C4WiftHm.js";
|
|
9
|
+
import { n as formatPolicyDrift, r as hasDrift, t as checkPolicyDrift } from "../policy-drift-Dm8i-s1J.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(
|
|
228
|
+
else console.error(chalk.red(` ✗ Doctor failed: ${describeFailure(err)}`));
|
|
125
229
|
process.exit(1);
|
|
126
230
|
});
|
|
127
231
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"doctor-cli.js","names":[],"sources":["../../src/schema/doctor-policy-checks.ts","../../src/schema/doctor-cli.ts"],"sourcesContent":["/**\n * The RLS half of `rebase doctor`, and the exit code `--policies` gates CI on.\n *\n * Lives beside `doctor-cli.ts` rather than inside it because that file runs\n * `main()` on import: a gate whose failure modes cannot be unit-tested is how\n * this one shipped reporting success for work it never did.\n */\nimport path from \"path\";\nimport chalk from \"chalk\";\nimport { CollectionConfig } from \"@rebasepro/types\";\nimport { loadCollections } from \"./doctor\";\nimport { checkPolicyDrift, formatPolicyDrift, hasDrift } from \"../security/policy-drift\";\nimport { validatePolicyPgRoles, warnOnAnonymousGrants } from \"../security/rls-enforcement\";\n\n/**\n * What the RLS checks concluded.\n *\n * `unchecked` exists because \"we could not look\" and \"we looked and it is fine\"\n * used to be reported identically: a collections path that did not resolve made\n * the loader return `[]` (it warns, it does not throw), `checkPolicyDrift`\n * early-returned an empty diff, and the gate printed\n * `✓ RLS policies match your collections` having compared zero policies against\n * zero collections. Any exception at all — a collection file that throws on\n * import, a `pg_policies` read the CI role is not granted, a connection reset —\n * did the same thing through a `warn`, and exited 0.\n */\nexport type PolicyCheckStatus = \"ok\" | \"problems\" | \"unchecked\";\n\n/**\n * The exit code for `rebase doctor --policies`.\n *\n * A gate that could not run has not passed. Only a completed, clean check\n * exits 0 — anything else, including \"we never opened a connection\", is a\n * failure, or the flag certifies a database nobody looked at.\n */\nexport function exitCodeForPolicyGate(status: PolicyCheckStatus): 0 | 1 {\n return status === \"ok\" ? 0 : 1;\n}\n\n/**\n * Policies actually deployed vs the ones the collections describe, plus policy\n * roles this server could never satisfy.\n *\n * Never reports `ok` for work it did not do — see {@link PolicyCheckStatus}.\n */\nexport async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<PolicyCheckStatus> {\n if (!databaseUrl) {\n console.error(chalk.yellow(\" ⚠ No DATABASE_URL — RLS policies were NOT checked\"));\n return \"unchecked\";\n }\n\n const resolvedPath = path.resolve(process.cwd(), collectionsPath);\n let problems = false;\n const { Pool } = await import(\"pg\");\n const pool = new Pool({ connectionString: databaseUrl });\n try {\n const collections: CollectionConfig[] = await loadCollections(resolvedPath);\n\n // Zero collections is not \"no drift\", it is nothing to compare. The\n // loader returns `[]` for a path that does not exist, so the commonest\n // way to get here is a `--collections` path resolved against the wrong\n // directory — which used to render as a green tick.\n if (collections.length === 0) {\n console.error(chalk.red(` ✗ No collections found in ${resolvedPath}`));\n console.error(chalk.gray(\" RLS policies were NOT checked. Pass --collections=<dir> if your collections live elsewhere.\"));\n return \"unchecked\";\n }\n\n const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];\n\n // Prove the connection before anything is ticked.\n //\n // `validatePolicyPgRoles` returns without a query when no collection\n // names a `pgRoles` — which is most projects — so it \"passed\" against a\n // database that was never reached, and the tick printed above an\n // ECONNREFUSED that only surfaced two checks later. Ten lines up, this\n // file says that reporting success for work it never did is the reason\n // it exists. This is the same shape, one function along.\n try {\n await runSql(\"SELECT 1\");\n } catch (err) {\n console.error(chalk.gray(\" ⏭ Policy roles: not checked\"));\n console.error(chalk.red(\" ✗ Could not check RLS policies:\"), err instanceof Error ? err.message : String(err));\n return \"unchecked\";\n }\n\n // A policy naming a role the server never runs as filters every row, so\n // the collection reads as empty. Report it without booting a server.\n try {\n await validatePolicyPgRoles(runSql, collections as never);\n console.log(chalk.green(\" ✓ Policy roles are usable by this server\"));\n } catch (err) {\n problems = true;\n console.log(\"\");\n console.error(chalk.red(err instanceof Error ? err.message : String(err)));\n }\n\n // A rule that reads as a lockdown but is true for every caller compiles\n // to a grant. Report it without booting a server.\n warnOnAnonymousGrants(collections as never);\n\n const drift = await checkPolicyDrift(pool as never, collections);\n console.log(\"\");\n if (hasDrift(drift)) {\n problems = true;\n console.log(chalk.yellow(\" RLS policies: database does not match your collections\"));\n console.log(formatPolicyDrift(drift));\n } else {\n console.log(chalk.green(` ✓ RLS policies match your collections (${collections.length} collection(s) checked)`));\n }\n } catch (err) {\n // Fail closed. This catch covers every query and every collection import\n // above, so returning the pre-catch verdict made the documented CI gate\n // green on exactly the runs it exists to catch.\n console.error(chalk.red(\" ✗ Could not check RLS policies:\"), err instanceof Error ? err.message : String(err));\n return \"unchecked\";\n } finally {\n await pool.end();\n }\n return problems ? \"problems\" : \"ok\";\n}\n","#!/usr/bin/env node\n/**\n * CLI entry point for `rebase doctor`.\n * Invoked via tsx by the server-postgres CLI plugin.\n */\nimport path from \"path\";\nimport chalk from \"chalk\";\nimport { runDoctor } from \"./doctor\";\nimport { exitCodeForPolicyGate, runPolicyChecks } from \"./doctor-policy-checks\";\nimport { diagnoseDbError } from \"../cli-errors\";\n\nasync function main() {\n const collectionsArg = process.argv.find((a) => a.startsWith(\"--collections=\"));\n const schemaArg = process.argv.find((a) => a.startsWith(\"--schema=\"));\n const sdkArg = process.argv.find((a) => a.startsWith(\"--sdk=\"));\n // --policies runs only the RLS checks: no schema diff, no SDK types. Useful\n // as a CI gate against a deployed database, where the collection files and\n // generated schema are not what you are asking about.\n const policiesOnly = process.argv.includes(\"--policies\");\n\n const collectionsPath = collectionsArg?.split(\"=\")[1] ?? path.join(\"..\", \"config\", \"collections\");\n const schemaPath = schemaArg?.split(\"=\")[1] ?? path.join(\"src\", \"schema.generated.ts\");\n const sdkPath = sdkArg?.split(\"=\")[1] ?? path.join(\"..\", \"generated\", \"sdk\", \"database.types.ts\");\n\n // Load .env\n try {\n const dotenv = await import(\"dotenv\");\n const envPath = process.env.DOTENV_CONFIG_PATH;\n if (envPath) {\n dotenv.config({ path: envPath, quiet: true });\n } else {\n dotenv.config({ quiet: true });\n }\n } catch {\n // dotenv may not be installed\n }\n\n const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;\n\n if (policiesOnly) {\n // Non-zero so this can gate CI — the whole point of the flag, and a\n // check that could not run has not passed. See exitCodeForPolicyGate.\n process.exit(exitCodeForPolicyGate(await runPolicyChecks(collectionsPath, databaseUrl)));\n }\n\n const report = await runDoctor({\n collectionsPath: path.resolve(process.cwd(), collectionsPath),\n schemaPath: path.resolve(process.cwd(), schemaPath),\n sdkPath: path.resolve(process.cwd(), sdkPath),\n databaseUrl: databaseUrl ?? undefined\n });\n\n const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);\n\n // Exit non-zero if there are errors. A policy run that could not happen is\n // reported loudly above but does not fail the interactive command — the\n // same treatment the *chosen* skip of the database phase gets. `--policies`\n // is the gate, and that one fails closed.\n //\n // A phase that was skipped by a fault is different, and `blocked` is what\n // separates the two: \"no DATABASE_URL\" is a local situation somebody may be\n // content with, while \"the DATABASE_URL you set refuses connections\" would\n // otherwise let `rebase doctor` go green in CI against a database it never\n // reached.\n const blocked = [report.collectionsToSchema, report.schemaToDatabase, report.collectionsToSdk]\n .some(phase => phase.blocked);\n if (report.summary.errors > 0 || policyStatus === \"problems\" || blocked) {\n process.exit(1);\n }\n}\n\nmain().catch((err) => {\n // A known database failure has a diagnosis; a stack trace through pg and\n // drizzle internals is not it. This is the last resort — `runDoctor`\n // already catches the connection failure it can name — so it exists for\n // everything reached after the report, the policy checks in particular.\n const banner = diagnoseDbError(err, process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING);\n if (banner) {\n console.error(banner);\n } else {\n console.error(chalk.red(\" ✗ Doctor failed\"), err instanceof Error ? (err.stack ?? err.message) : String(err));\n }\n process.exit(1);\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"doctor-cli.js","names":[],"sources":["../../src/schema/doctor-policy-checks.ts","../../src/schema/doctor-search-scale.ts","../../src/schema/doctor-cli.ts"],"sourcesContent":["/**\n * The RLS half of `rebase doctor`, and the exit code `--policies` gates CI on.\n *\n * Lives beside `doctor-cli.ts` rather than inside it because that file runs\n * `main()` on import: a gate whose failure modes cannot be unit-tested is how\n * this one shipped reporting success for work it never did.\n */\nimport path from \"path\";\nimport chalk from \"chalk\";\nimport { CollectionConfig } from \"@rebasepro/types\";\nimport { loadCollections } from \"./doctor\";\nimport { checkPolicyDrift, formatPolicyDrift, hasDrift } from \"../security/policy-drift\";\nimport { validatePolicyPgRoles, warnOnAnonymousGrants } from \"../security/rls-enforcement\";\n\n/**\n * What the RLS checks concluded.\n *\n * `unchecked` exists because \"we could not look\" and \"we looked and it is fine\"\n * used to be reported identically: a collections path that did not resolve made\n * the loader return `[]` (it warns, it does not throw), `checkPolicyDrift`\n * early-returned an empty diff, and the gate printed\n * `✓ RLS policies match your collections` having compared zero policies against\n * zero collections. Any exception at all — a collection file that throws on\n * import, a `pg_policies` read the CI role is not granted, a connection reset —\n * did the same thing through a `warn`, and exited 0.\n */\nexport type PolicyCheckStatus = \"ok\" | \"problems\" | \"unchecked\";\n\n/**\n * The exit code for `rebase doctor --policies`.\n *\n * A gate that could not run has not passed. Only a completed, clean check\n * exits 0 — anything else, including \"we never opened a connection\", is a\n * failure, or the flag certifies a database nobody looked at.\n */\nexport function exitCodeForPolicyGate(status: PolicyCheckStatus): 0 | 1 {\n return status === \"ok\" ? 0 : 1;\n}\n\n/**\n * Policies actually deployed vs the ones the collections describe, plus policy\n * roles this server could never satisfy.\n *\n * Never reports `ok` for work it did not do — see {@link PolicyCheckStatus}.\n */\nexport async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<PolicyCheckStatus> {\n if (!databaseUrl) {\n console.error(chalk.yellow(\" ⚠ No DATABASE_URL — RLS policies were NOT checked\"));\n return \"unchecked\";\n }\n\n const resolvedPath = path.resolve(process.cwd(), collectionsPath);\n let problems = false;\n const { Pool } = await import(\"pg\");\n const pool = new Pool({ connectionString: databaseUrl });\n try {\n const collections: CollectionConfig[] = await loadCollections(resolvedPath);\n\n // Zero collections is not \"no drift\", it is nothing to compare. The\n // loader returns `[]` for a path that does not exist, so the commonest\n // way to get here is a `--collections` path resolved against the wrong\n // directory — which used to render as a green tick.\n if (collections.length === 0) {\n console.error(chalk.red(` ✗ No collections found in ${resolvedPath}`));\n console.error(chalk.gray(\" RLS policies were NOT checked. Pass --collections=<dir> if your collections live elsewhere.\"));\n return \"unchecked\";\n }\n\n const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];\n\n // Prove the connection before anything is ticked.\n //\n // `validatePolicyPgRoles` returns without a query when no collection\n // names a `pgRoles` — which is most projects — so it \"passed\" against a\n // database that was never reached, and the tick printed above an\n // ECONNREFUSED that only surfaced two checks later. Ten lines up, this\n // file says that reporting success for work it never did is the reason\n // it exists. This is the same shape, one function along.\n try {\n await runSql(\"SELECT 1\");\n } catch (err) {\n console.error(chalk.gray(\" ⏭ Policy roles: not checked\"));\n console.error(chalk.red(\" ✗ Could not check RLS policies:\"), err instanceof Error ? err.message : String(err));\n return \"unchecked\";\n }\n\n // A policy naming a role the server never runs as filters every row, so\n // the collection reads as empty. Report it without booting a server.\n try {\n await validatePolicyPgRoles(runSql, collections as never);\n console.log(chalk.green(\" ✓ Policy roles are usable by this server\"));\n } catch (err) {\n problems = true;\n console.log(\"\");\n console.error(chalk.red(err instanceof Error ? err.message : String(err)));\n }\n\n // A rule that reads as a lockdown but is true for every caller compiles\n // to a grant. Report it without booting a server.\n warnOnAnonymousGrants(collections as never);\n\n const drift = await checkPolicyDrift(pool as never, collections);\n console.log(\"\");\n if (hasDrift(drift)) {\n problems = true;\n console.log(chalk.yellow(\" RLS policies: database does not match your collections\"));\n console.log(formatPolicyDrift(drift));\n } else {\n console.log(chalk.green(` ✓ RLS policies match your collections (${collections.length} collection(s) checked)`));\n }\n } catch (err) {\n // Fail closed. This catch covers every query and every collection import\n // above, so returning the pre-catch verdict made the documented CI gate\n // green on exactly the runs it exists to catch.\n console.error(chalk.red(\" ✗ Could not check RLS policies:\"), err instanceof Error ? err.message : String(err));\n return \"unchecked\";\n } finally {\n await pool.end();\n }\n return problems ? \"problems\" : \"ok\";\n}\n","/**\n * `rebase doctor`: collections searched by a full scan of a large table.\n *\n * Without a `search` block, a search — the admin's search box, `?searchString=`,\n * `.search()` — is `ILIKE '%term%'` over every text column. A leading `%`\n * defeats a B-tree, so each one reads the whole table: on 100k posts a search\n * took 326 ms, half for the page and half for its count. Nothing reports it;\n * the box just gets slower as the table grows. This names the collections\n * where that has started to matter, and points at the block that fixes it.\n *\n * A hint, not a failure: it never changes doctor's exit code, and the database\n * is not changed — Rebase does not add a search index on its own.\n *\n * Lives beside `doctor-cli.ts` rather than inside it because that file runs\n * `main()` on import.\n */\nimport path from \"path\";\nimport chalk from \"chalk\";\nimport { getTableName, relationalCollections } from \"@rebasepro/common\";\nimport { isPostgresCollectionConfig, type CollectionConfig } from \"@rebasepro/types\";\nimport { loadCollections } from \"./doctor\";\nimport { buildSearchColumnSpec } from \"./search-column\";\n\n/** Above this many rows an unindexed search is worth a word. */\nexport const UNINDEXED_SEARCH_ROWS = 10_000;\n\n/** A collection whose search would be `ILIKE` over these fields. */\nexport interface IlikeSearchedCollection {\n slug: string;\n schema: string;\n table: string;\n /** The string properties the search reads. */\n fields: string[];\n}\n\n/**\n * The collections a search reaches without an index: no `search` block (or\n * one that does not build), and at least one property the default search\n * reads — a `string`, not an enum, not a uuid key (the rule in\n * `DrizzleConditionBuilder.buildSearchConditions`).\n */\nexport function ilikeSearchedCollections(collections: CollectionConfig[]): IlikeSearchedCollection[] {\n const found: IlikeSearchedCollection[] = [];\n for (const collection of relationalCollections(collections)) {\n if (!isPostgresCollectionConfig(collection)) continue;\n let indexed: boolean;\n try {\n indexed = buildSearchColumnSpec(collection) !== undefined;\n } catch {\n // A block that does not build is reported by the boot; the search\n // falls back to ILIKE meanwhile, so it counts as none here.\n indexed = false;\n }\n if (indexed) continue;\n const fields = Object.entries(collection.properties ?? {})\n .filter(([, property]) => property.type === \"string\" && !property.enum && property.isId !== \"uuid\")\n .map(([key]) => key);\n const table = getTableName(collection);\n if (fields.length === 0 || !table) continue;\n found.push({ slug: collection.slug, schema: collection.schema ?? \"public\", table, fields });\n }\n return found;\n}\n\n/** Those of `candidates` whose table holds more than `threshold` rows, by estimate. */\nexport function unindexedSearchFindings(\n candidates: IlikeSearchedCollection[],\n rowEstimates: ReadonlyMap<string, number>,\n threshold: number = UNINDEXED_SEARCH_ROWS\n): (IlikeSearchedCollection & { rows: number })[] {\n return candidates\n .map(candidate => ({ ...candidate, rows: rowEstimates.get(`${candidate.schema}.${candidate.table}`) ?? 0 }))\n .filter(candidate => candidate.rows > threshold);\n}\n\n/**\n * Row counts by `schema.table`, estimated — `pg_class.reltuples`, or the\n * statistics collector's live-tuple count when the table was never analysed.\n * Nothing is scanned: counting the tables this is about would be the cost it\n * warns of.\n */\nexport async function estimateRowCounts(\n query: (text: string, values: unknown[]) => Promise<{ rows: { schema: string; table: string; rows: string | number }[] }>,\n schemas: string[]\n): Promise<Map<string, number>> {\n const { rows } = await query(\n `SELECT n.nspname AS schema, c.relname AS table,\n GREATEST(c.reltuples, COALESCE(s.n_live_tup, 0), 0)::bigint AS rows\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid\n WHERE c.relkind IN ('r', 'p') AND n.nspname = ANY($1)`,\n [schemas]\n );\n return new Map(rows.map(row => [`${row.schema}.${row.table}`, Number(row.rows)]));\n}\n\n/** The report, one warning per collection. */\nexport function formatUnindexedSearches(findings: (IlikeSearchedCollection & { rows: number })[]): string[] {\n if (findings.length === 0) return [];\n const lines = [\"\", chalk.yellow(` ⚠ ${findings.length} collection(s) searched by reading the whole table:`)];\n for (const finding of findings) {\n lines.push(chalk.yellow(\n ` • ${finding.slug} — about ${finding.rows.toLocaleString(\"en-US\")} rows, no \\`search\\` block: a search is ` +\n `ILIKE '%term%' over ${finding.fields.join(\", \")}`\n ));\n }\n lines.push(chalk.gray(\n \" Each search reads every row, and its count reads them again. A `search` block indexes it\\n\" +\n \" (full-text, or `mode: \\\"hybrid\\\"` for substrings too): https://rebase.pro/docs/backend/search\"\n ));\n return lines;\n}\n\n/**\n * Print the warning for the project at `collectionsPath`, against\n * `databaseUrl`. Never throws and never fails the run: it is a hint, and a\n * check that could not run says so in one grey line.\n */\nexport async function runSearchScaleCheck(collectionsPath: string, databaseUrl?: string): Promise<void> {\n if (!databaseUrl) return;\n const { Client } = await import(\"pg\");\n const client = new Client({ connectionString: databaseUrl });\n try {\n const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));\n const candidates = ilikeSearchedCollections(collections);\n if (candidates.length === 0) return;\n await client.connect();\n const estimates = await estimateRowCounts(\n (text, values) => client.query(text, values),\n [...new Set(candidates.map(candidate => candidate.schema))]\n );\n for (const line of formatUnindexedSearches(unindexedSearchFindings(candidates, estimates))) console.log(line);\n } catch (err) {\n console.log(chalk.gray(` ⏭ Search on large tables: not checked (${err instanceof Error ? err.message : String(err)})`));\n } finally {\n await client.end().catch(() => undefined);\n }\n}\n","#!/usr/bin/env node\n/**\n * CLI entry point for `rebase doctor`.\n * Invoked via tsx by the server-postgres CLI plugin.\n */\nimport path from \"path\";\nimport chalk from \"chalk\";\nimport { runDoctor } from \"./doctor\";\nimport { exitCodeForPolicyGate, runPolicyChecks } from \"./doctor-policy-checks\";\nimport { runSearchScaleCheck } from \"./doctor-search-scale\";\nimport { diagnoseDbError } from \"../cli-errors\";\nimport { describeFailure } from \"../cli-output\";\n\nasync function main() {\n const collectionsArg = process.argv.find((a) => a.startsWith(\"--collections=\"));\n const schemaArg = process.argv.find((a) => a.startsWith(\"--schema=\"));\n const sdkArg = process.argv.find((a) => a.startsWith(\"--sdk=\"));\n // --policies runs only the RLS checks: no schema diff, no SDK types. Useful\n // as a CI gate against a deployed database, where the collection files and\n // generated schema are not what you are asking about.\n const policiesOnly = process.argv.includes(\"--policies\");\n\n const collectionsPath = collectionsArg?.split(\"=\")[1] ?? path.join(\"..\", \"config\", \"collections\");\n const schemaPath = schemaArg?.split(\"=\")[1] ?? path.join(\"src\", \"schema.generated.ts\");\n const sdkPath = sdkArg?.split(\"=\")[1] ?? path.join(\"..\", \"generated\", \"sdk\", \"database.types.ts\");\n\n // Load .env\n try {\n const dotenv = await import(\"dotenv\");\n const envPath = process.env.DOTENV_CONFIG_PATH;\n if (envPath) {\n dotenv.config({ path: envPath, quiet: true });\n } else {\n dotenv.config({ quiet: true });\n }\n } catch {\n // dotenv may not be installed\n }\n\n const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;\n\n if (policiesOnly) {\n // Non-zero so this can gate CI — the whole point of the flag, and a\n // check that could not run has not passed. See exitCodeForPolicyGate.\n process.exit(exitCodeForPolicyGate(await runPolicyChecks(collectionsPath, databaseUrl)));\n }\n\n const report = await runDoctor({\n collectionsPath: path.resolve(process.cwd(), collectionsPath),\n schemaPath: path.resolve(process.cwd(), schemaPath),\n sdkPath: path.resolve(process.cwd(), sdkPath),\n databaseUrl: databaseUrl ?? undefined\n });\n\n const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);\n\n // A hint about speed, not drift: it never changes the exit code.\n await runSearchScaleCheck(collectionsPath, databaseUrl);\n\n // Exit non-zero if there are errors. A policy run that could not happen is\n // reported loudly above but does not fail the interactive command — the\n // same treatment the *chosen* skip of the database phase gets. `--policies`\n // is the gate, and that one fails closed.\n //\n // A phase that was skipped by a fault is different, and `blocked` is what\n // separates the two: \"no DATABASE_URL\" is a local situation somebody may be\n // content with, while \"the DATABASE_URL you set refuses connections\" would\n // otherwise let `rebase doctor` go green in CI against a database it never\n // reached.\n const blocked = [report.collectionsToSchema, report.schemaToDatabase, report.collectionsToSdk]\n .some(phase => phase.blocked);\n if (report.summary.errors > 0 || policyStatus === \"problems\" || blocked) {\n process.exit(1);\n }\n}\n\nmain().catch((err) => {\n // A known database failure has a diagnosis; a stack trace through pg and\n // drizzle internals is not it. This is the last resort — `runDoctor`\n // already catches the connection failure it can name — so it exists for\n // everything reached after the report, the policy checks in particular.\n const banner = diagnoseDbError(err, process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING);\n if (banner) {\n console.error(banner);\n } else {\n console.error(chalk.red(` ✗ Doctor failed: ${describeFailure(err)}`));\n }\n process.exit(1);\n});\n"],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
/** Above this many rows an unindexed search is worth a word. */
|
|
3
|
+
export declare const UNINDEXED_SEARCH_ROWS = 10000;
|
|
4
|
+
/** A collection whose search would be `ILIKE` over these fields. */
|
|
5
|
+
export interface IlikeSearchedCollection {
|
|
6
|
+
slug: string;
|
|
7
|
+
schema: string;
|
|
8
|
+
table: string;
|
|
9
|
+
/** The string properties the search reads. */
|
|
10
|
+
fields: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The collections a search reaches without an index: no `search` block (or
|
|
14
|
+
* one that does not build), and at least one property the default search
|
|
15
|
+
* reads — a `string`, not an enum, not a uuid key (the rule in
|
|
16
|
+
* `DrizzleConditionBuilder.buildSearchConditions`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function ilikeSearchedCollections(collections: CollectionConfig[]): IlikeSearchedCollection[];
|
|
19
|
+
/** Those of `candidates` whose table holds more than `threshold` rows, by estimate. */
|
|
20
|
+
export declare function unindexedSearchFindings(candidates: IlikeSearchedCollection[], rowEstimates: ReadonlyMap<string, number>, threshold?: number): (IlikeSearchedCollection & {
|
|
21
|
+
rows: number;
|
|
22
|
+
})[];
|
|
23
|
+
/**
|
|
24
|
+
* Row counts by `schema.table`, estimated — `pg_class.reltuples`, or the
|
|
25
|
+
* statistics collector's live-tuple count when the table was never analysed.
|
|
26
|
+
* Nothing is scanned: counting the tables this is about would be the cost it
|
|
27
|
+
* warns of.
|
|
28
|
+
*/
|
|
29
|
+
export declare function estimateRowCounts(query: (text: string, values: unknown[]) => Promise<{
|
|
30
|
+
rows: {
|
|
31
|
+
schema: string;
|
|
32
|
+
table: string;
|
|
33
|
+
rows: string | number;
|
|
34
|
+
}[];
|
|
35
|
+
}>, schemas: string[]): Promise<Map<string, number>>;
|
|
36
|
+
/** The report, one warning per collection. */
|
|
37
|
+
export declare function formatUnindexedSearches(findings: (IlikeSearchedCollection & {
|
|
38
|
+
rows: number;
|
|
39
|
+
})[]): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Print the warning for the project at `collectionsPath`, against
|
|
42
|
+
* `databaseUrl`. Never throws and never fails the run: it is a hint, and a
|
|
43
|
+
* check that could not run says so in one grey line.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runSearchScaleCheck(collectionsPath: string, databaseUrl?: string): Promise<void>;
|