@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,266 @@
1
+ import chalk from "chalk";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import pg from "pg";
5
+ import arg from "arg";
6
+ import * as dotenv from "dotenv";
7
+ import readline from "readline";
8
+
9
+ import {
10
+ TableRow,
11
+ TableColumn,
12
+ EnumValue,
13
+ PrimaryKeyRow,
14
+ ForeignKeyRow,
15
+ buildTablesMap,
16
+ buildEnumMap,
17
+ identifyJoinTables,
18
+ generateCollectionFile,
19
+ generateIndexContent,
20
+ mergeIndexContent,
21
+ safeHostFromUrl
22
+ } from "./introspect-db-logic";
23
+ import { logger } from "@rebasepro/server";
24
+
25
+ async function main() {
26
+ const args = arg(
27
+ {
28
+ "--output": String,
29
+ "--collections": String,
30
+ "--force": Boolean,
31
+ "--schema": String,
32
+ "--data-inference": Boolean,
33
+ "-o": "--output",
34
+ "-c": "--collections",
35
+ "-f": "--force"
36
+ },
37
+ { permissive: true }
38
+ );
39
+
40
+ const cwd = process.cwd();
41
+ const isBackendDir = path.basename(cwd) === "backend";
42
+ const defaultOutDir = isBackendDir
43
+ ? path.resolve(cwd, "..", "config", "collections")
44
+ : path.resolve(cwd, "config", "collections");
45
+
46
+ const outDir = args["--output"] || args["--collections"] || defaultOutDir;
47
+ const force = args["--force"] || false;
48
+ const pgSchema = args["--schema"] || "public";
49
+
50
+ if (!fs.existsSync(outDir)) {
51
+ fs.mkdirSync(outDir, { recursive: true });
52
+ }
53
+
54
+ // Load env
55
+ const envPaths = [
56
+ process.env.DOTENV_CONFIG_PATH,
57
+ path.resolve(process.cwd(), ".env"),
58
+ path.resolve(process.cwd(), "../.env"),
59
+ path.resolve(process.cwd(), "../../.env")
60
+ ].filter(Boolean) as string[];
61
+
62
+ for (const p of envPaths) {
63
+ if (fs.existsSync(p)) {
64
+ dotenv.config({ path: p });
65
+ break;
66
+ }
67
+ }
68
+
69
+ const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
70
+ if (!databaseUrl) {
71
+ logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
72
+ process.exit(1);
73
+ }
74
+
75
+ const client = new pg.Client({ connectionString: databaseUrl });
76
+
77
+ try {
78
+ await client.connect();
79
+ } catch (err) {
80
+ logger.error(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
81
+ logger.error(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
82
+ process.exit(1);
83
+ }
84
+
85
+ // Log the host portion safely — handle URLs without "@"
86
+ const hostPart = safeHostFromUrl(databaseUrl);
87
+ logger.info(chalk.gray(`Connected to database: ${hostPart}`));
88
+ logger.info(chalk.gray(`Introspecting schema '${pgSchema}'...`));
89
+
90
+ try {
91
+ // 1. Get Tables
92
+ const { rows: tables } = await client.query<TableRow>(`
93
+ SELECT table_name
94
+ FROM information_schema.tables
95
+ WHERE table_schema = $1 AND table_type = 'BASE TABLE'
96
+ AND table_name NOT LIKE 'drizzle_%'
97
+ AND table_name NOT LIKE 'rebase_%'
98
+ ORDER BY table_name
99
+ `, [pgSchema]);
100
+
101
+ // 2. Get Columns
102
+ const { rows: columns } = await client.query<TableColumn>(`
103
+ SELECT
104
+ c.table_name,
105
+ c.column_name,
106
+ c.data_type,
107
+ c.udt_name,
108
+ c.is_nullable,
109
+ c.column_default,
110
+ (SELECT a.atttypmod FROM pg_attribute a
111
+ JOIN pg_class pc ON a.attrelid = pc.oid
112
+ WHERE pc.relname = c.table_name
113
+ AND a.attname = c.column_name
114
+ AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod
115
+ FROM information_schema.columns c
116
+ WHERE c.table_schema = $1
117
+ `, [pgSchema]);
118
+
119
+ // 2b. Get Enum Types and their values
120
+ const { rows: enumValues } = await client.query<EnumValue>(`
121
+ SELECT t.typname AS enum_name,
122
+ e.enumlabel AS enum_value,
123
+ e.enumsortorder AS sort_order
124
+ FROM pg_type t
125
+ JOIN pg_enum e ON t.oid = e.enumtypid
126
+ JOIN pg_namespace n ON t.typnamespace = n.oid
127
+ WHERE n.nspname = $1
128
+ ORDER BY t.typname, e.enumsortorder
129
+ `, [pgSchema]);
130
+
131
+ // Build a map: enum_name -> ordered list of values
132
+ const enumMap = buildEnumMap(enumValues);
133
+
134
+ // 3. Get Primary Keys
135
+ const { rows: pks } = await client.query<PrimaryKeyRow>(`
136
+ SELECT t.relname as table_name, a.attname as column_name
137
+ FROM pg_index i
138
+ JOIN pg_attribute a ON a.attrelid = i.indrelid
139
+ AND a.attnum = ANY(i.indkey)
140
+ JOIN pg_class t ON t.oid = i.indrelid
141
+ JOIN pg_namespace n ON n.oid = t.relnamespace
142
+ WHERE i.indisprimary AND n.nspname = $1
143
+ `, [pgSchema]);
144
+
145
+ // 4. Get Foreign Keys
146
+ const { rows: fks } = await client.query<ForeignKeyRow>(`
147
+ SELECT
148
+ tc.table_name,
149
+ kcu.column_name,
150
+ ccu.table_name AS foreign_table_name,
151
+ ccu.column_name AS foreign_column_name
152
+ FROM
153
+ information_schema.table_constraints AS tc
154
+ JOIN information_schema.key_column_usage AS kcu
155
+ ON tc.constraint_name = kcu.constraint_name
156
+ AND tc.table_schema = kcu.table_schema
157
+ JOIN information_schema.constraint_column_usage AS ccu
158
+ ON ccu.constraint_name = tc.constraint_name
159
+ AND ccu.table_schema = tc.table_schema
160
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1
161
+ `, [pgSchema]);
162
+
163
+ const tablesMap = buildTablesMap(tables, columns, pks, fks);
164
+ const joinTables = identifyJoinTables(tablesMap);
165
+
166
+ logger.info(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
167
+
168
+ let runDataInference = false;
169
+ if (args["--data-inference"] !== undefined) {
170
+ runDataInference = args["--data-inference"];
171
+ } else {
172
+ const rl = readline.createInterface({
173
+ input: process.stdin,
174
+ output: process.stdout
175
+ });
176
+ const answer = await new Promise<string>((resolve) => rl.question(chalk.yellow("? Do you want to run comprehensive data inference on sampled rows to auto-detect types, formats, constraints, and UI configurations? (y/N) "), resolve));
177
+ runDataInference = answer.trim().toLowerCase() === "y";
178
+ rl.close();
179
+ }
180
+
181
+ if (runDataInference) {
182
+ logger.info(chalk.gray("Sampling database rows for data inference..."));
183
+ }
184
+
185
+ // Generate Collections
186
+ const generatedFiles: string[] = [];
187
+ const skippedFiles: string[] = [];
188
+
189
+ const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
190
+
191
+ const BATCH_SIZE = 10;
192
+ for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
193
+ const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
194
+
195
+ await Promise.all(batch.map(async ([tableName, meta]) => {
196
+ // ── File overwrite protection ──────────────────────────────
197
+ const filePath = path.join(outDir, `${tableName}.ts`);
198
+ if (fs.existsSync(filePath) && !force) {
199
+ skippedFiles.push(tableName);
200
+ return;
201
+ }
202
+
203
+ let sampleData: Record<string, unknown>[] | undefined = undefined;
204
+ if (runDataInference) {
205
+ try {
206
+ const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
207
+ sampleData = rows;
208
+ } catch (err) {
209
+ logger.error(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
210
+ }
211
+ }
212
+
213
+ const fileContent = generateCollectionFile(
214
+ tableName,
215
+ meta,
216
+ fks,
217
+ joinTables,
218
+ tablesMap,
219
+ enumMap,
220
+ sampleData
221
+ );
222
+
223
+ fs.writeFileSync(filePath, fileContent, "utf-8");
224
+ generatedFiles.push(tableName);
225
+ logger.info(chalk.green(` ✓ ${filePath}`));
226
+ }));
227
+ }
228
+
229
+ // Generate index.ts (sorted alphabetically for deterministic output)
230
+ if (generatedFiles.length > 0) {
231
+ const indexPath = path.join(outDir, "index.ts");
232
+
233
+ if (fs.existsSync(indexPath) && !force) {
234
+ // Merge: read existing index, add new exports that don't already exist
235
+ const existing = fs.readFileSync(indexPath, "utf-8");
236
+ const merged = mergeIndexContent(existing, generatedFiles);
237
+ fs.writeFileSync(indexPath, merged, "utf-8");
238
+ } else {
239
+ const indexContent = generateIndexContent(generatedFiles);
240
+ fs.writeFileSync(indexPath, indexContent, "utf-8");
241
+ }
242
+ logger.info(chalk.green(` ✓ ${indexPath}`));
243
+ }
244
+
245
+ logger.info("");
246
+ if (skippedFiles.length > 0) {
247
+ logger.info(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
248
+ logger.info(chalk.gray(" Use --force to overwrite existing files."));
249
+ logger.info("");
250
+ }
251
+ logger.info(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
252
+ logger.info(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
253
+ logger.info("");
254
+
255
+ } catch (e) {
256
+ logger.error(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
257
+ process.exit(1);
258
+ } finally {
259
+ await client.end();
260
+ }
261
+ }
262
+
263
+ main().catch((err) => {
264
+ logger.error(String(err));
265
+ process.exit(1);
266
+ });
@@ -0,0 +1,212 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+
3
+ import { buildCollectionsFromSchema, introspectSchema, readRlsStatus, IntrospectedSchema, Queryable } from "./introspect-runtime";
4
+ import { buildTablesMap, identifyJoinTables, TableColumn, ForeignKeyRow, PrimaryKeyRow } from "./introspect-db-logic";
5
+
6
+ function column(overrides: Partial<TableColumn> & { table_name: string; column_name: string }): TableColumn {
7
+ return {
8
+ data_type: "text",
9
+ udt_name: "text",
10
+ is_nullable: "YES",
11
+ column_default: null,
12
+ atttypmod: null,
13
+ ...overrides
14
+ };
15
+ }
16
+
17
+ function schemaOf(
18
+ columns: TableColumn[],
19
+ pks: PrimaryKeyRow[],
20
+ fks: ForeignKeyRow[] = [],
21
+ enums: Map<string, string[]> = new Map()
22
+ ): IntrospectedSchema {
23
+ const tableNames = [...new Set(columns.map((c) => c.table_name))].map((table_name) => ({ table_name }));
24
+ const tablesMap = buildTablesMap(tableNames, columns, pks, fks);
25
+ return { tablesMap, enumMap: enums, joinTables: identifyJoinTables(tablesMap) };
26
+ }
27
+
28
+ describe("buildCollectionsFromSchema", () => {
29
+ it("derives a collection per table with humanized names and the pg schema", () => {
30
+ const schema = schemaOf(
31
+ [
32
+ column({ table_name: "blog_posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
33
+ column({ table_name: "blog_posts", column_name: "title", is_nullable: "NO" })
34
+ ],
35
+ [{ table_name: "blog_posts", column_name: "id" }]
36
+ );
37
+
38
+ const [collection] = buildCollectionsFromSchema(schema, "public");
39
+
40
+ expect(collection.slug).toBe("blog_posts");
41
+ expect(collection.table).toBe("blog_posts");
42
+ expect((collection as { schema?: string }).schema).toBe("public");
43
+ expect(collection.name).toBe("Blog Posts");
44
+ expect(collection.singularName).toBe("Blog Post");
45
+ });
46
+
47
+ it("maps a uuid primary key to isId uuid and a serial one to increment", () => {
48
+ const schema = schemaOf(
49
+ [
50
+ column({ table_name: "users", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
51
+ column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" })
52
+ ],
53
+ [
54
+ { table_name: "users", column_name: "id" },
55
+ { table_name: "orders", column_name: "id" }
56
+ ]
57
+ );
58
+
59
+ const collections = buildCollectionsFromSchema(schema, "public");
60
+ const users = collections.find((c) => c.slug === "users")!;
61
+ const orders = collections.find((c) => c.slug === "orders")!;
62
+
63
+ expect((users.properties as any).id.isId).toBe("uuid");
64
+ expect((orders.properties as any).id.isId).toBe("increment");
65
+ });
66
+
67
+ it("marks NOT NULL columns without a default as required, and leaves others optional", () => {
68
+ const schema = schemaOf(
69
+ [
70
+ column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
71
+ column({ table_name: "posts", column_name: "title", is_nullable: "NO" }),
72
+ column({ table_name: "posts", column_name: "subtitle", is_nullable: "YES" }),
73
+ column({ table_name: "posts", column_name: "status", is_nullable: "NO", column_default: "'draft'" })
74
+ ],
75
+ [{ table_name: "posts", column_name: "id" }]
76
+ );
77
+
78
+ const props = buildCollectionsFromSchema(schema, "public")[0].properties as any;
79
+
80
+ expect(props.title.validation).toEqual({ required: true });
81
+ expect(props.subtitle.validation).toBeUndefined();
82
+ // A default means the database fills it in, so the API must not demand it.
83
+ expect(props.status.validation).toBeUndefined();
84
+ });
85
+
86
+ it("expands a pg enum column into enum entries", () => {
87
+ const schema = schemaOf(
88
+ [
89
+ column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
90
+ column({ table_name: "posts", column_name: "status", data_type: "USER-DEFINED", udt_name: "post_status" })
91
+ ],
92
+ [{ table_name: "posts", column_name: "id" }],
93
+ [],
94
+ new Map([["post_status", ["draft", "in_review"]]])
95
+ );
96
+
97
+ const props = buildCollectionsFromSchema(schema, "public")[0].properties as any;
98
+
99
+ expect(props.status.type).toBe("string");
100
+ expect(props.status.enum).toEqual([
101
+ { id: "draft", label: "Draft" },
102
+ { id: "in_review", label: "In Review" }
103
+ ]);
104
+ });
105
+
106
+ it("turns a foreign key into an owning relation and drops the raw fk column", () => {
107
+ const schema = schemaOf(
108
+ [
109
+ column({ table_name: "authors", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
110
+ column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
111
+ column({ table_name: "posts", column_name: "author_id", data_type: "uuid", udt_name: "uuid" })
112
+ ],
113
+ [
114
+ { table_name: "authors", column_name: "id" },
115
+ { table_name: "posts", column_name: "id" }
116
+ ],
117
+ [{ table_name: "posts", column_name: "author_id", foreign_table_name: "authors", foreign_column_name: "id" }]
118
+ );
119
+
120
+ const posts = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "posts")!;
121
+ const props = posts.properties as any;
122
+
123
+ expect(props.author_id).toBeUndefined();
124
+ expect(props.author).toEqual({
125
+ name: "Author",
126
+ type: "relation",
127
+ target: "authors",
128
+ cardinality: "one",
129
+ direction: "owning",
130
+ localKey: "author_id"
131
+ });
132
+ });
133
+
134
+ it("skips join tables — they are an edge between collections, not a collection", () => {
135
+ const schema = schemaOf(
136
+ [
137
+ column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
138
+ column({ table_name: "tags", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
139
+ column({ table_name: "posts_tags", column_name: "post_id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
140
+ column({ table_name: "posts_tags", column_name: "tag_id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" })
141
+ ],
142
+ [
143
+ { table_name: "posts", column_name: "id" },
144
+ { table_name: "tags", column_name: "id" },
145
+ { table_name: "posts_tags", column_name: "post_id" },
146
+ { table_name: "posts_tags", column_name: "tag_id" }
147
+ ],
148
+ [
149
+ { table_name: "posts_tags", column_name: "post_id", foreign_table_name: "posts", foreign_column_name: "id" },
150
+ { table_name: "posts_tags", column_name: "tag_id", foreign_table_name: "tags", foreign_column_name: "id" }
151
+ ]
152
+ );
153
+
154
+ const slugs = buildCollectionsFromSchema(schema, "public").map((c) => c.slug);
155
+
156
+ expect(slugs).toEqual(expect.arrayContaining(["posts", "tags"]));
157
+ expect(slugs).not.toContain("posts_tags");
158
+ });
159
+ });
160
+
161
+ describe("introspectSchema", () => {
162
+ it("scopes every query to the requested schema", async () => {
163
+ const calls: unknown[][] = [];
164
+ const client: Queryable = {
165
+ query: async (_text: string, values?: unknown[]) => {
166
+ calls.push(values ?? []);
167
+ return { rows: [] as any[] };
168
+ }
169
+ };
170
+
171
+ await introspectSchema(client, "analytics");
172
+
173
+ expect(calls.length).toBeGreaterThan(0);
174
+ for (const values of calls) {
175
+ expect(values).toEqual(["analytics"]);
176
+ }
177
+ });
178
+ });
179
+
180
+ describe("readRlsStatus", () => {
181
+ it("reports RLS state and policy count per table", async () => {
182
+ const client: Queryable = {
183
+ query: async () => ({
184
+ rows: [
185
+ { table: "posts", rls_enabled: true, policy_count: "2" },
186
+ { table: "secrets", rls_enabled: false, policy_count: "0" },
187
+ { table: "locked", rls_enabled: true, policy_count: 0 }
188
+ ] as never[]
189
+ })
190
+ };
191
+
192
+ const status = await readRlsStatus(client, "public");
193
+
194
+ // Unprotected: no authorization model, so baas must not serve it.
195
+ expect(status.get("secrets")).toEqual({ table: "secrets", rlsEnabled: false, policyCount: 0 });
196
+ // Protected and policied.
197
+ expect(status.get("posts")).toEqual({ table: "posts", rlsEnabled: true, policyCount: 2 });
198
+ // RLS on, no policies — legal, and returns nothing at all.
199
+ expect(status.get("locked")).toEqual({ table: "locked", rlsEnabled: true, policyCount: 0 });
200
+ });
201
+
202
+ it("scopes the lookup to the requested schema", async () => {
203
+ const calls: unknown[][] = [];
204
+ const client: Queryable = {
205
+ query: async (_t: string, v?: unknown[]) => { calls.push(v ?? []); return { rows: [] as never[] }; }
206
+ };
207
+
208
+ await readRlsStatus(client, "analytics");
209
+
210
+ expect(calls[0]).toEqual(["analytics"]);
211
+ });
212
+ });