@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g06dbe5b
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 +49 -2
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +40 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-Bww-Lg0s.js +8999 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -0
- package/dist/cli-helpers.d.ts +96 -1
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -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-B_JMGa5K.js +57 -0
- package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
- package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +2460 -10233
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +185 -144
- 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 +23 -25
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +99 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +38 -8
- package/dist/services/RelationService.d.ts +54 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +62 -2
- package/dist/services/dataService.d.ts +8 -1
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +129 -6
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-D1qbmLZ2.js +587 -0
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +176 -6
- package/src/PostgresBootstrapper.ts +245 -37
- package/src/auth/ensure-tables.ts +357 -81
- package/src/auth/services.ts +93 -7
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +199 -10
- package/src/cli.ts +276 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +129 -20
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +46 -19
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +572 -42
- package/src/schema/generate-drizzle-schema-logic.ts +169 -43
- package/src/schema/generate-postgres-ddl-logic.ts +670 -29
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +446 -100
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- 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 +11 -3
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +265 -122
- package/src/services/PersistService.ts +57 -7
- package/src/services/RelationService.ts +358 -114
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +123 -4
- package/src/services/dataService.ts +8 -1
- package/src/services/realtimeService.ts +346 -81
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +396 -29
- package/src/utils/pg-error-utils.ts +73 -3
- package/src/websocket.ts +130 -24
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/schema/doctor.ts
CHANGED
|
@@ -15,9 +15,9 @@ 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
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Resolve the SQL column name for a property.
|
|
@@ -33,11 +33,27 @@ const resolveColumnName = (propName: string, prop?: Property | null): string =>
|
|
|
33
33
|
|
|
34
34
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The report is CLI output, not application logging.
|
|
38
|
+
*
|
|
39
|
+
* `logger.info` is gated by `LOG_LEVEL`, which is a documented setting and
|
|
40
|
+
* ships in the scaffold's own `.env.example`: a developer who quietened their
|
|
41
|
+
* dev server with `LOG_LEVEL=warn` got a `rebase doctor` that printed nothing
|
|
42
|
+
* at all and still exited 1, with no way to tell a failing run from a crashed
|
|
43
|
+
* one. At the default level every line of the ASCII box arrived with
|
|
44
|
+
* `ℹ️ [INFO] ` glued to the front of it, and under `NODE_ENV=production` the
|
|
45
|
+
* whole report became JSON log records with the chalk escapes inside.
|
|
46
|
+
* `packages/cli` writes all of its output this way for the same reason.
|
|
47
|
+
*/
|
|
48
|
+
const out = (line = ""): void => {
|
|
49
|
+
console.log(line);
|
|
50
|
+
};
|
|
51
|
+
|
|
36
52
|
export type IssueSeverity = "error" | "warning" | "info";
|
|
37
53
|
|
|
38
54
|
export interface DoctorIssue {
|
|
39
55
|
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";
|
|
56
|
+
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
57
|
table?: string;
|
|
42
58
|
column?: string;
|
|
43
59
|
expected?: string;
|
|
@@ -46,11 +62,27 @@ export interface DoctorIssue {
|
|
|
46
62
|
fix: string;
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
export interface DoctorPhase {
|
|
66
|
+
passed: boolean;
|
|
67
|
+
issues: DoctorIssue[];
|
|
68
|
+
/**
|
|
69
|
+
* Why this phase never ran, when it did not.
|
|
70
|
+
*
|
|
71
|
+
* A check that did not happen is a third state, not a passing one. While a
|
|
72
|
+
* skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
|
|
73
|
+
* `✅ Collections → Database: In sync` and counted towards
|
|
74
|
+
* `✓ All schemas are in sync!` — so a project whose connection string was
|
|
75
|
+
* spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
|
|
76
|
+
* ticks and exit 0 against a database with no tables in it.
|
|
77
|
+
*/
|
|
78
|
+
skipped?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
export interface DoctorReport {
|
|
50
|
-
collectionsToSchema:
|
|
51
|
-
collectionsToSdk:
|
|
52
|
-
schemaToDatabase:
|
|
53
|
-
summary: { passed: number; warnings: number; errors: number };
|
|
82
|
+
collectionsToSchema: DoctorPhase;
|
|
83
|
+
collectionsToSdk: DoctorPhase;
|
|
84
|
+
schemaToDatabase: DoctorPhase;
|
|
85
|
+
summary: { passed: number; skipped: number; warnings: number; errors: number };
|
|
54
86
|
}
|
|
55
87
|
|
|
56
88
|
// ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
|
|
@@ -149,7 +181,7 @@ export async function loadCollections(collectionsPath: string): Promise<Collecti
|
|
|
149
181
|
export async function checkCollectionsVsSchema(
|
|
150
182
|
collections: CollectionConfig[],
|
|
151
183
|
schemaFilePath: string
|
|
152
|
-
): Promise<
|
|
184
|
+
): Promise<DoctorPhase> {
|
|
153
185
|
const issues: DoctorIssue[] = [];
|
|
154
186
|
|
|
155
187
|
// Check if schema file exists
|
|
@@ -164,8 +196,10 @@ export async function checkCollectionsVsSchema(
|
|
|
164
196
|
issues };
|
|
165
197
|
}
|
|
166
198
|
|
|
167
|
-
// Re-generate schema in-memory and compare with file on disk
|
|
168
|
-
|
|
199
|
+
// Re-generate schema in-memory and compare with file on disk. Only the
|
|
200
|
+
// collections the generator itself will emit — a Firestore collection has
|
|
201
|
+
// no table in the generated file and must not be reported as missing one.
|
|
202
|
+
const postgresCollections = relationalCollections(collections);
|
|
169
203
|
if (postgresCollections.length === 0) {
|
|
170
204
|
return { passed: true,
|
|
171
205
|
issues };
|
|
@@ -208,7 +242,7 @@ issues };
|
|
|
208
242
|
export async function checkCollectionsVsSdk(
|
|
209
243
|
collections: CollectionConfig[],
|
|
210
244
|
sdkFilePath: string
|
|
211
|
-
): Promise<
|
|
245
|
+
): Promise<DoctorPhase> {
|
|
212
246
|
const issues: DoctorIssue[] = [];
|
|
213
247
|
|
|
214
248
|
// The typed SDK is opt-in — nothing in a scaffolded project imports it until
|
|
@@ -248,12 +282,27 @@ issues };
|
|
|
248
282
|
}
|
|
249
283
|
} catch (err: unknown) {
|
|
250
284
|
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
285
|
+
|
|
286
|
+
// A `CodegenError` is not "the comparison did not run" — it is the
|
|
287
|
+
// schema saying it cannot produce a valid typed client at all, most
|
|
288
|
+
// often two slugs collapsing onto one accessor. Reporting that as a
|
|
289
|
+
// warning about staleness described the wrong problem and pointed at a
|
|
290
|
+
// command that would fail the same way.
|
|
291
|
+
if (err instanceof Error && err.name === "CodegenError") {
|
|
292
|
+
issues.push({
|
|
293
|
+
severity: "error",
|
|
294
|
+
category: "sdk_ungeneratable",
|
|
295
|
+
message: `The typed SDK cannot be generated from these collections: ${message}`,
|
|
296
|
+
fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
|
|
297
|
+
});
|
|
298
|
+
} else {
|
|
299
|
+
issues.push({
|
|
300
|
+
severity: "warning",
|
|
301
|
+
category: "sdk_stale",
|
|
302
|
+
message: `Could not regenerate SDK types for comparison: ${message}`,
|
|
303
|
+
fix: "Run `rebase generate-sdk` to verify"
|
|
304
|
+
});
|
|
305
|
+
}
|
|
257
306
|
}
|
|
258
307
|
|
|
259
308
|
return { passed: issues.length === 0,
|
|
@@ -280,7 +329,7 @@ interface DbEnumValue {
|
|
|
280
329
|
export async function checkCollectionsVsDatabase(
|
|
281
330
|
collections: CollectionConfig[],
|
|
282
331
|
databaseUrl: string
|
|
283
|
-
): Promise<
|
|
332
|
+
): Promise<DoctorPhase> {
|
|
284
333
|
const issues: DoctorIssue[] = [];
|
|
285
334
|
|
|
286
335
|
// Dynamic import to avoid loading pg when not needed
|
|
@@ -292,7 +341,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
292
341
|
const schemas = Array.from(new Set([
|
|
293
342
|
"public",
|
|
294
343
|
"rebase",
|
|
295
|
-
...collections
|
|
344
|
+
...relationalCollections(collections)
|
|
296
345
|
.filter(isPostgresCollectionConfig)
|
|
297
346
|
.map(c => c.schema)
|
|
298
347
|
.filter((s): s is string => !!s)
|
|
@@ -383,7 +432,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
383
432
|
|
|
384
433
|
// ── Compare each collection against the database ─────────────────
|
|
385
434
|
|
|
386
|
-
const postgresCollections = collections
|
|
435
|
+
const postgresCollections = relationalCollections(collections);
|
|
387
436
|
|
|
388
437
|
for (const collection of postgresCollections) {
|
|
389
438
|
const tableName = getTableName(collection);
|
|
@@ -595,96 +644,99 @@ issues };
|
|
|
595
644
|
// ── Report Rendering ─────────────────────────────────────────────────────
|
|
596
645
|
|
|
597
646
|
export function renderReport(report: DoctorReport): void {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
647
|
+
out();
|
|
648
|
+
out(chalk.bold(" 🩺 Rebase Schema Doctor"));
|
|
649
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
650
|
+
out();
|
|
602
651
|
|
|
603
652
|
// Phase 1
|
|
604
|
-
renderPhase(
|
|
605
|
-
"Collections → Generated Schema",
|
|
606
|
-
report.collectionsToSchema.passed,
|
|
607
|
-
report.collectionsToSchema.issues
|
|
608
|
-
);
|
|
653
|
+
renderPhase("Collections → Generated Schema", report.collectionsToSchema);
|
|
609
654
|
|
|
610
655
|
// Phase 2
|
|
611
|
-
renderPhase(
|
|
612
|
-
"Collections → Database",
|
|
613
|
-
report.schemaToDatabase.passed,
|
|
614
|
-
report.schemaToDatabase.issues
|
|
615
|
-
);
|
|
656
|
+
renderPhase("Collections → Database", report.schemaToDatabase);
|
|
616
657
|
|
|
617
658
|
// Phase 3
|
|
618
|
-
renderPhase(
|
|
619
|
-
"Collections → SDK Types",
|
|
620
|
-
report.collectionsToSdk.passed,
|
|
621
|
-
report.collectionsToSdk.issues
|
|
622
|
-
);
|
|
659
|
+
renderPhase("Collections → SDK Types", report.collectionsToSdk);
|
|
623
660
|
|
|
624
661
|
// Summary
|
|
625
|
-
|
|
626
|
-
const { passed, warnings, errors } = report.summary;
|
|
662
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
663
|
+
const { passed, skipped, warnings, errors } = report.summary;
|
|
627
664
|
|
|
628
665
|
const parts: string[] = [];
|
|
629
666
|
parts.push(chalk.green(`${passed} passed`));
|
|
667
|
+
if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
|
|
630
668
|
if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
|
|
631
669
|
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
632
670
|
|
|
633
|
-
|
|
634
|
-
|
|
671
|
+
out(` Summary: ${parts.join(", ")}`);
|
|
672
|
+
out();
|
|
635
673
|
|
|
636
674
|
if (errors > 0) {
|
|
637
|
-
|
|
675
|
+
out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
|
|
638
676
|
} else if (warnings > 0) {
|
|
639
|
-
|
|
677
|
+
out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
|
|
678
|
+
} else if (skipped > 0) {
|
|
679
|
+
// Never "all schemas are in sync" off the back of a check that did not
|
|
680
|
+
// run: a clean bill of health has to come from measurement.
|
|
681
|
+
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
682
|
} else {
|
|
641
|
-
|
|
683
|
+
out(chalk.green.bold(" ✓ All schemas are in sync!"));
|
|
642
684
|
}
|
|
643
|
-
|
|
685
|
+
out();
|
|
644
686
|
}
|
|
645
687
|
|
|
646
|
-
function renderPhase(label: string,
|
|
688
|
+
function renderPhase(label: string, phase: DoctorPhase): void {
|
|
689
|
+
// A phase that never ran gets its own marker and its reason. It is neither
|
|
690
|
+
// "In sync" nor a drift report; conflating it with the former is what let
|
|
691
|
+
// doctor certify a database it had not opened.
|
|
692
|
+
if (phase.skipped) {
|
|
693
|
+
out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
|
|
694
|
+
out();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const issues = phase.issues;
|
|
647
699
|
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
648
700
|
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
649
701
|
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
650
702
|
|
|
651
703
|
// Informational notes don't make a phase unhealthy, so key the header off
|
|
652
|
-
// real problems rather than `passed` alone.
|
|
704
|
+
// real problems rather than `phase.passed` alone.
|
|
653
705
|
if (errorCount === 0 && warnCount === 0) {
|
|
654
|
-
|
|
706
|
+
out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
655
707
|
} else {
|
|
656
708
|
const parts: string[] = [];
|
|
657
709
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
658
710
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
659
|
-
|
|
711
|
+
out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
|
|
660
712
|
}
|
|
661
|
-
|
|
713
|
+
out();
|
|
662
714
|
|
|
663
715
|
// Notes render as a quiet one-liner, not a full drift box.
|
|
664
716
|
for (const issue of infoIssues) {
|
|
665
717
|
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
666
|
-
|
|
667
|
-
|
|
718
|
+
out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
719
|
+
out();
|
|
668
720
|
}
|
|
669
721
|
|
|
670
722
|
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
671
723
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
672
724
|
const categoryLabel = formatCategory(issue.category);
|
|
673
|
-
|
|
725
|
+
out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
674
726
|
|
|
675
727
|
if (issue.table) {
|
|
676
728
|
const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
|
|
677
|
-
|
|
729
|
+
out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
|
|
678
730
|
}
|
|
679
731
|
|
|
680
732
|
if (issue.expected && issue.actual) {
|
|
681
|
-
|
|
733
|
+
out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
|
|
682
734
|
}
|
|
683
735
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
736
|
+
out(` ${chalk.gray("│")} ${issue.message}`);
|
|
737
|
+
out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
|
|
738
|
+
out(` ${chalk.gray("└" + "─".repeat(48))}`);
|
|
739
|
+
out();
|
|
688
740
|
}
|
|
689
741
|
}
|
|
690
742
|
|
|
@@ -699,7 +751,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
|
|
|
699
751
|
enum_value_mismatch: "Enum Value Mismatch",
|
|
700
752
|
missing_foreign_key: "Missing Foreign Key",
|
|
701
753
|
sdk_stale: "Stale SDK Types",
|
|
702
|
-
sdk_not_generated: "SDK Types Not Generated"
|
|
754
|
+
sdk_not_generated: "SDK Types Not Generated",
|
|
755
|
+
sdk_ungeneratable: "SDK Cannot Be Generated"
|
|
703
756
|
};
|
|
704
757
|
return labels[cat];
|
|
705
758
|
}
|
|
@@ -712,38 +765,44 @@ export async function runDoctor(options: {
|
|
|
712
765
|
sdkPath: string;
|
|
713
766
|
databaseUrl?: string;
|
|
714
767
|
}): Promise<DoctorReport> {
|
|
715
|
-
|
|
716
|
-
|
|
768
|
+
out();
|
|
769
|
+
out(chalk.bold(" 🩺 Loading collections..."));
|
|
717
770
|
const collections = await loadCollections(options.collectionsPath);
|
|
718
771
|
if (collections.length === 0) {
|
|
719
|
-
|
|
772
|
+
console.error(chalk.red(" ✗ No collections found."));
|
|
720
773
|
process.exit(1);
|
|
721
774
|
}
|
|
722
|
-
|
|
723
|
-
|
|
775
|
+
out(chalk.gray(` Found ${collections.length} collection(s)`));
|
|
776
|
+
out();
|
|
724
777
|
|
|
725
778
|
// Phase 1: Collections ↔ Generated Schema
|
|
726
|
-
|
|
779
|
+
out(chalk.gray(" Checking Collections → Generated Schema..."));
|
|
727
780
|
const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
|
|
728
781
|
|
|
729
782
|
// Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
|
|
730
|
-
let schemaToDatabase:
|
|
731
|
-
issues: [] };
|
|
783
|
+
let schemaToDatabase: DoctorPhase;
|
|
732
784
|
if (options.databaseUrl) {
|
|
733
|
-
|
|
785
|
+
out(chalk.gray(" Checking Collections → Database..."));
|
|
734
786
|
schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
|
|
735
787
|
} else {
|
|
736
|
-
|
|
737
|
-
|
|
788
|
+
// Not `{ passed: true }`: see DoctorPhase.skipped.
|
|
789
|
+
schemaToDatabase = { passed: false,
|
|
790
|
+
issues: [],
|
|
791
|
+
skipped: "DATABASE_URL not set" };
|
|
792
|
+
out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
|
|
793
|
+
out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
|
|
738
794
|
}
|
|
739
795
|
|
|
740
796
|
// Phase 3: Collections ↔ SDK Types
|
|
741
|
-
|
|
797
|
+
out(chalk.gray(" Checking Collections → SDK Types..."));
|
|
742
798
|
const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
|
|
743
799
|
|
|
744
|
-
const
|
|
800
|
+
const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
|
|
801
|
+
const allIssues = phases.flatMap((p) => p.issues);
|
|
745
802
|
const summary = {
|
|
746
|
-
|
|
803
|
+
// A skipped phase is not a passing one, however few issues it collected.
|
|
804
|
+
passed: phases.filter((p) => p.passed && !p.skipped).length,
|
|
805
|
+
skipped: phases.filter((p) => p.skipped).length,
|
|
747
806
|
warnings: allIssues.filter((i) => i.severity === "warning").length,
|
|
748
807
|
errors: allIssues.filter((i) => i.severity === "error").length
|
|
749
808
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
+
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
37
|
+
|
|
38
|
+
export interface PolicyEnsureResult {
|
|
39
|
+
/** `CREATE POLICY` statements that ran successfully. */
|
|
40
|
+
policiesApplied: number;
|
|
41
|
+
/** Tables that had RLS enabled. */
|
|
42
|
+
tablesSecured: number;
|
|
43
|
+
/** Declared tables absent from the database — left to a real migration. */
|
|
44
|
+
skipped: { table: string; reason: string }[];
|
|
45
|
+
/** Tables whose RLS could not be fully applied (fail closed). */
|
|
46
|
+
failures: { table: string; error: string }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bring the declared collections' RLS policies up to date. Returns what it did.
|
|
53
|
+
*
|
|
54
|
+
* Only tables that already exist are touched: the boot-time table creator runs
|
|
55
|
+
* first, so anything still missing is a table this additive path is not allowed
|
|
56
|
+
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
|
+
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureCollectionPolicies(
|
|
60
|
+
client: Queryable,
|
|
61
|
+
collections: CollectionConfig[],
|
|
62
|
+
log?: (message: string) => void
|
|
63
|
+
): Promise<PolicyEnsureResult> {
|
|
64
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
65
|
+
|
|
66
|
+
const plans = planCollectionPolicies(collections);
|
|
67
|
+
if (plans.length === 0) return result;
|
|
68
|
+
|
|
69
|
+
const schemas = Array.from(new Set(plans.map(p => p.schema)));
|
|
70
|
+
const existing = await readExistingSchema(client, schemas);
|
|
71
|
+
|
|
72
|
+
for (const plan of plans) {
|
|
73
|
+
if (!existing.tables.has(plan.qualified)) {
|
|
74
|
+
result.skipped.push({
|
|
75
|
+
table: plan.qualified,
|
|
76
|
+
reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
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
|
+
await client.query(plan.enableRls);
|
|
85
|
+
result.tablesSecured++;
|
|
86
|
+
|
|
87
|
+
let created = 0;
|
|
88
|
+
for (const statement of plan.policyStatements) {
|
|
89
|
+
await client.query(statement);
|
|
90
|
+
if (isCreatePolicy(statement)) {
|
|
91
|
+
result.policiesApplied++;
|
|
92
|
+
created++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
result.failures.push({
|
|
98
|
+
table: plan.qualified,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
|
|
|
87
87
|
expect(sql).not.toMatch(/NOT NULL/i);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
// ── Relations ────────────────────────────────────────────────────────────
|
|
91
|
+
//
|
|
92
|
+
// These columns were once skipped outright, on the reasoning that a bare
|
|
93
|
+
// column without its foreign key would disagree with what `db push` later
|
|
94
|
+
// generated. On a managed tenant nothing pushes afterwards, so the table
|
|
95
|
+
// arrived without the column its own collection reads: every insert 400ed
|
|
96
|
+
// with `column "author_id" does not exist`. The answer is to emit the key
|
|
97
|
+
// too, from the same planner `db push` uses.
|
|
98
|
+
|
|
99
|
+
const authors = {
|
|
100
|
+
name: "Authors",
|
|
101
|
+
slug: "authors",
|
|
102
|
+
properties: {
|
|
103
|
+
id: { name: "ID", type: "string", isId: "uuid" },
|
|
104
|
+
name: { name: "Name", type: "string" }
|
|
105
|
+
}
|
|
106
|
+
} as unknown as CollectionConfig;
|
|
107
|
+
|
|
108
|
+
const postsWithAuthor = {
|
|
109
|
+
...posts,
|
|
110
|
+
properties: {
|
|
111
|
+
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
112
|
+
author: { name: "Author", type: "reference", path: "authors" }
|
|
113
|
+
}
|
|
114
|
+
} as unknown as CollectionConfig;
|
|
115
|
+
|
|
116
|
+
const tags = {
|
|
117
|
+
name: "Tags",
|
|
118
|
+
slug: "tags",
|
|
119
|
+
properties: {
|
|
120
|
+
id: { name: "ID", type: "number", isId: "increment" },
|
|
121
|
+
name: { name: "Name", type: "string" }
|
|
122
|
+
}
|
|
123
|
+
} as unknown as CollectionConfig;
|
|
124
|
+
|
|
125
|
+
const postsWithTags = {
|
|
126
|
+
...posts,
|
|
127
|
+
properties: {
|
|
128
|
+
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
129
|
+
tags: {
|
|
130
|
+
name: "Tags",
|
|
131
|
+
type: "relation",
|
|
132
|
+
relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
|
|
96
133
|
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
}
|
|
135
|
+
} as unknown as CollectionConfig;
|
|
136
|
+
|
|
137
|
+
it("adds a reference column, and the foreign key that makes it one", () => {
|
|
138
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
|
|
139
|
+
|
|
140
|
+
expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
|
|
141
|
+
const fk = plan.actions.find(a => a.kind === "add-constraint");
|
|
142
|
+
expect(fk?.target).toBe("public.posts.posts_author_fkey");
|
|
143
|
+
expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("orders every constraint after the columns and tables it depends on", () => {
|
|
147
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
|
|
148
|
+
const kinds = plan.actions.map(a => a.kind);
|
|
149
|
+
expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
150
|
+
expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
|
|
154
|
+
const existing: ExistingSchema = {
|
|
155
|
+
tables: new Map([
|
|
156
|
+
["public.posts", new Set(["id", "author"])],
|
|
157
|
+
["public.authors", new Set(["id", "name"])]
|
|
158
|
+
]),
|
|
159
|
+
enums: new Set(["public.posts_status"]),
|
|
160
|
+
constraints: new Set(["public.posts.posts_author_fkey"])
|
|
161
|
+
};
|
|
162
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
|
|
163
|
+
expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
|
|
167
|
+
const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
|
|
168
|
+
const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
|
|
169
|
+
|
|
170
|
+
expect(junction).toBeDefined();
|
|
171
|
+
// The endpoint key types have to match the primary keys they reference:
|
|
172
|
+
// posts is a uuid, tags an auto-increment integer.
|
|
173
|
+
expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
|
|
174
|
+
expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
|
|
175
|
+
expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
|
|
176
|
+
|
|
177
|
+
const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
|
|
178
|
+
expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
|
|
179
|
+
expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("leaves an existing junction table alone", () => {
|
|
183
|
+
const existing: ExistingSchema = {
|
|
184
|
+
tables: new Map([
|
|
185
|
+
["public.posts", new Set(["id", "title", "views", "status"])],
|
|
186
|
+
["public.tags", new Set(["id", "name"])],
|
|
187
|
+
["public.posts_tags", new Set(["post_id", "tag_id"])]
|
|
188
|
+
]),
|
|
189
|
+
enums: new Set(["public.posts_status"]),
|
|
190
|
+
constraints: new Set([
|
|
191
|
+
"public.posts_tags.posts_tags_post_id_fkey",
|
|
192
|
+
"public.posts_tags.posts_tags_tag_id_fkey"
|
|
193
|
+
])
|
|
194
|
+
};
|
|
195
|
+
expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
|
|
100
196
|
});
|
|
101
197
|
});
|
|
102
198
|
|