@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133
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/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -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-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- 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/auth-users-columns.d.ts +97 -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 +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- 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/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- 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 +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- 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 +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- 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/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +253 -115
- package/src/services/PersistService.ts +82 -43
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/schema/doctor.ts
CHANGED
|
@@ -15,9 +15,11 @@ import chalk from "chalk";
|
|
|
15
15
|
import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
|
|
16
16
|
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
17
17
|
import { generateTypedefs } from "@rebasepro/codegen";
|
|
18
|
-
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
18
|
+
import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
|
|
19
19
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
|
-
import {
|
|
20
|
+
import { loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
21
|
+
// The report is CLI output, not application logging — see cli-output.ts.
|
|
22
|
+
import { out, outError } from "../cli-output";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Resolve the SQL column name for a property.
|
|
@@ -37,7 +39,7 @@ export type IssueSeverity = "error" | "warning" | "info";
|
|
|
37
39
|
|
|
38
40
|
export interface DoctorIssue {
|
|
39
41
|
severity: IssueSeverity;
|
|
40
|
-
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
|
|
42
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated" | "sdk_ungeneratable";
|
|
41
43
|
table?: string;
|
|
42
44
|
column?: string;
|
|
43
45
|
expected?: string;
|
|
@@ -46,11 +48,44 @@ export interface DoctorIssue {
|
|
|
46
48
|
fix: string;
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
export interface DoctorPhase {
|
|
52
|
+
passed: boolean;
|
|
53
|
+
issues: DoctorIssue[];
|
|
54
|
+
/**
|
|
55
|
+
* Why this phase never ran, when it did not.
|
|
56
|
+
*
|
|
57
|
+
* A check that did not happen is a third state, not a passing one. While a
|
|
58
|
+
* skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
|
|
59
|
+
* `✅ Collections → Database: In sync` and counted towards
|
|
60
|
+
* `✓ All schemas are in sync!` — so a project whose connection string was
|
|
61
|
+
* spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
|
|
62
|
+
* ticks and exit 0 against a database with no tables in it.
|
|
63
|
+
*/
|
|
64
|
+
skipped?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Why this phase had nothing to compare against, when it had nothing.
|
|
67
|
+
*
|
|
68
|
+
* Distinct from `skipped`, which means "the check could not run, and that
|
|
69
|
+
* is probably worth fixing". This one means "the artifact is optional and
|
|
70
|
+
* you have not asked for it": no drift is possible, so the run is still a
|
|
71
|
+
* clean bill of health.
|
|
72
|
+
*
|
|
73
|
+
* It exists because the alternative was a contradiction. The typed-SDK
|
|
74
|
+
* phase returned `{ passed: true }` when `generated/sdk/database.types.ts`
|
|
75
|
+
* did not exist, so a fresh project's report read
|
|
76
|
+
* `✅ Collections → SDK Types: In sync` directly above
|
|
77
|
+
* `ℹ Typed SDK not generated (optional).` — one line calling a file
|
|
78
|
+
* synchronised and the next saying it is absent. "In sync" is a claim about
|
|
79
|
+
* a comparison, and no comparison happened.
|
|
80
|
+
*/
|
|
81
|
+
notApplicable?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
49
84
|
export interface DoctorReport {
|
|
50
|
-
collectionsToSchema:
|
|
51
|
-
collectionsToSdk:
|
|
52
|
-
schemaToDatabase:
|
|
53
|
-
summary: { passed: number; warnings: number; errors: number };
|
|
85
|
+
collectionsToSchema: DoctorPhase;
|
|
86
|
+
collectionsToSdk: DoctorPhase;
|
|
87
|
+
schemaToDatabase: DoctorPhase;
|
|
88
|
+
summary: { passed: number; skipped: number; notApplicable: number; warnings: number; errors: number };
|
|
54
89
|
}
|
|
55
90
|
|
|
56
91
|
// ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
|
|
@@ -149,7 +184,7 @@ export async function loadCollections(collectionsPath: string): Promise<Collecti
|
|
|
149
184
|
export async function checkCollectionsVsSchema(
|
|
150
185
|
collections: CollectionConfig[],
|
|
151
186
|
schemaFilePath: string
|
|
152
|
-
): Promise<
|
|
187
|
+
): Promise<DoctorPhase> {
|
|
153
188
|
const issues: DoctorIssue[] = [];
|
|
154
189
|
|
|
155
190
|
// Check if schema file exists
|
|
@@ -164,8 +199,10 @@ export async function checkCollectionsVsSchema(
|
|
|
164
199
|
issues };
|
|
165
200
|
}
|
|
166
201
|
|
|
167
|
-
// Re-generate schema in-memory and compare with file on disk
|
|
168
|
-
|
|
202
|
+
// Re-generate schema in-memory and compare with file on disk. Only the
|
|
203
|
+
// collections the generator itself will emit — a Firestore collection has
|
|
204
|
+
// no table in the generated file and must not be reported as missing one.
|
|
205
|
+
const postgresCollections = relationalCollections(collections);
|
|
169
206
|
if (postgresCollections.length === 0) {
|
|
170
207
|
return { passed: true,
|
|
171
208
|
issues };
|
|
@@ -208,7 +245,7 @@ issues };
|
|
|
208
245
|
export async function checkCollectionsVsSdk(
|
|
209
246
|
collections: CollectionConfig[],
|
|
210
247
|
sdkFilePath: string
|
|
211
|
-
): Promise<
|
|
248
|
+
): Promise<DoctorPhase> {
|
|
212
249
|
const issues: DoctorIssue[] = [];
|
|
213
250
|
|
|
214
251
|
// The typed SDK is opt-in — nothing in a scaffolded project imports it until
|
|
@@ -223,7 +260,8 @@ export async function checkCollectionsVsSdk(
|
|
|
223
260
|
fix: "Run `rebase generate-sdk` if you want typed collection access"
|
|
224
261
|
});
|
|
225
262
|
return { passed: true,
|
|
226
|
-
issues
|
|
263
|
+
issues,
|
|
264
|
+
notApplicable: "not generated (optional)" };
|
|
227
265
|
}
|
|
228
266
|
|
|
229
267
|
try {
|
|
@@ -248,12 +286,27 @@ issues };
|
|
|
248
286
|
}
|
|
249
287
|
} catch (err: unknown) {
|
|
250
288
|
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
289
|
+
|
|
290
|
+
// A `CodegenError` is not "the comparison did not run" — it is the
|
|
291
|
+
// schema saying it cannot produce a valid typed client at all, most
|
|
292
|
+
// often two slugs collapsing onto one accessor. Reporting that as a
|
|
293
|
+
// warning about staleness described the wrong problem and pointed at a
|
|
294
|
+
// command that would fail the same way.
|
|
295
|
+
if (err instanceof Error && err.name === "CodegenError") {
|
|
296
|
+
issues.push({
|
|
297
|
+
severity: "error",
|
|
298
|
+
category: "sdk_ungeneratable",
|
|
299
|
+
message: `The typed SDK cannot be generated from these collections: ${message}`,
|
|
300
|
+
fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
issues.push({
|
|
304
|
+
severity: "warning",
|
|
305
|
+
category: "sdk_stale",
|
|
306
|
+
message: `Could not regenerate SDK types for comparison: ${message}`,
|
|
307
|
+
fix: "Run `rebase generate-sdk` to verify"
|
|
308
|
+
});
|
|
309
|
+
}
|
|
257
310
|
}
|
|
258
311
|
|
|
259
312
|
return { passed: issues.length === 0,
|
|
@@ -280,7 +333,7 @@ interface DbEnumValue {
|
|
|
280
333
|
export async function checkCollectionsVsDatabase(
|
|
281
334
|
collections: CollectionConfig[],
|
|
282
335
|
databaseUrl: string
|
|
283
|
-
): Promise<
|
|
336
|
+
): Promise<DoctorPhase> {
|
|
284
337
|
const issues: DoctorIssue[] = [];
|
|
285
338
|
|
|
286
339
|
// Dynamic import to avoid loading pg when not needed
|
|
@@ -292,7 +345,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
292
345
|
const schemas = Array.from(new Set([
|
|
293
346
|
"public",
|
|
294
347
|
"rebase",
|
|
295
|
-
...collections
|
|
348
|
+
...relationalCollections(collections)
|
|
296
349
|
.filter(isPostgresCollectionConfig)
|
|
297
350
|
.map(c => c.schema)
|
|
298
351
|
.filter((s): s is string => !!s)
|
|
@@ -383,7 +436,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
383
436
|
|
|
384
437
|
// ── Compare each collection against the database ─────────────────
|
|
385
438
|
|
|
386
|
-
const postgresCollections = collections
|
|
439
|
+
const postgresCollections = relationalCollections(collections);
|
|
387
440
|
|
|
388
441
|
for (const collection of postgresCollections) {
|
|
389
442
|
const tableName = getTableName(collection);
|
|
@@ -595,96 +648,112 @@ issues };
|
|
|
595
648
|
// ── Report Rendering ─────────────────────────────────────────────────────
|
|
596
649
|
|
|
597
650
|
export function renderReport(report: DoctorReport): void {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
651
|
+
out();
|
|
652
|
+
out(chalk.bold(" 🩺 Rebase Schema Doctor"));
|
|
653
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
654
|
+
out();
|
|
602
655
|
|
|
603
656
|
// Phase 1
|
|
604
|
-
renderPhase(
|
|
605
|
-
"Collections → Generated Schema",
|
|
606
|
-
report.collectionsToSchema.passed,
|
|
607
|
-
report.collectionsToSchema.issues
|
|
608
|
-
);
|
|
657
|
+
renderPhase("Collections → Generated Schema", report.collectionsToSchema);
|
|
609
658
|
|
|
610
659
|
// Phase 2
|
|
611
|
-
renderPhase(
|
|
612
|
-
"Collections → Database",
|
|
613
|
-
report.schemaToDatabase.passed,
|
|
614
|
-
report.schemaToDatabase.issues
|
|
615
|
-
);
|
|
660
|
+
renderPhase("Collections → Database", report.schemaToDatabase);
|
|
616
661
|
|
|
617
662
|
// Phase 3
|
|
618
|
-
renderPhase(
|
|
619
|
-
"Collections → SDK Types",
|
|
620
|
-
report.collectionsToSdk.passed,
|
|
621
|
-
report.collectionsToSdk.issues
|
|
622
|
-
);
|
|
663
|
+
renderPhase("Collections → SDK Types", report.collectionsToSdk);
|
|
623
664
|
|
|
624
665
|
// Summary
|
|
625
|
-
|
|
626
|
-
const { passed, warnings, errors } = report.summary;
|
|
666
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
667
|
+
const { passed, skipped, notApplicable, warnings, errors } = report.summary;
|
|
627
668
|
|
|
628
669
|
const parts: string[] = [];
|
|
629
670
|
parts.push(chalk.green(`${passed} passed`));
|
|
671
|
+
if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
|
|
672
|
+
if (notApplicable > 0) parts.push(chalk.gray(`${notApplicable} not applicable`));
|
|
630
673
|
if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
|
|
631
674
|
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
632
675
|
|
|
633
|
-
|
|
634
|
-
|
|
676
|
+
out(` Summary: ${parts.join(", ")}`);
|
|
677
|
+
out();
|
|
635
678
|
|
|
636
679
|
if (errors > 0) {
|
|
637
|
-
|
|
680
|
+
out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
|
|
638
681
|
} else if (warnings > 0) {
|
|
639
|
-
|
|
682
|
+
out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
|
|
683
|
+
} else if (skipped > 0) {
|
|
684
|
+
// Never "all schemas are in sync" off the back of a check that did not
|
|
685
|
+
// run: a clean bill of health has to come from measurement.
|
|
686
|
+
out(chalk.yellow.bold(` ⚠ Nothing wrong in the checks that ran, but ${skipped} did not run — this is not a clean bill of health.`));
|
|
640
687
|
} else {
|
|
641
|
-
|
|
688
|
+
out(chalk.green.bold(" ✓ All schemas are in sync!"));
|
|
642
689
|
}
|
|
643
|
-
|
|
690
|
+
out();
|
|
644
691
|
}
|
|
645
692
|
|
|
646
|
-
function renderPhase(label: string,
|
|
693
|
+
function renderPhase(label: string, phase: DoctorPhase): void {
|
|
694
|
+
// A phase that never ran gets its own marker and its reason. It is neither
|
|
695
|
+
// "In sync" nor a drift report; conflating it with the former is what let
|
|
696
|
+
// doctor certify a database it had not opened.
|
|
697
|
+
if (phase.skipped) {
|
|
698
|
+
out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
|
|
699
|
+
out();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Nothing to compare against. Its own marker, and only the remedy under it
|
|
704
|
+
// — the phase header already carries the reason, so re-printing the note
|
|
705
|
+
// would say the same thing twice. See DoctorPhase.notApplicable.
|
|
706
|
+
if (phase.notApplicable) {
|
|
707
|
+
out(` ${chalk.gray("➖")} ${label}: ${chalk.gray(phase.notApplicable)}`);
|
|
708
|
+
for (const issue of phase.issues.filter((i) => i.severity === "info")) {
|
|
709
|
+
out(` ${chalk.gray(issue.fix)}`);
|
|
710
|
+
}
|
|
711
|
+
out();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const issues = phase.issues;
|
|
647
716
|
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
648
717
|
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
649
718
|
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
650
719
|
|
|
651
720
|
// Informational notes don't make a phase unhealthy, so key the header off
|
|
652
|
-
// real problems rather than `passed` alone.
|
|
721
|
+
// real problems rather than `phase.passed` alone.
|
|
653
722
|
if (errorCount === 0 && warnCount === 0) {
|
|
654
|
-
|
|
723
|
+
out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
655
724
|
} else {
|
|
656
725
|
const parts: string[] = [];
|
|
657
726
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
658
727
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
659
|
-
|
|
728
|
+
out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
|
|
660
729
|
}
|
|
661
|
-
|
|
730
|
+
out();
|
|
662
731
|
|
|
663
732
|
// Notes render as a quiet one-liner, not a full drift box.
|
|
664
733
|
for (const issue of infoIssues) {
|
|
665
734
|
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
666
|
-
|
|
667
|
-
|
|
735
|
+
out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
736
|
+
out();
|
|
668
737
|
}
|
|
669
738
|
|
|
670
739
|
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
671
740
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
672
741
|
const categoryLabel = formatCategory(issue.category);
|
|
673
|
-
|
|
742
|
+
out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
674
743
|
|
|
675
744
|
if (issue.table) {
|
|
676
745
|
const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
|
|
677
|
-
|
|
746
|
+
out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
|
|
678
747
|
}
|
|
679
748
|
|
|
680
749
|
if (issue.expected && issue.actual) {
|
|
681
|
-
|
|
750
|
+
out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
|
|
682
751
|
}
|
|
683
752
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
753
|
+
out(` ${chalk.gray("│")} ${issue.message}`);
|
|
754
|
+
out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
|
|
755
|
+
out(` ${chalk.gray("└" + "─".repeat(48))}`);
|
|
756
|
+
out();
|
|
688
757
|
}
|
|
689
758
|
}
|
|
690
759
|
|
|
@@ -699,7 +768,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
699
768
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
700
769
|
missing_foreign_key: "Missing Foreign Key",
|
|
701
770
|
sdk_stale: "Stale SDK Types",
|
|
702
|
-
sdk_not_generated: "SDK Types Not Generated"
|
|
771
|
+
sdk_not_generated: "SDK Types Not Generated",
|
|
772
|
+
sdk_ungeneratable: "SDK Cannot Be Generated"
|
|
703
773
|
};
|
|
704
774
|
return labels[cat];
|
|
705
775
|
}
|
|
@@ -712,38 +782,47 @@ export async function runDoctor(options: {
|
|
|
712
782
|
sdkPath: string;
|
|
713
783
|
databaseUrl?: string;
|
|
714
784
|
}): Promise<DoctorReport> {
|
|
715
|
-
|
|
716
|
-
|
|
785
|
+
out();
|
|
786
|
+
out(chalk.bold(" 🩺 Loading collections..."));
|
|
717
787
|
const collections = await loadCollections(options.collectionsPath);
|
|
718
788
|
if (collections.length === 0) {
|
|
719
|
-
|
|
789
|
+
outError(chalk.red(" ✗ No collections found."));
|
|
720
790
|
process.exit(1);
|
|
721
791
|
}
|
|
722
|
-
|
|
723
|
-
|
|
792
|
+
out(chalk.gray(` Found ${collections.length} collection(s)`));
|
|
793
|
+
out();
|
|
724
794
|
|
|
725
795
|
// Phase 1: Collections ↔ Generated Schema
|
|
726
|
-
|
|
796
|
+
out(chalk.gray(" Checking Collections → Generated Schema..."));
|
|
727
797
|
const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
|
|
728
798
|
|
|
729
799
|
// Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
|
|
730
|
-
let schemaToDatabase:
|
|
731
|
-
issues: [] };
|
|
800
|
+
let schemaToDatabase: DoctorPhase;
|
|
732
801
|
if (options.databaseUrl) {
|
|
733
|
-
|
|
802
|
+
out(chalk.gray(" Checking Collections → Database..."));
|
|
734
803
|
schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
|
|
735
804
|
} else {
|
|
736
|
-
|
|
737
|
-
|
|
805
|
+
// Not `{ passed: true }`: see DoctorPhase.skipped.
|
|
806
|
+
schemaToDatabase = { passed: false,
|
|
807
|
+
issues: [],
|
|
808
|
+
skipped: "DATABASE_URL not set" };
|
|
809
|
+
out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
|
|
810
|
+
out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
|
|
738
811
|
}
|
|
739
812
|
|
|
740
813
|
// Phase 3: Collections ↔ SDK Types
|
|
741
|
-
|
|
814
|
+
out(chalk.gray(" Checking Collections → SDK Types..."));
|
|
742
815
|
const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
|
|
743
816
|
|
|
744
|
-
const
|
|
817
|
+
const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
|
|
818
|
+
const allIssues = phases.flatMap((p) => p.issues);
|
|
745
819
|
const summary = {
|
|
746
|
-
|
|
820
|
+
// A skipped phase is not a passing one, however few issues it collected.
|
|
821
|
+
// Nor is one with nothing to compare against: "passed" would put a
|
|
822
|
+
// never-generated SDK in the same column as a verified one.
|
|
823
|
+
passed: phases.filter((p) => p.passed && !p.skipped && !p.notApplicable).length,
|
|
824
|
+
skipped: phases.filter((p) => p.skipped).length,
|
|
825
|
+
notApplicable: phases.filter((p) => p.notApplicable && !p.skipped).length,
|
|
747
826
|
warnings: allIssues.filter((i) => i.severity === "warning").length,
|
|
748
827
|
errors: allIssues.filter((i) => i.severity === "error").length
|
|
749
828
|
};
|
|
@@ -32,8 +32,10 @@
|
|
|
32
32
|
* exactly which collection is not yet servable and why.
|
|
33
33
|
*/
|
|
34
34
|
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
-
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
35
|
+
import { planCollectionPolicies, type CollectionPolicyPlan } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { isGeneratedPolicyName } from "../security/policy-drift";
|
|
36
37
|
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
38
|
+
import { REBASE_USER_ROLE } from "../security/rls-enforcement";
|
|
37
39
|
|
|
38
40
|
export interface PolicyEnsureResult {
|
|
39
41
|
/** `CREATE POLICY` statements that ran successfully. */
|
|
@@ -42,8 +44,36 @@ export interface PolicyEnsureResult {
|
|
|
42
44
|
tablesSecured: number;
|
|
43
45
|
/** Declared tables absent from the database — left to a real migration. */
|
|
44
46
|
skipped: { table: string; reason: string }[];
|
|
45
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Tables that have RLS on but did not get every policy. They deny — RLS
|
|
49
|
+
* with no matching policy is deny-all — so they are safe but not servable.
|
|
50
|
+
*/
|
|
46
51
|
failures: { table: string; error: string }[];
|
|
52
|
+
/**
|
|
53
|
+
* Tables RLS could not be enabled on, whose DML grant was withdrawn instead.
|
|
54
|
+
*
|
|
55
|
+
* This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`
|
|
56
|
+
* failing was recorded as a `failure` and reported with the same "it stays
|
|
57
|
+
* locked (denies)" wording as a failed policy — but the two are opposites.
|
|
58
|
+
* A policy statement failing leaves RLS on and the table denying. `enableRls`
|
|
59
|
+
* failing leaves RLS *off*, and the schema-wide grant to the user role has
|
|
60
|
+
* already been made by `ensureRlsEnforcement`, so the table is readable and
|
|
61
|
+
* writable by every authenticated request with no row filtering at all.
|
|
62
|
+
*/
|
|
63
|
+
unsecured: { table: string; error: string; grantWithdrawn: boolean }[];
|
|
64
|
+
/**
|
|
65
|
+
* Generated policies removed because no current rule produces them.
|
|
66
|
+
*
|
|
67
|
+
* A policy's name embeds a hash of the rule's semantics, so editing a rule
|
|
68
|
+
* does not update a policy — it creates a new one and abandons the old.
|
|
69
|
+
* Postgres ORs permissive policies, so the abandoned one keeps granting:
|
|
70
|
+
* a `USING (true)` tightened to an owner check went on admitting everyone,
|
|
71
|
+
* forever, while the deploy logged success.
|
|
72
|
+
*
|
|
73
|
+
* `db push` reconciles this, and cannot reach a managed tenant's in-cluster
|
|
74
|
+
* database — which is the reason this module exists. So boot has to do it.
|
|
75
|
+
*/
|
|
76
|
+
orphansDropped: number;
|
|
47
77
|
}
|
|
48
78
|
|
|
49
79
|
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
@@ -56,12 +86,46 @@ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test
|
|
|
56
86
|
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
87
|
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
88
|
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Remove generated policies on this table that the current plan does not
|
|
92
|
+
* produce.
|
|
93
|
+
*
|
|
94
|
+
* Scoped hard, because dropping a policy is destructive: only this one table,
|
|
95
|
+
* only names matching the generated `<table>_<op>_<sha1>` shape, and only names
|
|
96
|
+
* absent from the statements just applied. A hand-written policy, or one
|
|
97
|
+
* belonging to another table, is never touched — `isGeneratedPolicyName` is the
|
|
98
|
+
* same predicate `db push` uses to draw that line.
|
|
99
|
+
*/
|
|
100
|
+
async function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {
|
|
101
|
+
const expected = new Set<string>();
|
|
102
|
+
for (const statement of plan.policyStatements) {
|
|
103
|
+
const match = /^\s*CREATE POLICY\s+"([^"]+)"/i.exec(statement);
|
|
104
|
+
if (match) expected.add(match[1]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const existing = await client.query<{ policyname: string }>(
|
|
108
|
+
`SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, "''")}' ` +
|
|
109
|
+
`AND tablename = '${plan.table.replace(/'/g, "''")}'`
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let dropped = 0;
|
|
113
|
+
for (const row of existing.rows) {
|
|
114
|
+
const name = row.policyname;
|
|
115
|
+
if (expected.has(name)) continue;
|
|
116
|
+
if (!isGeneratedPolicyName(name, plan.table)) continue;
|
|
117
|
+
await client.query(`DROP POLICY IF EXISTS "${name}" ON "${plan.schema}"."${plan.table}"`);
|
|
118
|
+
dropped++;
|
|
119
|
+
}
|
|
120
|
+
return dropped;
|
|
121
|
+
}
|
|
122
|
+
|
|
59
123
|
export async function ensureCollectionPolicies(
|
|
60
124
|
client: Queryable,
|
|
61
125
|
collections: CollectionConfig[],
|
|
62
126
|
log?: (message: string) => void
|
|
63
127
|
): Promise<PolicyEnsureResult> {
|
|
64
|
-
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
128
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };
|
|
65
129
|
|
|
66
130
|
const plans = planCollectionPolicies(collections);
|
|
67
131
|
if (plans.length === 0) return result;
|
|
@@ -78,12 +142,36 @@ export async function ensureCollectionPolicies(
|
|
|
78
142
|
continue;
|
|
79
143
|
}
|
|
80
144
|
|
|
145
|
+
// Enabling RLS is its own step, because its failure is the opposite of
|
|
146
|
+
// every other failure here. Once RLS is on, anything that goes wrong
|
|
147
|
+
// afterwards leaves the table denying; while it is off, the grant made
|
|
148
|
+
// earlier in boot leaves the table wide open. Sharing one `try` meant
|
|
149
|
+
// the dangerous case was reported in the safe case's words.
|
|
81
150
|
try {
|
|
82
|
-
// Enable first: if a later policy statement fails, the table is left
|
|
83
|
-
// locked (deny-all for the user role) rather than open.
|
|
84
151
|
await client.query(plan.enableRls);
|
|
85
152
|
result.tablesSecured++;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
155
|
+
// Take the privilege back rather than serve an unprotected table.
|
|
156
|
+
// This is the fail-closed step the old code assumed it already had:
|
|
157
|
+
// per-table, so one collection cannot take the rest of the
|
|
158
|
+
// deployment down, but leaving nothing readable without RLS.
|
|
159
|
+
let grantWithdrawn = false;
|
|
160
|
+
try {
|
|
161
|
+
await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);
|
|
162
|
+
grantWithdrawn = true;
|
|
163
|
+
} catch {
|
|
164
|
+
// Fall through: reported below with `grantWithdrawn: false`,
|
|
165
|
+
// which the caller escalates. There is nothing else this
|
|
166
|
+
// function can do to make the table safe.
|
|
167
|
+
}
|
|
168
|
+
result.unsecured.push({ table: plan.qualified,
|
|
169
|
+
error,
|
|
170
|
+
grantWithdrawn });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
86
173
|
|
|
174
|
+
try {
|
|
87
175
|
let created = 0;
|
|
88
176
|
for (const statement of plan.policyStatements) {
|
|
89
177
|
await client.query(statement);
|
|
@@ -92,7 +180,12 @@ export async function ensureCollectionPolicies(
|
|
|
92
180
|
created++;
|
|
93
181
|
}
|
|
94
182
|
}
|
|
95
|
-
|
|
183
|
+
|
|
184
|
+
const orphans = await dropOrphanedPoliciesOn(client, plan);
|
|
185
|
+
result.orphansDropped += orphans;
|
|
186
|
+
|
|
187
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +
|
|
188
|
+
(orphans > 0 ? `, ${orphans} orphan(s) dropped` : ""));
|
|
96
189
|
} catch (err) {
|
|
97
190
|
result.failures.push({
|
|
98
191
|
table: plan.qualified,
|