@rebasepro/server-postgres 0.24.0 → 0.24.1-canary.gcb918fc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/{BranchService-BRt78gfa.js → BranchService-Bsn2wfaj.js} +3 -2
- package/dist/{BranchService-BRt78gfa.js.map → BranchService-Bsn2wfaj.js.map} +1 -1
- package/dist/PostgresBackendDriver.d.ts +91 -2
- package/dist/PostgresBootstrapper.d.ts +101 -2
- package/dist/auth/ensure-tables.d.ts +12 -0
- package/dist/auth/services.d.ts +6 -0
- 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-DW5p9_zv.js → backup-cli-DnpJi6I7.js} +37 -29
- package/dist/backup-cli-DnpJi6I7.js.map +1 -0
- package/dist/{backup-service-EwcDVG-8.js → backup-service-BTBB7RXn.js} +55 -21
- 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-console-Ca6ZlAVU.js +154 -0
- package/dist/cli-console-Ca6ZlAVU.js.map +1 -0
- package/dist/cli-console.d.ts +39 -0
- package/dist/cli-errors-CaI4ntyD.js +1919 -0
- package/dist/cli-errors-CaI4ntyD.js.map +1 -0
- package/dist/cli-errors.d.ts +51 -5
- package/dist/{cli-flags-BglvpjHv.js → cli-flags-JNI-pwSf.js} +6 -3
- package/dist/cli-flags-JNI-pwSf.js.map +1 -0
- package/dist/cli-helpers.d.ts +40 -0
- package/dist/cli-output.d.ts +16 -28
- package/dist/cli-push.d.ts +87 -0
- package/dist/cli.js +566 -182
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-1-LQD0yI.js → column-plan-helpers-drZIsSNf.js} +10 -4
- package/dist/{column-plan-helpers-1-LQD0yI.js.map → column-plan-helpers-drZIsSNf.js.map} +1 -1
- package/dist/{databasePoolManager-Bj5FbeAs.js → connection-C9mT8bgf.js} +24 -112
- package/dist/connection-C9mT8bgf.js.map +1 -0
- package/dist/connection.d.ts +13 -2
- package/dist/databasePoolManager-BIuewidb.js +119 -0
- package/dist/databasePoolManager-BIuewidb.js.map +1 -0
- package/dist/{ensure-collection-tables-kkkHk8oo.js → deferred-index-build-DP1_SNFo.js} +484 -346
- package/dist/deferred-index-build-DP1_SNFo.js.map +1 -0
- package/dist/{doctor-C-sYWbmt.js → doctor-C4WiftHm.js} +63 -52
- 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-BmI_tRxc.js → ensure-tables-CVm_yvzb.js} +173 -77
- package/dist/ensure-tables-CVm_yvzb.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-sSFDp6LR.js → generate-drizzle-schema-logic-B74WN37S.js} +5 -5
- 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-BJsLaVNX.js → generate-postgres-ddl-logic-B64O2oZF.js} +3 -3
- package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js.map → generate-postgres-ddl-logic-B64O2oZF.js.map} +1 -1
- package/dist/generated-sql.d.ts +8 -2
- package/dist/history/HistoryService.d.ts +64 -5
- package/dist/history/ensure-history-table.d.ts +63 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +139 -19236
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-kCETE8TY.js → introspect-db-logic-C-Di8dA9.js} +44 -6
- package/dist/introspect-db-logic-C-Di8dA9.js.map +1 -0
- package/dist/{introspect-db-queries-C_Q5VgQw.js → introspect-db-queries-D6kAqReI.js} +23 -1
- package/dist/introspect-db-queries-D6kAqReI.js.map +1 -0
- package/dist/open-rebase-CbOur0TU.js +20858 -0
- package/dist/open-rebase-CbOur0TU.js.map +1 -0
- package/dist/open-rebase.d.ts +86 -0
- package/dist/pg-error-utils-vJezlXdI.js +388 -0
- package/dist/pg-error-utils-vJezlXdI.js.map +1 -0
- package/dist/{plan-schema-DU9exq6C.js → plan-schema-B1YF_tYq.js} +151 -41
- package/dist/plan-schema-B1YF_tYq.js.map +1 -0
- package/dist/{policy-drift-B-J2hhm0.js → policy-drift-Dm8i-s1J.js} +23 -6
- 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/{render-ddl-Ds2t_d9V.js → render-ddl-CtC3y9Ol.js} +8 -4
- package/dist/render-ddl-CtC3y9Ol.js.map +1 -0
- package/dist/{rls-bootstrap-sql-_KNnjanK.js → rls-bootstrap-sql-RQUljnJ1.js} +125 -1270
- package/dist/rls-bootstrap-sql-RQUljnJ1.js.map +1 -0
- package/dist/{rls-enforcement-CfXOJJaW.js → rls-enforcement-DvzqlaL1.js} +129 -17
- package/dist/rls-enforcement-DvzqlaL1.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +2 -2
- package/dist/schema/boot-ddl.d.ts +47 -0
- package/dist/schema/collection-index.d.ts +45 -0
- package/dist/schema/column-plan-helpers.d.ts +5 -0
- package/dist/schema/deferred-index-build.d.ts +131 -0
- package/dist/schema/destructive-sql.d.ts +7 -0
- 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 +20 -1
- package/dist/schema/ensure-collection-policies.d.ts +73 -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.js +10 -81
- package/dist/schema/generate-postgres-ddl.js.map +1 -1
- package/dist/schema/introspect-db-logic.d.ts +12 -0
- package/dist/schema/introspect-db-queries.d.ts +11 -1
- package/dist/schema/introspect-db-structure.d.ts +10 -0
- package/dist/schema/introspect-db.js +4 -5
- 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 +126 -0
- package/dist/schema/plan/types.d.ts +8 -0
- 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/security/rls-enforcement.d.ts +32 -4
- package/dist/services/FetchService.d.ts +58 -8
- package/dist/services/PersistService.d.ts +15 -0
- package/dist/services/RelationService.d.ts +88 -3
- package/dist/services/cdc/CdcListener.d.ts +17 -6
- package/dist/services/cdc/trigger-cdc.d.ts +151 -27
- package/dist/services/channel-bus/index.d.ts +1 -1
- package/dist/services/channel-history.d.ts +15 -4
- package/dist/services/collection-helpers.d.ts +15 -0
- package/dist/services/field-viewer.d.ts +15 -1
- package/dist/services/pg-notify-listener.d.ts +7 -0
- package/dist/services/realtimeService.d.ts +314 -14
- package/dist/services/row-pipeline.d.ts +16 -9
- package/dist/services/socket-liveness.d.ts +42 -2
- package/dist/services/sql-script.d.ts +207 -3
- package/dist/services/vector-iterative-scan.d.ts +40 -0
- package/dist/services/write-depth.d.ts +6 -0
- package/dist/services/write-transaction-scope.d.ts +9 -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 +21 -1
- 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-BLGc5yhs.js +1604 -0
- package/dist/websocket-BLGc5yhs.js.map +1 -0
- package/dist/websocket.d.ts +13 -0
- package/package.json +7 -7
- package/dist/backup-cli-DW5p9_zv.js.map +0 -1
- package/dist/backup-service-EwcDVG-8.js.map +0 -1
- package/dist/cli-errors-DsA-K9uP.js +0 -893
- package/dist/cli-errors-DsA-K9uP.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/databasePoolManager-Bj5FbeAs.js.map +0 -1
- package/dist/doctor-C-sYWbmt.js.map +0 -1
- package/dist/ensure-collection-policies-B1ureSIV.js +0 -160
- package/dist/ensure-collection-policies-B1ureSIV.js.map +0 -1
- package/dist/ensure-collection-tables-kkkHk8oo.js.map +0 -1
- package/dist/ensure-tables-BmI_tRxc.js.map +0 -1
- package/dist/generate-drizzle-schema-93M0lUxK.js +0 -123
- package/dist/generate-drizzle-schema-93M0lUxK.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +0 -1
- package/dist/introspect-db-logic-kCETE8TY.js.map +0 -1
- package/dist/introspect-db-queries-C_Q5VgQw.js.map +0 -1
- package/dist/plan-schema-DU9exq6C.js.map +0 -1
- package/dist/policy-drift-B-J2hhm0.js.map +0 -1
- package/dist/render-ddl-Ds2t_d9V.js.map +0 -1
- package/dist/rls-bootstrap-sql-_KNnjanK.js.map +0 -1
- package/dist/rls-enforcement-CfXOJJaW.js.map +0 -1
- package/dist/source-Br7L7GOI.js +0 -410
- package/dist/source-Br7L7GOI.js.map +0 -1
- package/dist/utils/sql-redaction.d.ts +0 -21
|
@@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAAgB,sBAAsB,QAAkC;CACpE,OAAO,WAAW,OAAO,IAAI;AACjC;;;;;;;AAQA,eAAsB,gBAAgB,iBAAyB,aAAkD;CAC7G,IAAI,CAAC,aAAa;EACd,QAAQ,MAAM,MAAM,OAAO,qDAAqD,CAAC;EACjF,OAAO;CACX;CAEA,MAAM,eAAe,KAAK,QAAQ,QAAQ,IAAI,GAAG,eAAe;CAChE,IAAI,WAAW;CACf,MAAM,EAAE,SAAS,MAAM,OAAO;CAC9B,MAAM,OAAO,IAAI,KAAK,EAAE,kBAAkB,YAAY,CAAC;CACvD,IAAI;EACA,MAAM,cAAkC,MAAM,gBAAgB,YAAY;EAM1E,IAAI,YAAY,WAAW,GAAG;GAC1B,QAAQ,MAAM,MAAM,IAAI,+BAA+B,cAAc,CAAC;GACtE,QAAQ,MAAM,MAAM,KAAK,iGAAiG,CAAC;GAC3H,OAAO;EACX;EAEA,MAAM,SAAS,OAAO,UAAkB,MAAM,KAAK,MAAM,IAAI,EAAA,CAAG;EAUhE,IAAI;GACA,MAAM,OAAO,UAAU;EAC3B,SAAS,KAAK;GACV,QAAQ,MAAM,MAAM,KAAK,+BAA+B,CAAC;GACzD,QAAQ,MAAM,MAAM,IAAI,mCAAmC,GAAG,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;GAC9G,OAAO;EACX;EAIA,IAAI;GACA,MAAM,sBAAsB,QAAQ,WAAoB;GACxD,QAAQ,IAAI,MAAM,MAAM,4CAA4C,CAAC;EACzE,SAAS,KAAK;GACV,WAAW;GACX,QAAQ,IAAI,EAAE;GACd,QAAQ,MAAM,MAAM,IAAI,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC,CAAC;EAC7E;EAIA,sBAAsB,WAAoB;EAE1C,MAAM,QAAQ,MAAM,iBAAiB,MAAe,WAAW;EAC/D,QAAQ,IAAI,EAAE;EACd,IAAI,SAAS,KAAK,GAAG;GACjB,WAAW;GACX,QAAQ,IAAI,MAAM,OAAO,0DAA0D,CAAC;GACpF,QAAQ,IAAI,kBAAkB,KAAK,CAAC;EACxC,OACI,QAAQ,IAAI,MAAM,MAAM,4CAA4C,YAAY,OAAO,wBAAwB,CAAC;CAExH,SAAS,KAAK;EAIV,QAAQ,MAAM,MAAM,IAAI,mCAAmC,GAAG,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC;EAC9G,OAAO;CACX,UAAU;EACN,MAAM,KAAK,IAAI;CACnB;CACA,OAAO,WAAW,aAAa;AACnC;;;;;;;;;;;;;;;;;;;;AChGA,IAAa,wBAAwB;;;;;;;AAiBrC,SAAgB,yBAAyB,aAA4D;CACjG,MAAM,QAAmC,CAAC;CAC1C,KAAK,MAAM,cAAc,sBAAsB,WAAW,GAAG;EACzD,IAAI,CAAC,2BAA2B,UAAU,GAAG;EAC7C,IAAI;EACJ,IAAI;GACA,UAAU,sBAAsB,UAAU,MAAM,KAAA;EACpD,QAAQ;GAGJ,UAAU;EACd;EACA,IAAI,SAAS;EACb,MAAM,SAAS,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,CAAC,CACrD,QAAQ,GAAG,cAAc,SAAS,SAAS,YAAY,CAAC,SAAS,QAAQ,SAAS,SAAS,MAAM,CAAC,CAClG,KAAK,CAAC,SAAS,GAAG;EACvB,MAAM,QAAQ,aAAa,UAAU;EACrC,IAAI,OAAO,WAAW,KAAK,CAAC,OAAO;EACnC,MAAM,KAAK;GAAE,MAAM,WAAW;GAAM,QAAQ,WAAW,UAAU;GAAU;GAAO;EAAO,CAAC;CAC9F;CACA,OAAO;AACX;;AAGA,SAAgB,wBACZ,YACA,cACA,YAAoB,uBAC0B;CAC9C,OAAO,WACF,KAAI,eAAc;EAAE,GAAG;EAAW,MAAM,aAAa,IAAI,GAAG,UAAU,OAAO,GAAG,UAAU,OAAO,KAAK;CAAE,EAAE,CAAC,CAC3G,QAAO,cAAa,UAAU,OAAO,SAAS;AACvD;;;;;;;AAQA,eAAsB,kBAClB,OACA,SAC4B;CAC5B,MAAM,EAAE,SAAS,MAAM,MACnB;;;;;iEAMA,CAAC,OAAO,CACZ;CACA,OAAO,IAAI,IAAI,KAAK,KAAI,QAAO,CAAC,GAAG,IAAI,OAAO,GAAG,IAAI,SAAS,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC;AACpF;;AAGA,SAAgB,wBAAwB,UAAoE;CACxG,IAAI,SAAS,WAAW,GAAG,OAAO,CAAC;CACnC,MAAM,QAAQ,CAAC,IAAI,MAAM,OAAO,OAAO,SAAS,OAAO,oDAAoD,CAAC;CAC5G,KAAK,MAAM,WAAW,UAClB,MAAM,KAAK,MAAM,OACb,UAAU,QAAQ,KAAK,WAAW,QAAQ,KAAK,eAAe,OAAO,EAAE,8DAChD,QAAQ,OAAO,KAAK,IAAI,GACnD,CAAC;CAEL,MAAM,KAAK,MAAM,KACb,iMAEJ,CAAC;CACD,OAAO;AACX;;;;;;AAOA,eAAsB,oBAAoB,iBAAyB,aAAqC;CACpG,IAAI,CAAC,aAAa;CAClB,MAAM,EAAE,WAAW,MAAM,OAAO;CAChC,MAAM,SAAS,IAAI,OAAO,EAAE,kBAAkB,YAAY,CAAC;CAC3D,IAAI;EAEA,MAAM,aAAa,yBAAyB,MADlB,gBAAgB,KAAK,QAAQ,QAAQ,IAAI,GAAG,eAAe,CAAC,CAC/B;EACvD,IAAI,WAAW,WAAW,GAAG;EAC7B,MAAM,OAAO,QAAQ;EACrB,MAAM,YAAY,MAAM,mBACnB,MAAM,WAAW,OAAO,MAAM,MAAM,MAAM,GAC3C,CAAC,GAAG,IAAI,IAAI,WAAW,KAAI,cAAa,UAAU,MAAM,CAAC,CAAC,CAC9D;EACA,KAAK,MAAM,QAAQ,wBAAwB,wBAAwB,YAAY,SAAS,CAAC,GAAG,QAAQ,IAAI,IAAI;CAChH,SAAS,KAAK;EACV,QAAQ,IAAI,MAAM,KAAK,4CAA4C,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,EAAE,EAAE,CAAC;CAC3H,UAAU;EACN,MAAM,OAAO,IAAI,CAAC,CAAC,YAAY,KAAA,CAAS;CAC5C;AACJ;;;;;;;AC7HA,eAAe,OAAO;CAClB,MAAM,iBAAiB,QAAQ,KAAK,MAAM,MAAM,EAAE,WAAW,gBAAgB,CAAC;CAC9E,MAAM,YAAY,QAAQ,KAAK,MAAM,MAAM,EAAE,WAAW,WAAW,CAAC;CACpE,MAAM,SAAS,QAAQ,KAAK,MAAM,MAAM,EAAE,WAAW,QAAQ,CAAC;CAI9D,MAAM,eAAe,QAAQ,KAAK,SAAS,YAAY;CAEvD,MAAM,kBAAkB,gBAAgB,MAAM,GAAG,CAAC,CAAC,MAAM,KAAK,KAAK,MAAM,UAAU,aAAa;CAChG,MAAM,aAAa,WAAW,MAAM,GAAG,CAAC,CAAC,MAAM,KAAK,KAAK,OAAO,qBAAqB;CACrF,MAAM,UAAU,QAAQ,MAAM,GAAG,CAAC,CAAC,MAAM,KAAK,KAAK,MAAM,aAAa,OAAO,mBAAmB;CAGhG,IAAI;EACA,MAAM,SAAS,MAAM,OAAO;EAC5B,MAAM,UAAU,QAAQ,IAAI;EAC5B,IAAI,SACA,OAAO,OAAO;GAAE,MAAM;GAAS,OAAO;EAAK,CAAC;OAE5C,OAAO,OAAO,EAAE,OAAO,KAAK,CAAC;CAErC,QAAQ,CAER;CAEA,MAAM,cAAc,QAAQ,IAAI,gBAAgB,QAAQ,IAAI;CAE5D,IAAI,cAGA,QAAQ,KAAK,sBAAsB,MAAM,gBAAgB,iBAAiB,WAAW,CAAC,CAAC;CAG3F,MAAM,SAAS,MAAM,UAAU;EAC3B,iBAAiB,KAAK,QAAQ,QAAQ,IAAI,GAAG,eAAe;EAC5D,YAAY,KAAK,QAAQ,QAAQ,IAAI,GAAG,UAAU;EAClD,SAAS,KAAK,QAAQ,QAAQ,IAAI,GAAG,OAAO;EAC5C,aAAa,eAAe,KAAA;CAChC,CAAC;CAED,MAAM,eAAe,MAAM,gBAAgB,iBAAiB,WAAW;CAGvE,MAAM,oBAAoB,iBAAiB,WAAW;CAYtD,MAAM,UAAU;EAAC,OAAO;EAAqB,OAAO;EAAkB,OAAO;CAAgB,CAAC,CACzF,MAAK,UAAS,MAAM,OAAO;CAChC,IAAI,OAAO,QAAQ,SAAS,KAAK,iBAAiB,cAAc,SAC5D,QAAQ,KAAK,CAAC;AAEtB;AAEA,KAAK,CAAC,CAAC,OAAO,QAAQ;CAKlB,MAAM,SAAS,gBAAgB,KAAK,QAAQ,IAAI,gBAAgB,QAAQ,IAAI,uBAAuB;CACnG,IAAI,QACA,QAAQ,MAAM,MAAM;MAEpB,QAAQ,MAAM,MAAM,IAAI,sBAAsB,gBAAgB,GAAG,GAAG,CAAC;CAEzE,QAAQ,KAAK,CAAC;AAClB,CAAC"}
|
|
@@ -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>;
|
package/dist/schema/doctor.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CollectionConfig, Property } from "@rebasepro/types";
|
|
2
|
-
import type { PgType, TablePlan } from "./plan/types.js";
|
|
2
|
+
import type { PgType, SchemaPlan, TablePlan } from "./plan/types.js";
|
|
3
3
|
export type IssueSeverity = "error" | "warning" | "info";
|
|
4
4
|
export interface DoctorIssue {
|
|
5
5
|
severity: IssueSeverity;
|
|
@@ -148,6 +148,25 @@ export declare function primaryKeyIssues(tablePlan: TablePlan, dbPk: string[], d
|
|
|
148
148
|
*/
|
|
149
149
|
export declare function junctionKeyIssues(junctionPlan: TablePlan, dbColumns: DbColumn[], dbPk: string[], displayName: string): DoctorIssue[];
|
|
150
150
|
export declare function checkCollectionsVsDatabase(collections: CollectionConfig[], databaseUrl: string): Promise<DoctorPhase>;
|
|
151
|
+
/**
|
|
152
|
+
* A `public` table in the junction shape that no collection declares.
|
|
153
|
+
*
|
|
154
|
+
* The shape is the planner's: exactly two columns, together the primary key,
|
|
155
|
+
* each a single-column foreign key to a collection's table. Renaming or
|
|
156
|
+
* removing a many-to-many leaves such a table behind — push and boot only
|
|
157
|
+
* create — with every link row still in it and nothing reading it.
|
|
158
|
+
*
|
|
159
|
+
* Info, never more: the table may be data somebody still wants. The remedy is
|
|
160
|
+
* shown, never run.
|
|
161
|
+
*/
|
|
162
|
+
export declare function orphanedJunctionIssues(plan: SchemaPlan, columnsByTable: Map<string, {
|
|
163
|
+
column_name: string;
|
|
164
|
+
}[]>, pksByTable: Map<string, string[]>, fksByTable: Map<string, {
|
|
165
|
+
constraint_name: string;
|
|
166
|
+
column_name: string;
|
|
167
|
+
foreign_table_schema: string;
|
|
168
|
+
foreign_table_name: string;
|
|
169
|
+
}[]>): DoctorIssue[];
|
|
151
170
|
export declare function renderReport(report: DoctorReport): void;
|
|
152
171
|
export declare function runDoctor(options: {
|
|
153
172
|
collectionsPath: string;
|
|
@@ -1,43 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Applying a bundle's RLS policies to a database at boot, idempotently.
|
|
3
|
-
*
|
|
4
|
-
* ## Why this exists
|
|
5
|
-
*
|
|
6
|
-
* {@link ensureCollectionTables} creates the collection *tables* a managed
|
|
7
|
-
* runtime boots against, but a table with row-level security disabled and no
|
|
8
|
-
* policies is not servable: authenticated requests run as the restricted
|
|
9
|
-
* `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
|
|
10
|
-
* public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
|
|
11
|
-
* is denied. The policies live in the collections' `securityRules`; nothing at
|
|
12
|
-
* boot applied them. `rebase db push` does — but it drives Atlas against a
|
|
13
|
-
* local `DATABASE_URL`, and a managed tenant's database is reachable only from
|
|
14
|
-
* inside the cluster, by the runtime that is already connected to it. So the
|
|
15
|
-
* runtime is the only thing that *can* apply them, and this is where it does.
|
|
16
|
-
*
|
|
17
|
-
* ## Why this is safe to run on every boot
|
|
18
|
-
*
|
|
19
|
-
* Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
|
|
20
|
-
* enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
|
|
21
|
-
* a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
|
|
22
|
-
* and replaces; it never drops data. (It does not *reconcile* — a policy a
|
|
23
|
-
* previous push left behind under an old name is not removed here; that stays a
|
|
24
|
-
* `db push` / `db migrate` concern, alongside destructive schema changes.)
|
|
25
|
-
*
|
|
26
|
-
* Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
|
|
27
|
-
* table whose policies could not be applied fails **closed** (denies) rather
|
|
28
|
-
* than leaking rows. One collection's policy failing (e.g. a rule that
|
|
29
|
-
* references a table a real migration has not created yet) must not crash-loop
|
|
30
|
-
* the whole deployment and take the other collections' working routes down with
|
|
31
|
-
* it. Failures are reported loudly and per-table so the operator can see
|
|
32
|
-
* exactly which collection is not yet servable and why.
|
|
33
|
-
*/
|
|
34
1
|
import { type CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import { type CollectionPolicyPlan } from "./generate-postgres-ddl-logic.js";
|
|
35
3
|
import { type Queryable } from "./ensure-collection-tables.js";
|
|
4
|
+
import { type BootLockOptions } from "./boot-ddl.js";
|
|
36
5
|
export interface PolicyEnsureResult {
|
|
37
6
|
/** `CREATE POLICY` statements that ran successfully. */
|
|
38
7
|
policiesApplied: number;
|
|
39
|
-
/** Tables that had RLS enabled. */
|
|
8
|
+
/** Tables that had RLS enabled — including the {@link unchanged} ones, which already had it. */
|
|
40
9
|
tablesSecured: number;
|
|
10
|
+
/**
|
|
11
|
+
* Tables whose policies were already exactly the declared ones, RLS on, by
|
|
12
|
+
* the stamp each policy carries — left alone: no statement, no lock.
|
|
13
|
+
*/
|
|
14
|
+
unchanged: number;
|
|
41
15
|
/** Declared tables absent from the database — left to a real migration. */
|
|
42
16
|
skipped: {
|
|
43
17
|
table: string;
|
|
@@ -80,7 +54,34 @@ export interface PolicyEnsureResult {
|
|
|
80
54
|
* database — which is the reason this module exists. So boot has to do it.
|
|
81
55
|
*/
|
|
82
56
|
orphansDropped: number;
|
|
57
|
+
/**
|
|
58
|
+
* Tables whose policies were not re-applied because another session held
|
|
59
|
+
* the table's lock for every attempt — a long transaction, a report, a
|
|
60
|
+
* dump. Each table's policy statements run in one transaction, so a table
|
|
61
|
+
* here was left exactly as it was: RLS on, its existing policies in force.
|
|
62
|
+
* `rulesChanged` says whether those differ from the declared ones by name
|
|
63
|
+
* (a rule whose policy is missing, or an orphan still granting), in which
|
|
64
|
+
* case the new rules are not in force on this table until a later boot.
|
|
65
|
+
*/
|
|
66
|
+
deferred: {
|
|
67
|
+
table: string;
|
|
68
|
+
error: string;
|
|
69
|
+
rulesChanged: boolean;
|
|
70
|
+
}[];
|
|
83
71
|
}
|
|
72
|
+
/** What every policy stamp starts with. See {@link policyStampSql}. */
|
|
73
|
+
export declare const POLICY_STAMP_PREFIX = "rebase:";
|
|
74
|
+
/**
|
|
75
|
+
* One statement that stamps every policy the plan just created on its table:
|
|
76
|
+
* `COMMENT ON POLICY … IS 'rebase:<hash>'`, the hash taken over the rendered
|
|
77
|
+
* statement's hash and the definition Postgres stored — read back here, after
|
|
78
|
+
* the `CREATE`s, in the same transaction. A `DO` block because the stored
|
|
79
|
+
* definition is only known once the policy exists, and a `COMMENT` takes a
|
|
80
|
+
* literal. One line, and it names only this table.
|
|
81
|
+
*
|
|
82
|
+
* `undefined` for a table the plan gives no policies.
|
|
83
|
+
*/
|
|
84
|
+
export declare function policyStampSql(plan: CollectionPolicyPlan): string | undefined;
|
|
84
85
|
export interface TableLockResult {
|
|
85
86
|
/** Tables RLS is now on for. */
|
|
86
87
|
locked: number;
|
|
@@ -103,4 +104,41 @@ export interface TableLockResult {
|
|
|
103
104
|
* a table that does not exist, and the step that creates it locks it.
|
|
104
105
|
*/
|
|
105
106
|
export declare function lockCollectionTables(client: Queryable, collections: CollectionConfig[]): Promise<TableLockResult>;
|
|
106
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Bring the declared collections' RLS policies up to date. Returns what it did.
|
|
109
|
+
*
|
|
110
|
+
* Only tables that already exist are touched: the boot-time table creator runs
|
|
111
|
+
* first, so anything still missing is a table this additive path did not
|
|
112
|
+
* create (it creates junctions too) — one a failed boot DDL left out, or a
|
|
113
|
+
* project whose schema a migration owns. Enabling RLS on a
|
|
114
|
+
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
115
|
+
*
|
|
116
|
+
* ## One transaction per table
|
|
117
|
+
*
|
|
118
|
+
* Each table's statements — `ENABLE` when RLS is off, every `DROP POLICY IF
|
|
119
|
+
* EXISTS` / `CREATE POLICY` pair, the orphan drops — go to Postgres as one
|
|
120
|
+
* string, which it runs as one transaction (see `boot-ddl`). Before, each was
|
|
121
|
+
* its own autocommit: at 240 tables, 4,080 commits and 4,080 ACCESS EXCLUSIVE
|
|
122
|
+
* lock acquisitions per boot, 6–22 s on a disk that really flushes. And between
|
|
123
|
+
* a policy's DROP and its CREATE, every other replica evaluated the table with
|
|
124
|
+
* that policy missing — a user's continuous reads of their own row came back
|
|
125
|
+
* empty once per boot (measured, Postgres 18). Now a concurrent request sees the
|
|
126
|
+
* old policies or the new ones, never neither.
|
|
127
|
+
*
|
|
128
|
+
* If the transaction fails for any reason but a lock timeout, the table is
|
|
129
|
+
* applied again one statement at a time, exactly as before — so a rule that
|
|
130
|
+
* cannot be created (a function a migration has not added yet) leaves the
|
|
131
|
+
* table in the same deny-leaning partial state it always did, rather than
|
|
132
|
+
* rolling back to the old, possibly looser, policies.
|
|
133
|
+
*
|
|
134
|
+
* ## Lock budget
|
|
135
|
+
*
|
|
136
|
+
* The transaction runs under `lock_timeout` (`boot-ddl`), so a table that a
|
|
137
|
+
* long transaction, a report or a dump is holding costs live traffic one
|
|
138
|
+
* second, not the statement timeout. A contended table is retried after the
|
|
139
|
+
* others, with backoff, within a retry budget for the whole step; one still
|
|
140
|
+
* held after that is reported in `deferred` — its transaction rolled back, so
|
|
141
|
+
* it is exactly as it was before this boot — or, if RLS was off on it, closed
|
|
142
|
+
* by revoking the user role's grant, as any table RLS cannot be enabled on is.
|
|
143
|
+
*/
|
|
144
|
+
export declare function ensureCollectionPolicies(client: Queryable, collections: CollectionConfig[], log?: (message: string) => void, options?: BootLockOptions): Promise<PolicyEnsureResult>;
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
* See `diff-plan.ts` for the whole of that argument.
|
|
23
23
|
*/
|
|
24
24
|
import { type CollectionConfig } from "@rebasepro/types";
|
|
25
|
-
import { type ConstraintPolicy, type DiffOptions, type EnsureAction, type EnsurePlan, type ExistingSchema, type LegacyForeignKey, type OrphanedRequiredColumn, type SearchColumnDrift, type WithheldConstraint } from "./plan/diff-plan.js";
|
|
26
|
-
|
|
25
|
+
import { type ConstraintPolicy, type DeferredIndexBuild, type DiffOptions, type EnsureAction, type EnsurePlan, type ExistingSchema, type LegacyForeignKey, type OrphanedRequiredColumn, type SearchColumnDrift, type WithheldConstraint } from "./plan/diff-plan.js";
|
|
26
|
+
import { type DeferredIndexOutcome, type OpenIndexBuildSession } from "./deferred-index-build.js";
|
|
27
|
+
export type { ConstraintPolicy, DeferredIndexBuild, EnsureAction, EnsurePlan, ExistingSchema, LegacyForeignKey, OrphanedRequiredColumn, SearchColumnDrift, WithheldConstraint };
|
|
27
28
|
/**
|
|
28
29
|
* The subset of a database handle this needs: run a statement, get rows back.
|
|
29
30
|
*
|
|
@@ -51,6 +52,36 @@ export interface EnsureOptions extends DiffOptions {
|
|
|
51
52
|
* reads the world.
|
|
52
53
|
*/
|
|
53
54
|
databaseExtensions?: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* Run each statement that changes an existing table under this
|
|
57
|
+
* `lock_timeout` (ms), retrying a timeout with backoff — see
|
|
58
|
+
* `schema/boot-ddl`. The boot sets it; absent, statements wait on locks for
|
|
59
|
+
* as long as the connection's `statement_timeout` allows, as the live schema
|
|
60
|
+
* editor's confirmed changes do.
|
|
61
|
+
*/
|
|
62
|
+
lockTimeoutMs?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Apply the statements that build tables this run creates — and the enum
|
|
65
|
+
* types they use — in transactions of up to {@link NEW_TABLE_BATCH}
|
|
66
|
+
* statements instead of one autocommit each. The boot sets it; see
|
|
67
|
+
* `ensureCollectionTables`.
|
|
68
|
+
*/
|
|
69
|
+
batchNewTables?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Leave the indexes a live table is missing (or holds INVALID) to the
|
|
72
|
+
* caller, in {@link EnsureOutcome.deferredIndexes}, instead of building
|
|
73
|
+
* them before returning. The boot sets it and builds them in the
|
|
74
|
+
* background once it serves — see `deferred-index-build`. A UNIQUE one is
|
|
75
|
+
* built before returning either way: see {@link DeferredIndexBuild.unique}.
|
|
76
|
+
*/
|
|
77
|
+
deferIndexBuilds?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* The connection those builds run on when they are not deferred. Absent,
|
|
80
|
+
* they share `client` — fine for a small table, and cut short by the
|
|
81
|
+
* pool's `statement_timeout` on a large one, which is why the boot hands
|
|
82
|
+
* over a dedicated connection.
|
|
83
|
+
*/
|
|
84
|
+
openIndexBuildSession?: OpenIndexBuildSession;
|
|
54
85
|
}
|
|
55
86
|
export interface EnsureOutcome extends EnsurePlan {
|
|
56
87
|
/**
|
|
@@ -68,6 +99,14 @@ export interface EnsureOutcome extends EnsurePlan {
|
|
|
68
99
|
target: string;
|
|
69
100
|
error: string;
|
|
70
101
|
}[];
|
|
102
|
+
/**
|
|
103
|
+
* What happened to the indexes built here rather than left to the caller —
|
|
104
|
+
* every one of {@link EnsurePlan.deferredIndexes}, or under
|
|
105
|
+
* `deferIndexBuilds` the UNIQUE ones only; absent when there were none.
|
|
106
|
+
* An index on a table that already exists reports here, not in
|
|
107
|
+
* {@link failures}.
|
|
108
|
+
*/
|
|
109
|
+
indexBuilds?: DeferredIndexOutcome;
|
|
71
110
|
}
|
|
72
111
|
/**
|
|
73
112
|
* Decide what to add. Pure — the caller supplies what exists and runs the
|
|
@@ -82,8 +121,22 @@ export interface EnsureOutcome extends EnsurePlan {
|
|
|
82
121
|
* with no developer in the loop.
|
|
83
122
|
*/
|
|
84
123
|
export declare function planCollectionSchemaEnsure(allCollections: CollectionConfig[], existing: ExistingSchema, options?: EnsureOptions): EnsurePlan;
|
|
124
|
+
export interface ReadSchemaOptions {
|
|
125
|
+
/**
|
|
126
|
+
* The existing tables worth probing for rows, given every table's columns.
|
|
127
|
+
*
|
|
128
|
+
* Whether a table holds rows decides one thing: whether a NOT NULL or a
|
|
129
|
+
* UNIQUE can be added with a column *being added* to it. So the boot asks
|
|
130
|
+
* only about tables gaining a column — none at all on a boot that changes
|
|
131
|
+
* nothing — rather than probing every table in the schema, adopted and
|
|
132
|
+
* unmanaged ones included, on every boot. A table that is not probed is
|
|
133
|
+
* reported as holding rows: the answer that never plans a constraint that
|
|
134
|
+
* could fail against data. Absent, every table is probed.
|
|
135
|
+
*/
|
|
136
|
+
probeTables?: (tables: ReadonlyMap<string, ReadonlySet<string>>) => Iterable<string>;
|
|
137
|
+
}
|
|
85
138
|
/** Read what the database has, for the schemas the collections live in. */
|
|
86
|
-
export declare function readExistingSchema(client: Queryable, schemas: string[]): Promise<ExistingSchema>;
|
|
139
|
+
export declare function readExistingSchema(client: Queryable, schemas: string[], options?: ReadSchemaOptions): Promise<ExistingSchema>;
|
|
87
140
|
/**
|
|
88
141
|
* Read what the database looks like, for the schemas a set of collections
|
|
89
142
|
* lives in.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enum labels the database holds and the collections no longer declare.
|
|
3
|
+
*
|
|
4
|
+
* PostgreSQL has no `ALTER TYPE … DROP VALUE`, and Atlas plans nothing for a
|
|
5
|
+
* removed label — `db push` reported success while the database kept the old
|
|
6
|
+
* label, so writes of it (by SQL, or an older client) went on succeeding, and
|
|
7
|
+
* `rebase doctor` sent the developer back to the same push. Boot behaves the
|
|
8
|
+
* same way by design: it adds labels and never removes one. So this is not a
|
|
9
|
+
* refusal — the push stays what boot would do — but the push says it, with the
|
|
10
|
+
* real way to retire a label.
|
|
11
|
+
*/
|
|
12
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
13
|
+
import type { EnumPlan } from "./plan/types.js";
|
|
14
|
+
import { type Queryable } from "./ensure-collection-tables.js";
|
|
15
|
+
export interface KeptEnumLabels {
|
|
16
|
+
/** `schema.type`, as the catalogue names it. */
|
|
17
|
+
type: string;
|
|
18
|
+
/** Labels in the database that no collection declares, in the type's order. */
|
|
19
|
+
labels: string[];
|
|
20
|
+
}
|
|
21
|
+
/** Pure: the declared enums against the catalogue's labels, keyed `schema.type`. */
|
|
22
|
+
export declare function findKeptEnumLabels(declared: Pick<EnumPlan, "qualified" | "labels">[], existing: ReadonlyMap<string, string[]>): KeptEnumLabels[];
|
|
23
|
+
/** Plans the collections and reads the catalogue's enum labels. */
|
|
24
|
+
export declare function keptEnumLabels(client: Queryable, collections: CollectionConfig[]): Promise<KeptEnumLabels[]>;
|
|
25
|
+
/** What the push prints: which labels stay, and how one is actually retired. */
|
|
26
|
+
export declare function formatKeptEnumLabelsNotice(kept: KeptEnumLabels[]): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
__createRequire(import.meta.url);
|
|
3
|
-
import { t as main } from "../generate-drizzle-schema-
|
|
3
|
+
import { t as main } from "../generate-drizzle-schema-Bz52jH2h.js";
|
|
4
4
|
//#region src/schema/bin/generate-drizzle-schema.ts
|
|
5
5
|
/**
|
|
6
6
|
* The built entry point for `generate-drizzle-schema`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two generators `rebase db push` runs as its step 1, as functions.
|
|
3
|
+
*
|
|
4
|
+
* They used to exist only as scripts, each its own `tsx` process: the driver
|
|
5
|
+
* (itself a `tsx` process) spawned one for the Drizzle schema and then one for
|
|
6
|
+
* the DDL, and each paid a Node start, tsx's loader and an import of the whole
|
|
7
|
+
* server package before reading a single collection — about 0.5 s apiece, a
|
|
8
|
+
* second per push. The driver process is one-shot, so a fresh module graph per
|
|
9
|
+
* generation bought nothing: the collections cannot change under it.
|
|
10
|
+
*
|
|
11
|
+
* The scripts remain (`generate-drizzle-schema.ts`, `generate-postgres-ddl.ts`)
|
|
12
|
+
* for `--watch`, which re-imports the collections on every save and does need a
|
|
13
|
+
* process of its own — Node's module cache would serve it the first version
|
|
14
|
+
* forever.
|
|
15
|
+
*
|
|
16
|
+
* Both functions print their own success lines and their own error, exactly as
|
|
17
|
+
* the scripts did, and throw {@link GenerationFailed} after printing — so a
|
|
18
|
+
* caller can tell "the generator said why" from "the generator never ran".
|
|
19
|
+
*/
|
|
20
|
+
/** Thrown once the generator has printed why it failed. */
|
|
21
|
+
export declare class GenerationFailed extends Error {
|
|
22
|
+
constructor(message: string, options?: {
|
|
23
|
+
cause?: unknown;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export interface GenerateFileOptions {
|
|
27
|
+
/** The collections directory (or single module), resolved against the cwd. */
|
|
28
|
+
collectionsPath: string;
|
|
29
|
+
/** Where to write. Absent: print to stdout instead. */
|
|
30
|
+
outputPath?: string;
|
|
31
|
+
/**
|
|
32
|
+
* End on the line that says what to run next. Only `rebase schema
|
|
33
|
+
* generate` itself wants it: inside `db push` it told the push to run
|
|
34
|
+
* `db generate`, and inside `db generate` to run itself.
|
|
35
|
+
*/
|
|
36
|
+
nextStep?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** `src/schema.generated.ts`, from the collections. */
|
|
39
|
+
export declare function writeDrizzleSchema(options: GenerateFileOptions): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* `schema.sql` (Atlas's desired state) and, beside it, `policies.sql`,
|
|
42
|
+
* `search.sql`, `vector.sql` and `triggers.sql` — the parts Atlas does not own.
|
|
43
|
+
*/
|
|
44
|
+
export declare function writePostgresDdl(options: GenerateFileOptions): Promise<void>;
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The line `rebase schema generate` ends on.
|
|
3
|
-
*
|
|
4
|
-
* Its own module because `generate-drizzle-schema.ts` uses `import.meta` to
|
|
5
|
-
* decide whether it was executed directly, which the driver's jest suite cannot
|
|
6
|
-
* load at all — so the one sentence in that file with a decision in it had no
|
|
7
|
-
* test and could not have one where it stood.
|
|
8
|
-
*/
|
|
9
|
-
export declare const formatTerminalText: (text: string, options?: {
|
|
10
|
-
bold?: boolean;
|
|
11
|
-
backgroundColor?: "blue" | "green" | "red" | "yellow" | "cyan" | "magenta";
|
|
12
|
-
textColor?: "white" | "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan";
|
|
13
|
-
}) => string;
|
|
14
1
|
/**
|
|
15
2
|
* What to do with the file that was just generated.
|
|
16
3
|
*
|