@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.0
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/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- package/dist/websocket-D0TBU3ia.js.map +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the project being introspected into can actually import.
|
|
3
|
+
*
|
|
4
|
+
* The generator is pure — `introspect-db-logic.ts` reads no files — so the one
|
|
5
|
+
* question it cannot answer for itself lives here: which `defineCollection`, if
|
|
6
|
+
* any, will resolve from the directory the collections are written to.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
import { ADMIN_TYPES_PACKAGE, COMMON_PACKAGE, type CollectionBuilder } from "./introspect-db-logic";
|
|
12
|
+
|
|
13
|
+
/** How far up to look before giving up, if no project root announces itself. */
|
|
14
|
+
const MAX_LEVELS = 8;
|
|
15
|
+
|
|
16
|
+
/** Every dependency name a manifest declares, in any of the three fields. */
|
|
17
|
+
function declaredDependencies(manifestPath: string): Set<string> {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
let parsed: unknown;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
22
|
+
} catch {
|
|
23
|
+
// A malformed or unreadable package.json is not this function's problem
|
|
24
|
+
// to report — it answers "can this import resolve", and the answer is no.
|
|
25
|
+
return names;
|
|
26
|
+
}
|
|
27
|
+
if (typeof parsed !== "object" || parsed === null) return names;
|
|
28
|
+
const manifest = parsed as Record<string, unknown>;
|
|
29
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
30
|
+
const block = manifest[field];
|
|
31
|
+
if (typeof block === "object" && block !== null) {
|
|
32
|
+
for (const name of Object.keys(block)) names.add(name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return names;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which `defineCollection` to generate against, for collections written to `outDir`.
|
|
40
|
+
*
|
|
41
|
+
* **The detection is the package manifests above the output directory**, unioned from
|
|
42
|
+
* `outDir` up to and including the project root — the first ancestor holding a
|
|
43
|
+
* `rebase.json`, or `MAX_LEVELS` up if there is none.
|
|
44
|
+
*
|
|
45
|
+
* That is the rule Node itself applies: a file in `config/collections` resolves a bare
|
|
46
|
+
* specifier through `config/node_modules`, then `<project>/node_modules`, and so on up.
|
|
47
|
+
* Reading the manifests along that same path answers the only question that matters —
|
|
48
|
+
* *will this import resolve in the project I am writing into* — from the state on disk
|
|
49
|
+
* at the moment of generation. The alternatives are all proxies for it: `rebase.json`'s
|
|
50
|
+
* `apps` block says a CMS scaffold declared an admin app, and a `frontend/` directory
|
|
51
|
+
* says one was scaffolded, but neither is what the compiler consults, and either can be
|
|
52
|
+
* true of a project whose `config` package does not depend on `@rebasepro/admin-types`.
|
|
53
|
+
*
|
|
54
|
+
* Ambiguity resolves towards the admin panel: a project that declares both packages has
|
|
55
|
+
* a panel, and `@rebasepro/admin-types` is the flavour that keeps the `admin` block.
|
|
56
|
+
*/
|
|
57
|
+
export function detectCollectionBuilder(outDir: string): CollectionBuilder {
|
|
58
|
+
let dir = path.resolve(outDir);
|
|
59
|
+
const declared = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (let level = 0; level < MAX_LEVELS; level++) {
|
|
62
|
+
const manifest = path.join(dir, "package.json");
|
|
63
|
+
if (fs.existsSync(manifest)) {
|
|
64
|
+
for (const name of declaredDependencies(manifest)) declared.add(name);
|
|
65
|
+
}
|
|
66
|
+
// The project root, and the last level worth reading: anything above it
|
|
67
|
+
// belongs to whatever the project happens to be checked out inside.
|
|
68
|
+
if (fs.existsSync(path.join(dir, "rebase.json"))) break;
|
|
69
|
+
|
|
70
|
+
const parent = path.dirname(dir);
|
|
71
|
+
if (parent === dir) break;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (declared.has(ADMIN_TYPES_PACKAGE)) return "admin-types";
|
|
76
|
+
if (declared.has(COMMON_PACKAGE)) return "common";
|
|
77
|
+
return "annotation";
|
|
78
|
+
}
|
|
@@ -14,10 +14,11 @@ import {
|
|
|
14
14
|
mergeIndexContent,
|
|
15
15
|
safeHostFromUrl
|
|
16
16
|
} from "./introspect-db-logic";
|
|
17
|
+
import { detectCollectionBuilder } from "./introspect-db-project";
|
|
17
18
|
import { countRowsUpTo, readSchemaMetadata } from "./introspect-db-queries";
|
|
18
19
|
import { classifyTables, lookupCandidates, LOOKUP_MAX_ROWS } from "./introspect-db-structure";
|
|
19
20
|
import { parseCheckConstraints } from "./introspect-db-constraints";
|
|
20
|
-
import {
|
|
21
|
+
import { out, outWarn, outError } from "../cli-output";
|
|
21
22
|
|
|
22
23
|
async function main() {
|
|
23
24
|
const args = arg(
|
|
@@ -59,14 +60,14 @@ async function main() {
|
|
|
59
60
|
|
|
60
61
|
for (const p of envPaths) {
|
|
61
62
|
if (fs.existsSync(p)) {
|
|
62
|
-
dotenv.config({ path: p });
|
|
63
|
+
dotenv.config({ path: p, quiet: true });
|
|
63
64
|
break;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
68
69
|
if (!databaseUrl) {
|
|
69
|
-
|
|
70
|
+
outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
70
71
|
process.exit(1);
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -75,15 +76,15 @@ async function main() {
|
|
|
75
76
|
try {
|
|
76
77
|
await client.connect();
|
|
77
78
|
} catch (err) {
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
outError(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
|
|
80
|
+
outError(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
|
|
80
81
|
process.exit(1);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// Log the host portion safely — handle URLs without "@"
|
|
84
85
|
const hostPart = safeHostFromUrl(databaseUrl);
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
out(chalk.gray(`Connected to database: ${hostPart}`));
|
|
87
|
+
out(chalk.gray(`Introspecting schema '${pgSchema}'...`));
|
|
87
88
|
|
|
88
89
|
try {
|
|
89
90
|
const metadata = await readSchemaMetadata(client, pgSchema);
|
|
@@ -101,12 +102,28 @@ async function main() {
|
|
|
101
102
|
} catch (err) {
|
|
102
103
|
// A table this run cannot read is simply not classified as a code
|
|
103
104
|
// list; everything else about it still generates.
|
|
104
|
-
|
|
105
|
+
out(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
const classifications = classifyTables(metadata, tablesMap);
|
|
109
110
|
const checkFacts = parseCheckConstraints(metadata.checks);
|
|
111
|
+
|
|
112
|
+
// Which builder the generated files may import — read from the manifests
|
|
113
|
+
// above `outDir`, because a project without `@rebasepro/admin-types` cannot
|
|
114
|
+
// resolve that import and has no `admin` block to write into either.
|
|
115
|
+
const builder = detectCollectionBuilder(outDir);
|
|
116
|
+
if (builder === "annotation") {
|
|
117
|
+
outWarn(chalk.yellow(
|
|
118
|
+
"⚠ Neither @rebasepro/admin-types nor @rebasepro/common is declared above " +
|
|
119
|
+
`${outDir}.`
|
|
120
|
+
));
|
|
121
|
+
outWarn(chalk.gray(
|
|
122
|
+
" Generating plain type annotations, which widen the property keys to `string`: " +
|
|
123
|
+
"`propertiesOrder` and friends will not be checked. Add @rebasepro/common to the " +
|
|
124
|
+
"config package to turn that checking on."
|
|
125
|
+
));
|
|
126
|
+
}
|
|
110
127
|
const joinTables = new Set(
|
|
111
128
|
Array.from(classifications.values())
|
|
112
129
|
.filter((c) => c.role === "junction")
|
|
@@ -116,8 +133,8 @@ async function main() {
|
|
|
116
133
|
const roleCount = (role: string) =>
|
|
117
134
|
Array.from(classifications.values()).filter((c) => c.role === role).length;
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
out(chalk.blue(`Found ${tablesMap.size} tables.`));
|
|
137
|
+
out(chalk.gray(
|
|
121
138
|
` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ` +
|
|
122
139
|
`${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`
|
|
123
140
|
));
|
|
@@ -130,7 +147,7 @@ async function main() {
|
|
|
130
147
|
} else if (!process.stdin.isTTY) {
|
|
131
148
|
// No terminal to answer the question below (scaffolding scripts, CI,
|
|
132
149
|
// `rebase init --introspect`) — asking would hang forever.
|
|
133
|
-
|
|
150
|
+
out(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
|
|
134
151
|
} else {
|
|
135
152
|
const rl = readline.createInterface({
|
|
136
153
|
input: process.stdin,
|
|
@@ -142,7 +159,7 @@ async function main() {
|
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
if (runDataInference) {
|
|
145
|
-
|
|
162
|
+
out(chalk.gray("Sampling database rows for data inference..."));
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
// Generate Collections
|
|
@@ -169,7 +186,7 @@ async function main() {
|
|
|
169
186
|
const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
|
|
170
187
|
sampleData = rows;
|
|
171
188
|
} catch (err) {
|
|
172
|
-
|
|
189
|
+
outError(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
|
|
173
190
|
}
|
|
174
191
|
}
|
|
175
192
|
|
|
@@ -181,12 +198,12 @@ async function main() {
|
|
|
181
198
|
tablesMap,
|
|
182
199
|
enumMap,
|
|
183
200
|
sampleData,
|
|
184
|
-
{ metadata, classifications, checkFacts }
|
|
201
|
+
{ metadata, classifications, checkFacts, builder }
|
|
185
202
|
);
|
|
186
203
|
|
|
187
204
|
fs.writeFileSync(filePath, fileContent, "utf-8");
|
|
188
205
|
generatedFiles.push(tableName);
|
|
189
|
-
|
|
206
|
+
out(chalk.green(` ✓ ${filePath}`));
|
|
190
207
|
}));
|
|
191
208
|
}
|
|
192
209
|
|
|
@@ -213,21 +230,21 @@ async function main() {
|
|
|
213
230
|
const indexContent = generateIndexContent(allFiles);
|
|
214
231
|
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
215
232
|
}
|
|
216
|
-
|
|
233
|
+
out(chalk.green(` ✓ ${indexPath}`));
|
|
217
234
|
}
|
|
218
235
|
|
|
219
|
-
|
|
236
|
+
out("");
|
|
220
237
|
if (skippedFiles.length > 0) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
out(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
|
|
239
|
+
out(chalk.gray(" Use --force to overwrite existing files."));
|
|
240
|
+
out("");
|
|
224
241
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
out(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
|
|
243
|
+
out(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
|
|
244
|
+
out("");
|
|
228
245
|
|
|
229
246
|
} catch (e) {
|
|
230
|
-
|
|
247
|
+
outError(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
|
|
231
248
|
process.exit(1);
|
|
232
249
|
} finally {
|
|
233
250
|
await client.end();
|
|
@@ -235,6 +252,6 @@ async function main() {
|
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
main().catch((err) => {
|
|
238
|
-
|
|
255
|
+
outError(String(err));
|
|
239
256
|
process.exit(1);
|
|
240
257
|
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getIconForTable
|
|
29
29
|
} from "./introspect-db-logic";
|
|
30
30
|
import { humanize } from "./introspect-db-naming";
|
|
31
|
+
import { firstFreeKey, toWireKey } from "@rebasepro/utils";
|
|
31
32
|
|
|
32
33
|
export interface IntrospectedSchema {
|
|
33
34
|
tablesMap: Map<string, TableMeta>;
|
|
@@ -169,6 +170,7 @@ function buildProperties(
|
|
|
169
170
|
enumMap: Map<string, string[]>
|
|
170
171
|
): Record<string, Record<string, unknown>> {
|
|
171
172
|
const properties: Record<string, Record<string, unknown>> = {};
|
|
173
|
+
const takenKeys = new Set<string>();
|
|
172
174
|
|
|
173
175
|
for (const col of meta.columns) {
|
|
174
176
|
const isPk = meta.pks.includes(col.column_name);
|
|
@@ -188,7 +190,17 @@ function buildProperties(
|
|
|
188
190
|
type: propType
|
|
189
191
|
};
|
|
190
192
|
|
|
191
|
-
|
|
193
|
+
// The wire name, with `columnName` above carrying the column. The two
|
|
194
|
+
// are different names for different things — this used to serve the
|
|
195
|
+
// column, so a runtime-introspected collection answered `user_id` while
|
|
196
|
+
// every authored one beside it answered `displayName`.
|
|
197
|
+
//
|
|
198
|
+
// `user_id` and `userId` as two real columns camel-case to one key, so
|
|
199
|
+
// the first free candidate wins and the second falls back to its own
|
|
200
|
+
// column name, then to a numbered tail. Never dropped, and stable for a
|
|
201
|
+
// given database: columns arrive in ordinal order.
|
|
202
|
+
const key = firstFreeKey([toWireKey(col.column_name), col.column_name], takenKeys);
|
|
203
|
+
takenKeys.add(key);
|
|
192
204
|
|
|
193
205
|
if (isPk) {
|
|
194
206
|
property.isId = idKindFor(col, propType);
|
|
@@ -239,7 +251,7 @@ function buildRelations(
|
|
|
239
251
|
if (!targetSlug) continue;
|
|
240
252
|
|
|
241
253
|
// Strip the conventional _id suffix: author_id -> author
|
|
242
|
-
let key = fk.column_name.replace(/_id$/, "");
|
|
254
|
+
let key = toWireKey(fk.column_name.replace(/_id$/, ""));
|
|
243
255
|
if (meta.pks.includes(fk.column_name) && key === fk.column_name) {
|
|
244
256
|
// The FK is also the PK and its name doesn't imply a relation (e.g.
|
|
245
257
|
// "id"), so naming the relation after the column would collide with
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
DEFAULT_SEARCH_WEIGHT,
|
|
59
59
|
DEFAULT_FUZZY_THRESHOLD
|
|
60
60
|
} from "@rebasepro/types";
|
|
61
|
+
import { createHash } from "node:crypto";
|
|
61
62
|
import { getTableName } from "@rebasepro/common";
|
|
62
63
|
import { toSnakeCase, toPostgresIdentifier } from "@rebasepro/utils";
|
|
63
64
|
|
|
@@ -460,6 +461,90 @@ export const searchIndexStatements = (spec: SearchColumnSpec): string[] => {
|
|
|
460
461
|
return statements;
|
|
461
462
|
};
|
|
462
463
|
|
|
464
|
+
// ── Telling a changed `search` block from an unchanged one ──────────────────
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Marker on the comment of every generated search column this module creates.
|
|
468
|
+
*
|
|
469
|
+
* Versioned because the fingerprint below is only comparable against itself: a
|
|
470
|
+
* future change to how it is computed has to read as "not stamped by this
|
|
471
|
+
* version" rather than as drift on every existing column.
|
|
472
|
+
*/
|
|
473
|
+
export const SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A stable fingerprint of one generated column's expression.
|
|
477
|
+
*
|
|
478
|
+
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
479
|
+
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
480
|
+
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
481
|
+
* according to `search_path`. Comparing that text to the text we generated
|
|
482
|
+
* would report drift on wording, and this comparison decides whether a boot
|
|
483
|
+
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
484
|
+
* code that writes the column, so equality means what it says.
|
|
485
|
+
*/
|
|
486
|
+
export const searchExpressionFingerprint = (expression: string): string =>
|
|
487
|
+
`${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
488
|
+
|
|
489
|
+
/** One generated column, with the fingerprint that identifies its expression. */
|
|
490
|
+
export interface SearchColumnStamp {
|
|
491
|
+
column: string;
|
|
492
|
+
/** The expression the column is generated from. */
|
|
493
|
+
expression: string;
|
|
494
|
+
fingerprint: string;
|
|
495
|
+
/** `COMMENT ON COLUMN …`, which is where the fingerprint is recorded. */
|
|
496
|
+
sql: string;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The stamps for a spec's generated columns — one per column, never shared.
|
|
501
|
+
*
|
|
502
|
+
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
503
|
+
* nothing about the first, and a spec-wide fingerprint would report the
|
|
504
|
+
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
505
|
+
* is purely additive.
|
|
506
|
+
*/
|
|
507
|
+
export const searchColumnStamps = (spec: SearchColumnSpec): SearchColumnStamp[] => {
|
|
508
|
+
const stamp = (column: string, expression: string): SearchColumnStamp => {
|
|
509
|
+
const fingerprint = searchExpressionFingerprint(expression);
|
|
510
|
+
return {
|
|
511
|
+
column,
|
|
512
|
+
expression,
|
|
513
|
+
fingerprint,
|
|
514
|
+
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
515
|
+
};
|
|
516
|
+
};
|
|
517
|
+
const stamps = [stamp(spec.column, spec.expression)];
|
|
518
|
+
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
519
|
+
return stamps;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The same drift check as the boot ensure, for the SQL file.
|
|
524
|
+
*
|
|
525
|
+
* Needed because {@link searchColumnStamps} would otherwise *launder* drift on
|
|
526
|
+
* the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that
|
|
527
|
+
* exists, so a re-generated `search.sql` would stamp a stale column with the
|
|
528
|
+
* new block's fingerprint and the next boot would find them in agreement.
|
|
529
|
+
* Guarding first means the file refuses instead — `rebase db push` is attended,
|
|
530
|
+
* and the operator reading the failure is the person who changed the block.
|
|
531
|
+
*/
|
|
532
|
+
export const searchStampGuards = (spec: SearchColumnSpec): string[] =>
|
|
533
|
+
searchColumnStamps(spec).map(stamp => {
|
|
534
|
+
const relation = quote(`"${spec.schema}"."${spec.table}"`);
|
|
535
|
+
return `DO $rebase_search$
|
|
536
|
+
DECLARE recorded text;
|
|
537
|
+
BEGIN
|
|
538
|
+
SELECT col_description(a.attrelid, a.attnum) INTO recorded
|
|
539
|
+
FROM pg_attribute a
|
|
540
|
+
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;
|
|
541
|
+
IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN
|
|
542
|
+
RAISE EXCEPTION 'Rebase: the search block for ${spec.schema}.${spec.table} changed after the generated column "${stamp.column}" was built (recorded %, expected ${stamp.fingerprint}). Postgres cannot alter a generated expression in place. Drop the column and re-apply this file — it rewrites the table and rebuilds the index: ALTER TABLE ${relation.slice(1, -1)} DROP COLUMN "${stamp.column}";', recorded;
|
|
543
|
+
END IF;
|
|
544
|
+
END
|
|
545
|
+
$rebase_search$;`;
|
|
546
|
+
});
|
|
547
|
+
|
|
463
548
|
/**
|
|
464
549
|
* The index names the spec creates.
|
|
465
550
|
*
|
|
@@ -18,9 +18,50 @@ function collection(slug: string): CollectionConfig {
|
|
|
18
18
|
} as unknown as CollectionConfig;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
function
|
|
23
|
-
return {
|
|
21
|
+
/** A collection whose write rule is declared RESTRICTIVE, so the DDL says `AS RESTRICTIVE`. */
|
|
22
|
+
function restrictiveCollection(slug: string): CollectionConfig {
|
|
23
|
+
return {
|
|
24
|
+
name: slug,
|
|
25
|
+
slug,
|
|
26
|
+
table: slug,
|
|
27
|
+
schema: "public",
|
|
28
|
+
properties: { id: { name: "ID", type: "string", isId: "uuid" } },
|
|
29
|
+
securityRules: [
|
|
30
|
+
{ operation: "select", access: "public" },
|
|
31
|
+
{ operations: ["insert", "update", "delete"], roles: ["admin"], mode: "restrictive" }
|
|
32
|
+
]
|
|
33
|
+
} as unknown as CollectionConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stands in for the two catalogue reads: `pg_policies`, then `pg_class`.
|
|
38
|
+
*
|
|
39
|
+
* The second one is dispatched on the query text rather than call order, so a
|
|
40
|
+
* test that stops caring about ordering does not silently start feeding policy
|
|
41
|
+
* rows to the RLS-status read — which would report every table as RLS-off,
|
|
42
|
+
* since a policy row has no `relrowsecurity` column and `undefined` is falsy.
|
|
43
|
+
*
|
|
44
|
+
* `rlsOff` names tables whose RLS switch is off. Anything not listed is on,
|
|
45
|
+
* which is what a healthy `db push` leaves behind.
|
|
46
|
+
*/
|
|
47
|
+
function dbWith(rows: Record<string, unknown>[], rlsOff: string[] = []): Queryable {
|
|
48
|
+
return {
|
|
49
|
+
query: async (text: string, values?: unknown[]) => {
|
|
50
|
+
if (!text.includes("pg_class")) return { rows: rows as never[] };
|
|
51
|
+
const wanted = (values?.[0] as string[] | undefined) ?? [];
|
|
52
|
+
return {
|
|
53
|
+
rows: wanted.map((qualified) => {
|
|
54
|
+
const [schemaname, tablename] = qualified.split(".");
|
|
55
|
+
return {
|
|
56
|
+
schemaname,
|
|
57
|
+
tablename,
|
|
58
|
+
relrowsecurity: !rlsOff.includes(tablename),
|
|
59
|
+
relforcerowsecurity: !rlsOff.includes(tablename)
|
|
60
|
+
};
|
|
61
|
+
}) as never[]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
24
65
|
}
|
|
25
66
|
|
|
26
67
|
/** A pg_policies row matching an expected policy, clauses and all. */
|
|
@@ -30,6 +71,7 @@ function liveRow(p: PolicyRef, overrides: Record<string, unknown> = {}) {
|
|
|
30
71
|
// Postgres rewrites the text it stores; only presence is compared.
|
|
31
72
|
qual: p.hasUsing ? "(rewritten by postgres)" : null,
|
|
32
73
|
with_check: p.hasWithCheck ? "(rewritten by postgres)" : null,
|
|
74
|
+
permissive: p.mode ?? "PERMISSIVE",
|
|
33
75
|
...overrides
|
|
34
76
|
};
|
|
35
77
|
}
|
|
@@ -56,6 +98,65 @@ describe("checkPolicyDrift", () => {
|
|
|
56
98
|
expect(formatPolicyDrift(drift)).toBe("");
|
|
57
99
|
});
|
|
58
100
|
|
|
101
|
+
it("flags a table whose RLS switch is off, though every policy still matches", async () => {
|
|
102
|
+
// The whole point: DISABLE ROW LEVEL SECURITY leaves pg_policies
|
|
103
|
+
// untouched, so name, roles, command and clause presence all agree and
|
|
104
|
+
// every other check here passes. Before this category the report was
|
|
105
|
+
// empty on a table Postgres was filtering nothing on.
|
|
106
|
+
const cols = [collection("authors")];
|
|
107
|
+
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
108
|
+
|
|
109
|
+
const drift = await checkPolicyDrift(
|
|
110
|
+
dbWith(expected.map((p) => liveRow(p)), ["authors"]),
|
|
111
|
+
cols
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(drift.missing).toHaveLength(0);
|
|
115
|
+
expect(drift.diverged).toHaveLength(0);
|
|
116
|
+
expect(drift.orphaned).toHaveLength(0);
|
|
117
|
+
expect(drift.rlsDisabled).toEqual([{ schema: "public", table: "authors", forced: false }]);
|
|
118
|
+
expect(hasDrift(drift)).toBe(true);
|
|
119
|
+
expect(formatPolicyDrift(drift)).toContain("RLS DISABLED");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("flags a RESTRICTIVE rule the database stores as PERMISSIVE", async () => {
|
|
123
|
+
// A gate that should be ANDed in is now ORed in — it widens access
|
|
124
|
+
// instead of narrowing it, with identical name, roles, command and
|
|
125
|
+
// clauses. Only the `AS` clause differs, and it used to be discarded by
|
|
126
|
+
// the DDL parser and never selected from pg_policies.
|
|
127
|
+
const cols = [restrictiveCollection("ledger")];
|
|
128
|
+
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
129
|
+
const restrictive = expected.filter((p) => p.mode === "RESTRICTIVE");
|
|
130
|
+
// Guards the test itself: if the generator stops emitting AS RESTRICTIVE
|
|
131
|
+
// this must fail loudly rather than pass by having nothing to compare.
|
|
132
|
+
expect(restrictive.length).toBeGreaterThan(0);
|
|
133
|
+
|
|
134
|
+
const live = expected.map((p) =>
|
|
135
|
+
p.name === restrictive[0].name ? liveRow(p, { permissive: "PERMISSIVE" }) : liveRow(p)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
139
|
+
|
|
140
|
+
expect(drift.diverged).toHaveLength(1);
|
|
141
|
+
expect(drift.diverged[0].differences.join(" ")).toContain("mode: expected RESTRICTIVE");
|
|
142
|
+
expect(formatPolicyDrift(drift)).toContain("widens access");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does not invent drift when either side's mode is unreadable", async () => {
|
|
146
|
+
// An older server or a driver that hands back a shape we do not know
|
|
147
|
+
// must not turn into a wall of false positives.
|
|
148
|
+
const cols = [collection("authors")];
|
|
149
|
+
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
150
|
+
|
|
151
|
+
const drift = await checkPolicyDrift(
|
|
152
|
+
dbWith(expected.map((p) => liveRow(p, { permissive: null }))),
|
|
153
|
+
cols
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(drift.diverged).toHaveLength(0);
|
|
157
|
+
expect(hasDrift(drift)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
59
160
|
it("flags a policy the database never received as missing", async () => {
|
|
60
161
|
const drift = await checkPolicyDrift(dbWith([]), [collection("authors")]);
|
|
61
162
|
|