@rebasepro/server-postgres 0.0.1-canary.4829d6e

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.
Files changed (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Runtime introspection — builds collections in memory from the live database.
3
+ *
4
+ * This is what makes BaaS mode work with zero configuration: instead of loading
5
+ * collection files from disk, the server reads `information_schema` at boot and
6
+ * derives a collection per table, so any database is served over REST without a
7
+ * single config file.
8
+ *
9
+ * Distinct from `introspect-db.ts`, which runs the same queries but emits
10
+ * TypeScript *source* for a developer to edit and commit (CMS mode). The two
11
+ * share the mapping helpers in `introspect-db-logic.ts` so a table is described
12
+ * the same way whether it was generated or introspected.
13
+ */
14
+ import type { PostgresCollectionConfig } from "@rebasepro/types";
15
+
16
+ import {
17
+ TableRow,
18
+ TableColumn,
19
+ EnumValue,
20
+ PrimaryKeyRow,
21
+ ForeignKeyRow,
22
+ TableMeta,
23
+ buildTablesMap,
24
+ buildEnumMap,
25
+ identifyJoinTables,
26
+ humanize,
27
+ singularize,
28
+ mapPgType,
29
+ getIconForTable
30
+ } from "./introspect-db-logic";
31
+
32
+ export interface IntrospectedSchema {
33
+ tablesMap: Map<string, TableMeta>;
34
+ enumMap: Map<string, string[]>;
35
+ joinTables: Set<string>;
36
+ }
37
+
38
+ /** Whether a table carries an authorization model of its own. */
39
+ export interface TableRlsStatus {
40
+ table: string;
41
+ /** ALTER TABLE … ENABLE ROW LEVEL SECURITY has been run. */
42
+ rlsEnabled: boolean;
43
+ /** Policies attached to it. RLS enabled with none = nothing is visible. */
44
+ policyCount: number;
45
+ }
46
+
47
+ /**
48
+ * Read the RLS posture of each table in a schema.
49
+ *
50
+ * This is what decides whether baas mode may serve a table. A table with RLS
51
+ * disabled has no authorization model: since every authenticated request runs
52
+ * as `rebase_user`, and that role is granted DML on the schema, serving such a
53
+ * table hands every row to every logged-in user.
54
+ */
55
+ export async function readRlsStatus(client: Queryable, pgSchema: string): Promise<Map<string, TableRlsStatus>> {
56
+ const { rows } = await client.query<{ table: string; rls_enabled: boolean; policy_count: string | number }>(
57
+ `SELECT c.relname AS table,
58
+ c.relrowsecurity AS rls_enabled,
59
+ (SELECT count(*) FROM pg_policy p WHERE p.polrelid = c.oid) AS policy_count
60
+ FROM pg_class c
61
+ JOIN pg_namespace n ON n.oid = c.relnamespace
62
+ WHERE n.nspname = $1 AND c.relkind = 'r'`,
63
+ [pgSchema]
64
+ );
65
+
66
+ return new Map(
67
+ rows.map((r) => [
68
+ r.table,
69
+ { table: r.table, rlsEnabled: r.rls_enabled === true, policyCount: Number(r.policy_count ?? 0) }
70
+ ])
71
+ );
72
+ }
73
+
74
+ /** Minimal query surface — satisfied by pg.Client and pg.Pool alike. */
75
+ export interface Queryable {
76
+ query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;
77
+ }
78
+
79
+ /**
80
+ * Read tables, columns, enums, primary keys and foreign keys for a schema.
81
+ * Mirrors the queries in introspect-db.ts.
82
+ */
83
+ export async function introspectSchema(client: Queryable, pgSchema: string): Promise<IntrospectedSchema> {
84
+ const { rows: tables } = await client.query<TableRow>(
85
+ `SELECT table_name
86
+ FROM information_schema.tables
87
+ WHERE table_schema = $1 AND table_type = 'BASE TABLE'
88
+ AND table_name NOT LIKE 'drizzle_%'
89
+ AND table_name NOT LIKE 'rebase_%'
90
+ ORDER BY table_name`,
91
+ [pgSchema]
92
+ );
93
+
94
+ const { rows: columns } = await client.query<TableColumn>(
95
+ `SELECT
96
+ c.table_name,
97
+ c.column_name,
98
+ c.data_type,
99
+ c.udt_name,
100
+ c.is_nullable,
101
+ c.column_default,
102
+ (SELECT a.atttypmod FROM pg_attribute a
103
+ JOIN pg_class pc ON a.attrelid = pc.oid
104
+ WHERE pc.relname = c.table_name
105
+ AND a.attname = c.column_name
106
+ AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod
107
+ FROM information_schema.columns c
108
+ WHERE c.table_schema = $1`,
109
+ [pgSchema]
110
+ );
111
+
112
+ const { rows: enumValues } = await client.query<EnumValue>(
113
+ `SELECT t.typname AS enum_name,
114
+ e.enumlabel AS enum_value,
115
+ e.enumsortorder AS sort_order
116
+ FROM pg_type t
117
+ JOIN pg_enum e ON t.oid = e.enumtypid
118
+ JOIN pg_namespace n ON t.typnamespace = n.oid
119
+ WHERE n.nspname = $1
120
+ ORDER BY t.typname, e.enumsortorder`,
121
+ [pgSchema]
122
+ );
123
+
124
+ const { rows: pks } = await client.query<PrimaryKeyRow>(
125
+ `SELECT t.relname as table_name, a.attname as column_name
126
+ FROM pg_index i
127
+ JOIN pg_attribute a ON a.attrelid = i.indrelid
128
+ AND a.attnum = ANY(i.indkey)
129
+ JOIN pg_class t ON t.oid = i.indrelid
130
+ JOIN pg_namespace n ON n.oid = t.relnamespace
131
+ WHERE i.indisprimary AND n.nspname = $1`,
132
+ [pgSchema]
133
+ );
134
+
135
+ const { rows: fks } = await client.query<ForeignKeyRow>(
136
+ `SELECT
137
+ tc.table_name,
138
+ kcu.column_name,
139
+ ccu.table_name AS foreign_table_name,
140
+ ccu.column_name AS foreign_column_name
141
+ FROM information_schema.table_constraints AS tc
142
+ JOIN information_schema.key_column_usage AS kcu
143
+ ON tc.constraint_name = kcu.constraint_name
144
+ AND tc.table_schema = kcu.table_schema
145
+ JOIN information_schema.constraint_column_usage AS ccu
146
+ ON ccu.constraint_name = tc.constraint_name
147
+ AND ccu.table_schema = tc.table_schema
148
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1`,
149
+ [pgSchema]
150
+ );
151
+
152
+ const tablesMap = buildTablesMap(tables, columns, pks, fks);
153
+ return {
154
+ tablesMap,
155
+ enumMap: buildEnumMap(enumValues),
156
+ joinTables: identifyJoinTables(tablesMap)
157
+ };
158
+ }
159
+
160
+ /** Derive the `isId` flavour for a primary-key column. */
161
+ function idKindFor(col: TableColumn, propType: string): "uuid" | "increment" | true {
162
+ if (col.data_type.toLowerCase() === "uuid") return "uuid";
163
+ if (propType === "number") return "increment";
164
+ return true;
165
+ }
166
+
167
+ function buildProperties(
168
+ meta: TableMeta,
169
+ enumMap: Map<string, string[]>
170
+ ): Record<string, Record<string, unknown>> {
171
+ const properties: Record<string, Record<string, unknown>> = {};
172
+
173
+ for (const col of meta.columns) {
174
+ const isPk = meta.pks.includes(col.column_name);
175
+ // Foreign keys surface as relations below, unless they are also part of
176
+ // the primary key, in which case the column itself must stay.
177
+ const isFk = meta.fks.some((fk) => fk.column_name === col.column_name);
178
+ if (isFk && !isPk) continue;
179
+
180
+ const enumValues = enumMap.get(col.udt_name);
181
+ const isEnum = col.data_type === "USER-DEFINED" && enumValues !== undefined;
182
+ const isVector = col.udt_name === "vector";
183
+ const propType = isEnum ? "string" : isVector ? "vector" : mapPgType(col.data_type);
184
+
185
+ const property: Record<string, unknown> = {
186
+ name: humanize(col.column_name),
187
+ columnName: col.column_name,
188
+ type: propType
189
+ };
190
+
191
+ const key = col.column_name;
192
+
193
+ if (isPk) {
194
+ property.isId = idKindFor(col, propType);
195
+ } else if (col.is_nullable === "NO" && col.column_default === null) {
196
+ property.validation = { required: true };
197
+ }
198
+
199
+ if (isEnum && enumValues) {
200
+ property.enum = enumValues.map((value) => ({ id: value, label: humanize(value) }));
201
+ }
202
+
203
+ properties[key] = property;
204
+ }
205
+
206
+ return properties;
207
+ }
208
+
209
+ /**
210
+ * Owning relations, derived from this table's foreign keys. Mirrors the shape
211
+ * `generateCollectionFile` emits, except `target` uses the slug string form
212
+ * rather than a thunk to a module import.
213
+ */
214
+ function buildRelations(
215
+ meta: TableMeta,
216
+ slugByTable: Map<string, string>
217
+ ): Record<string, Record<string, unknown>> {
218
+ const relations: Record<string, Record<string, unknown>> = {};
219
+
220
+ for (const fk of meta.fks) {
221
+ const targetSlug = slugByTable.get(fk.foreign_table_name);
222
+ if (!targetSlug) continue;
223
+
224
+ // Strip the conventional _id suffix: author_id -> author
225
+ let key = fk.column_name.replace(/_id$/, "");
226
+ if (meta.pks.includes(fk.column_name) && key === fk.column_name) {
227
+ // The FK is also the PK and its name doesn't imply a relation (e.g.
228
+ // "id"), so naming the relation after the column would collide with
229
+ // the primary-key property.
230
+ key = fk.foreign_table_name;
231
+ }
232
+
233
+ relations[key] = {
234
+ name: humanize(key),
235
+ type: "relation",
236
+ target: targetSlug,
237
+ cardinality: "one",
238
+ direction: "owning",
239
+ localKey: fk.column_name
240
+ };
241
+ }
242
+
243
+ return relations;
244
+ }
245
+
246
+ /**
247
+ * Turn an introspected schema into collections.
248
+ *
249
+ * Join tables are skipped: they carry no identity of their own and exist to
250
+ * express a many-to-many edge between two other tables.
251
+ */
252
+ export function buildCollectionsFromSchema(
253
+ { tablesMap, enumMap, joinTables }: IntrospectedSchema,
254
+ pgSchema: string
255
+ ): PostgresCollectionConfig[] {
256
+ const slugByTable = new Map<string, string>();
257
+ for (const tableName of tablesMap.keys()) {
258
+ if (!joinTables.has(tableName)) slugByTable.set(tableName, tableName);
259
+ }
260
+
261
+ const collections: PostgresCollectionConfig[] = [];
262
+
263
+ for (const [tableName, meta] of tablesMap) {
264
+ if (joinTables.has(tableName)) continue;
265
+
266
+ const collectionName = humanize(tableName);
267
+ const collection = {
268
+ name: collectionName,
269
+ singularName: singularize(collectionName),
270
+ slug: tableName,
271
+ table: tableName,
272
+ schema: pgSchema,
273
+ icon: getIconForTable(tableName),
274
+ properties: {
275
+ ...buildProperties(meta, enumMap),
276
+ ...buildRelations(meta, slugByTable)
277
+ }
278
+ } as unknown as PostgresCollectionConfig;
279
+
280
+ collections.push(collection);
281
+ }
282
+
283
+ return collections;
284
+ }
285
+
286
+ /** Introspect the database and return ready-to-serve collections. */
287
+ export async function introspectCollections(
288
+ client: Queryable,
289
+ pgSchema: string
290
+ ): Promise<PostgresCollectionConfig[]> {
291
+ const schema = await introspectSchema(client, pgSchema);
292
+ return buildCollectionsFromSchema(schema, pgSchema);
293
+ }
@@ -0,0 +1,11 @@
1
+ import { pgTable, text, pgPolicy } from "drizzle-orm/pg-core";
2
+ import { sql } from "drizzle-orm";
3
+
4
+ export const testTable = pgTable("test", {
5
+ id: text("id").primaryKey()
6
+ }, (t) => [
7
+ pgPolicy("renamed_policy", { as: "permissive",
8
+ to: "public",
9
+ for: "select",
10
+ using: sql`true` })
11
+ ]);
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import type { CollectionConfig } from "@rebasepro/types";
3
+
4
+ import { checkPolicyDrift, parseExpectedPolicies, formatPolicyDrift, hasDrift, type Queryable } from "./policy-drift";
5
+ import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
6
+
7
+ function collection(slug: string): CollectionConfig {
8
+ return {
9
+ name: slug,
10
+ slug,
11
+ table: slug,
12
+ schema: "public",
13
+ properties: { id: { name: "ID", type: "string", isId: "uuid" } },
14
+ securityRules: [
15
+ { operation: "select", access: "public" },
16
+ { operations: ["insert", "update", "delete"], roles: ["admin"] }
17
+ ]
18
+ } as unknown as CollectionConfig;
19
+ }
20
+
21
+ /** Stands in for pg_policies. */
22
+ function dbWith(rows: Record<string, unknown>[]): Queryable {
23
+ return { query: async () => ({ rows: rows as never[] }) };
24
+ }
25
+
26
+ describe("parseExpectedPolicies", () => {
27
+ it("reads the DDL that db push actually applies", () => {
28
+ const ddl = generatePostgresPoliciesDdl([collection("authors")]);
29
+ const parsed = parseExpectedPolicies(ddl);
30
+
31
+ expect(parsed.length).toBeGreaterThan(0);
32
+ const select = parsed.find((p) => p.command === "SELECT" && p.roles.includes("public"));
33
+ expect(select).toMatchObject({ schema: "public", table: "authors", command: "SELECT" });
34
+ });
35
+ });
36
+
37
+ describe("checkPolicyDrift", () => {
38
+ it("reports nothing when the database matches the collections", async () => {
39
+ const cols = [collection("authors")];
40
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
41
+ const live = expected.map((p) => ({
42
+ schemaname: p.schema, tablename: p.table, policyname: p.name, roles: p.roles, cmd: p.command
43
+ }));
44
+
45
+ const drift = await checkPolicyDrift(dbWith(live), cols);
46
+
47
+ expect(hasDrift(drift)).toBe(false);
48
+ expect(formatPolicyDrift(drift)).toBe("");
49
+ });
50
+
51
+ it("flags a policy the database never received as missing", async () => {
52
+ const drift = await checkPolicyDrift(dbWith([]), [collection("authors")]);
53
+
54
+ expect(drift.missing.length).toBeGreaterThan(0);
55
+ expect(formatPolicyDrift(drift)).toContain("Missing");
56
+ expect(formatPolicyDrift(drift)).toContain("rebase db push");
57
+ });
58
+
59
+ it("flags a stale policy left by an earlier push as orphaned", async () => {
60
+ // The demo's actual bug: test_policy TO authenticated outlived the
61
+ // config that created it, and kept filtering every row.
62
+ const cols = [collection("customers")];
63
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
64
+ const live = [
65
+ ...expected.map((p) => ({ schemaname: p.schema, tablename: p.table, policyname: p.name, roles: p.roles, cmd: p.command })),
66
+ { schemaname: "public", tablename: "customers", policyname: "test_policy", roles: ["authenticated"], cmd: "ALL" }
67
+ ];
68
+
69
+ const drift = await checkPolicyDrift(dbWith(live), cols);
70
+
71
+ expect(drift.orphaned).toHaveLength(1);
72
+ expect(drift.orphaned[0]).toMatchObject({ name: "test_policy", roles: ["authenticated"] });
73
+ expect(formatPolicyDrift(drift)).toContain("Orphaned");
74
+ });
75
+
76
+ it("flags a policy whose roles were changed underneath it as diverged", async () => {
77
+ const cols = [collection("orders")];
78
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
79
+ const live = expected.map((p) => ({
80
+ schemaname: p.schema, tablename: p.table, policyname: p.name,
81
+ // Someone re-granted it to a role requests never run as.
82
+ roles: ["authenticated"], cmd: p.command
83
+ }));
84
+
85
+ const drift = await checkPolicyDrift(dbWith(live), cols);
86
+
87
+ expect(drift.diverged.length).toBeGreaterThan(0);
88
+ expect(drift.diverged[0].differences.join(" ")).toContain("roles");
89
+ expect(formatPolicyDrift(drift)).toContain("Diverged");
90
+ });
91
+
92
+ it("parses roles when the driver returns the raw {a,b} text form", async () => {
93
+ const cols = [collection("tags")];
94
+ const live = [{ schemaname: "public", tablename: "tags", policyname: "test_policy", roles: "{authenticated,anon}", cmd: "ALL" }];
95
+
96
+ const drift = await checkPolicyDrift(dbWith(live), cols);
97
+
98
+ expect(drift.orphaned[0].roles).toEqual(["authenticated", "anon"]);
99
+ });
100
+
101
+ it("reports nothing when there are no collections at all", async () => {
102
+ // With no collections there is no expectation to reconcile against, so
103
+ // scanning would report every policy in the database as orphaned.
104
+ // Note a collection with no securityRules is NOT this case — it still
105
+ // generates default (admin-only) policies.
106
+ const drift = await checkPolicyDrift(
107
+ dbWith([{ schemaname: "public", tablename: "x", policyname: "p", roles: ["public"], cmd: "ALL" }]),
108
+ []
109
+ );
110
+
111
+ expect(hasDrift(drift)).toBe(false);
112
+ });
113
+
114
+ it("still expects the default policies a collection without securityRules generates", async () => {
115
+ const bare = { name: "x", slug: "x", table: "x", schema: "public", properties: {} } as unknown as CollectionConfig;
116
+
117
+ const drift = await checkPolicyDrift(dbWith([]), [bare]);
118
+
119
+ // Locked-by-default: absence of rules means admin-only policies, not none.
120
+ expect(drift.missing.length).toBeGreaterThan(0);
121
+ });
122
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Compare the RLS policies a database actually has against the ones the
3
+ * collections describe.
4
+ *
5
+ * Policies live in Postgres; the collection config is only their *source*.
6
+ * Nothing reconciled the two, which is how the demo database served empty
7
+ * collections indefinitely: its policies granted `TO authenticated` (a Supabase
8
+ * role name) while requests run as `rebase_user`, so RLS filtered every row.
9
+ * The config was later corrected and the database never noticed — an empty
10
+ * table is indistinguishable from a table with no data.
11
+ *
12
+ * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same
13
+ * function `db push` uses to write `drizzle/policies.sql`, so this compares
14
+ * against exactly what would be applied rather than a reimplementation.
15
+ */
16
+ import type { CollectionConfig } from "@rebasepro/types";
17
+
18
+ import { generatePostgresPoliciesDdl } from "../schema/generate-postgres-ddl-logic";
19
+
20
+ export interface PolicyRef {
21
+ schema: string;
22
+ table: string;
23
+ name: string;
24
+ /** Roles in the TO clause. */
25
+ roles: string[];
26
+ /** SELECT / INSERT / UPDATE / DELETE / ALL. */
27
+ command: string;
28
+ }
29
+
30
+ export interface PolicyDrift {
31
+ /** Described by the collections, absent from the database. */
32
+ missing: PolicyRef[];
33
+ /** In the database, described by no collection — stale pushes live here. */
34
+ orphaned: PolicyRef[];
35
+ /** Same policy name, different roles or command. */
36
+ diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];
37
+ }
38
+
39
+ export interface Queryable {
40
+ query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;
41
+ }
42
+
43
+ const CREATE_POLICY = /CREATE POLICY "([^"]+)" ON "([^"]+)"\."([^"]+)"\s+AS (\w+)\s+FOR (\w+)\s+TO ([^\n]+?)(?:\s+USING|\s+WITH CHECK|;)/gi;
44
+
45
+ /** Parse the generated DDL rather than rebuilding the shape by hand. */
46
+ export function parseExpectedPolicies(ddl: string): PolicyRef[] {
47
+ const found: PolicyRef[] = [];
48
+ for (const m of ddl.matchAll(CREATE_POLICY)) {
49
+ const [, name, schema, table, , command, rolesRaw] = m;
50
+ const roles = rolesRaw
51
+ .split(",")
52
+ .map((r) => r.trim().replace(/^"|"$/g, ""))
53
+ .filter(Boolean);
54
+ found.push({ schema, table, name, roles, command: command.toUpperCase() });
55
+ }
56
+ return found;
57
+ }
58
+
59
+ async function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {
60
+ const { rows } = await client.query<{
61
+ schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;
62
+ }>(
63
+ `SELECT schemaname, tablename, policyname, roles, cmd
64
+ FROM pg_policies
65
+ WHERE schemaname = ANY($1)`,
66
+ [schemas]
67
+ );
68
+
69
+ return rows.map((r) => ({
70
+ schema: r.schemaname,
71
+ table: r.tablename,
72
+ name: r.policyname,
73
+ // node-postgres yields text[] as an array; some drivers hand back "{a,b}".
74
+ roles: Array.isArray(r.roles)
75
+ ? r.roles
76
+ : String(r.roles ?? "").replace(/^\{|\}$/g, "").split(",").filter(Boolean),
77
+ command: (r.cmd ?? "ALL").toUpperCase()
78
+ }));
79
+ }
80
+
81
+ const keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;
82
+ const sameRoles = (a: string[], b: string[]) =>
83
+ a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
84
+
85
+ /**
86
+ * Diff expected against live.
87
+ *
88
+ * Compares names, roles and command only — all exact values. Policy
89
+ * *expressions* are deliberately not compared: Postgres rewrites `qual`/
90
+ * `with_check` when storing them (parenthesising, casting, schema-qualifying),
91
+ * so text comparison reports drift that does not exist, and a check that cries
92
+ * wolf gets ignored. Roles alone catch the failure this exists for.
93
+ */
94
+ export async function checkPolicyDrift(
95
+ client: Queryable,
96
+ collections: CollectionConfig[]
97
+ ): Promise<PolicyDrift> {
98
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));
99
+ const schemas = [...new Set(expected.map((p) => p.schema))];
100
+ // Nothing expected means nothing to reconcile against; scanning every
101
+ // schema would report the whole database as orphaned.
102
+ if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [] };
103
+
104
+ const live = await readLivePolicies(client, schemas);
105
+ const liveByKey = new Map(live.map((p) => [keyOf(p), p]));
106
+ const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));
107
+
108
+ const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [] };
109
+
110
+ for (const [key, want] of expectedByKey) {
111
+ const got = liveByKey.get(key);
112
+ if (!got) {
113
+ drift.missing.push(want);
114
+ continue;
115
+ }
116
+ const differences: string[] = [];
117
+ if (!sameRoles(want.roles, got.roles)) {
118
+ differences.push(`roles: expected [${want.roles.join(", ")}], database has [${got.roles.join(", ")}]`);
119
+ }
120
+ if (want.command !== got.command) {
121
+ differences.push(`command: expected ${want.command}, database has ${got.command}`);
122
+ }
123
+ if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });
124
+ }
125
+
126
+ for (const [key, got] of liveByKey) {
127
+ if (!expectedByKey.has(key)) drift.orphaned.push(got);
128
+ }
129
+
130
+ return drift;
131
+ }
132
+
133
+ export const hasDrift = (d: PolicyDrift): boolean =>
134
+ d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0;
135
+
136
+ /** Human-readable report; empty string when the database matches the config. */
137
+ export function formatPolicyDrift(drift: PolicyDrift): string {
138
+ if (!hasDrift(drift)) return "";
139
+ const lines: string[] = [];
140
+
141
+ if (drift.missing.length > 0) {
142
+ lines.push(" Missing — described by your collections, absent from the database:");
143
+ for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`);
144
+ lines.push(" Run `rebase db push` to apply them.");
145
+ }
146
+ if (drift.orphaned.length > 0) {
147
+ lines.push(" Orphaned — in the database, described by no collection:");
148
+ for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`);
149
+ lines.push(" Left behind by an earlier push. These still filter rows.");
150
+ }
151
+ if (drift.diverged.length > 0) {
152
+ lines.push(" Diverged — same policy, different definition:");
153
+ for (const d of drift.diverged) {
154
+ lines.push(` • ${d.expected.schema}.${d.expected.table} → "${d.expected.name}"`);
155
+ for (const diff of d.differences) lines.push(` ${diff}`);
156
+ }
157
+ }
158
+ return lines.join("\n");
159
+ }