@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.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- 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
|
+
});
|