@rebasepro/server-postgres 0.17.3 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,56 +0,0 @@
1
- /**
2
- * The PostgreSQL type → Rebase property type mapping.
3
- *
4
- * Split out of `introspect-db-logic` so that the structural analysis can use it
5
- * without importing the generator, which imports the analysis. Re-exported from
6
- * `introspect-db-logic` so existing callers keep their import path.
7
- */
8
-
9
- /**
10
- * Map a PostgreSQL data type to a Rebase property type.
11
- */
12
- export function mapPgType(dataType: string): string {
13
- const dt = dataType.toLowerCase();
14
-
15
- // Interval MUST be checked before numeric ("interval" contains "int")
16
- if (dt === "interval") return "string";
17
-
18
- // Array types MUST be checked before numeric ("_int4" contains "int")
19
- if (dt === "array" || dt.startsWith("_")) return "array";
20
-
21
- // Numeric types
22
- if (
23
- dt.includes("int") || // integer, smallint, bigint
24
- dt.includes("numeric") ||
25
- dt.includes("decimal") ||
26
- dt.includes("serial") || // serial, bigserial
27
- dt === "real" ||
28
- dt === "float4" ||
29
- dt === "float8" ||
30
- dt === "double precision" ||
31
- dt === "money"
32
- ) {
33
- return "number";
34
- }
35
-
36
- // Boolean
37
- if (dt.includes("bool")) return "boolean";
38
-
39
- // Date / Time
40
- if (dt.includes("time") || dt.includes("date")) return "date";
41
-
42
- // JSON
43
- if (dt === "json" || dt === "jsonb") return "map";
44
-
45
- // Binary
46
- if (dt === "bytea") return "binary";
47
-
48
- // Network types
49
- if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
50
-
51
- // UUID
52
- if (dt === "uuid") return "string";
53
-
54
- // Text/varchar/char — default to string
55
- return "string";
56
- }
@@ -1,257 +0,0 @@
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
- buildTablesMap,
11
- buildEnumMap,
12
- generateCollectionFile,
13
- generateIndexContent,
14
- mergeIndexContent,
15
- safeHostFromUrl
16
- } from "./introspect-db-logic";
17
- import { detectCollectionBuilder } from "./introspect-db-project";
18
- import { countRowsUpTo, readSchemaMetadata } from "./introspect-db-queries";
19
- import { classifyTables, lookupCandidates, LOOKUP_MAX_ROWS } from "./introspect-db-structure";
20
- import { parseCheckConstraints } from "./introspect-db-constraints";
21
- import { out, outWarn, outError } from "../cli-output";
22
-
23
- async function main() {
24
- const args = arg(
25
- {
26
- "--output": String,
27
- "--collections": String,
28
- "--force": Boolean,
29
- "--schema": String,
30
- "--data-inference": Boolean,
31
- "--no-data-inference": Boolean,
32
- "-o": "--output",
33
- "-c": "--collections",
34
- "-f": "--force"
35
- },
36
- { permissive: true }
37
- );
38
-
39
- const cwd = process.cwd();
40
- const isBackendDir = path.basename(cwd) === "backend";
41
- const defaultOutDir = isBackendDir
42
- ? path.resolve(cwd, "..", "config", "collections")
43
- : path.resolve(cwd, "config", "collections");
44
-
45
- const outDir = args["--output"] || args["--collections"] || defaultOutDir;
46
- const force = args["--force"] || false;
47
- const pgSchema = args["--schema"] || "public";
48
-
49
- if (!fs.existsSync(outDir)) {
50
- fs.mkdirSync(outDir, { recursive: true });
51
- }
52
-
53
- // Load env
54
- const envPaths = [
55
- process.env.DOTENV_CONFIG_PATH,
56
- path.resolve(process.cwd(), ".env"),
57
- path.resolve(process.cwd(), "../.env"),
58
- path.resolve(process.cwd(), "../../.env")
59
- ].filter(Boolean) as string[];
60
-
61
- for (const p of envPaths) {
62
- if (fs.existsSync(p)) {
63
- dotenv.config({ path: p, quiet: true });
64
- break;
65
- }
66
- }
67
-
68
- const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
69
- if (!databaseUrl) {
70
- outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
71
- process.exit(1);
72
- }
73
-
74
- const client = new pg.Client({ connectionString: databaseUrl });
75
-
76
- try {
77
- await client.connect();
78
- } catch (err) {
79
- outError(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
80
- outError(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
81
- process.exit(1);
82
- }
83
-
84
- // Log the host portion safely — handle URLs without "@"
85
- const hostPart = safeHostFromUrl(databaseUrl);
86
- out(chalk.gray(`Connected to database: ${hostPart}`));
87
- out(chalk.gray(`Introspecting schema '${pgSchema}'...`));
88
-
89
- try {
90
- const metadata = await readSchemaMetadata(client, pgSchema);
91
- const enumMap = buildEnumMap(metadata.enumValues);
92
- const tablesMap = buildTablesMap(metadata.tables, metadata.columns, metadata.pks, metadata.fks);
93
- const fks = metadata.fks;
94
-
95
- // Only tables that could structurally be a code list are counted, and
96
- // each count stops at the threshold — see `countRowsUpTo`. Introspection
97
- // runs against a database it does not own, so "cheap on a table of any
98
- // size" is a requirement, not an optimization.
99
- for (const table of lookupCandidates(metadata, tablesMap)) {
100
- try {
101
- metadata.rowCounts[table] = await countRowsUpTo(client, pgSchema, table, LOOKUP_MAX_ROWS);
102
- } catch (err) {
103
- // A table this run cannot read is simply not classified as a code
104
- // list; everything else about it still generates.
105
- out(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
106
- }
107
- }
108
-
109
- const classifications = classifyTables(metadata, tablesMap);
110
- const checkFacts = parseCheckConstraints(metadata.checks);
111
-
112
- // Which builder the generated files may import — read from the manifests
113
- // above `outDir`, because a project without `@rebasepro/cms-types` cannot
114
- // resolve that import and has no `admin` block to write into either.
115
- const builder = detectCollectionBuilder(outDir);
116
- if (builder === "annotation") {
117
- outWarn(chalk.yellow(
118
- "⚠ Neither @rebasepro/cms-types nor @rebasepro/common is declared above " +
119
- `${outDir}.`
120
- ));
121
- outWarn(chalk.gray(
122
- " Generating plain type annotations, which widen the property keys to `string`: " +
123
- "`propertiesOrder` and friends will not be checked. Add @rebasepro/common to the " +
124
- "config package to turn that checking on."
125
- ));
126
- }
127
- const joinTables = new Set(
128
- Array.from(classifications.values())
129
- .filter((c) => c.role === "junction")
130
- .map((c) => c.table)
131
- );
132
-
133
- const roleCount = (role: string) =>
134
- Array.from(classifications.values()).filter((c) => c.role === role).length;
135
-
136
- out(chalk.blue(`Found ${tablesMap.size} tables.`));
137
- out(chalk.gray(
138
- ` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ` +
139
- `${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`
140
- ));
141
-
142
- let runDataInference = false;
143
- if (args["--no-data-inference"]) {
144
- runDataInference = false;
145
- } else if (args["--data-inference"] !== undefined) {
146
- runDataInference = args["--data-inference"];
147
- } else if (!process.stdin.isTTY) {
148
- // No terminal to answer the question below (scaffolding scripts, CI,
149
- // `rebase init --introspect`) — asking would hang forever.
150
- out(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
151
- } else {
152
- const rl = readline.createInterface({
153
- input: process.stdin,
154
- output: process.stdout
155
- });
156
- 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));
157
- runDataInference = answer.trim().toLowerCase() === "y";
158
- rl.close();
159
- }
160
-
161
- if (runDataInference) {
162
- out(chalk.gray("Sampling database rows for data inference..."));
163
- }
164
-
165
- // Generate Collections
166
- const generatedFiles: string[] = [];
167
- const skippedFiles: string[] = [];
168
-
169
- const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
170
-
171
- const BATCH_SIZE = 10;
172
- for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
173
- const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
174
-
175
- await Promise.all(batch.map(async ([tableName, meta]) => {
176
- // ── File overwrite protection ──────────────────────────────
177
- const filePath = path.join(outDir, `${tableName}.ts`);
178
- if (fs.existsSync(filePath) && !force) {
179
- skippedFiles.push(tableName);
180
- return;
181
- }
182
-
183
- let sampleData: Record<string, unknown>[] | undefined = undefined;
184
- if (runDataInference) {
185
- try {
186
- const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
187
- sampleData = rows;
188
- } catch (err) {
189
- outError(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
190
- }
191
- }
192
-
193
- const fileContent = generateCollectionFile(
194
- tableName,
195
- meta,
196
- fks,
197
- joinTables,
198
- tablesMap,
199
- enumMap,
200
- sampleData,
201
- { metadata, classifications, checkFacts, builder }
202
- );
203
-
204
- fs.writeFileSync(filePath, fileContent, "utf-8");
205
- generatedFiles.push(tableName);
206
- out(chalk.green(` ✓ ${filePath}`));
207
- }));
208
- }
209
-
210
- // Generate index.ts (sorted alphabetically for deterministic output)
211
- if (generatedFiles.length > 0) {
212
- const indexPath = path.join(outDir, "index.ts");
213
-
214
- if (fs.existsSync(indexPath) && !force) {
215
- // Merge: read existing index, add new exports that don't already exist
216
- const existing = fs.readFileSync(indexPath, "utf-8");
217
- const merged = mergeIndexContent(existing, generatedFiles);
218
- fs.writeFileSync(indexPath, merged, "utf-8");
219
- } else {
220
- // --force replaces collections derived from the database, but the
221
- // directory can also hold hand-written ones with no table in the
222
- // introspected schema (the auth users collection lives in
223
- // "rebase"). The backend discovers the whole directory, so an
224
- // index listing only introspected tables would silently drop them
225
- // from the admin UI while the API still served them.
226
- const siblings = fs.readdirSync(outDir)
227
- .filter(f => f.endsWith(".ts") && f !== "index.ts")
228
- .map(f => f.replace(/\.ts$/, ""));
229
- const allFiles = [...new Set([...generatedFiles, ...siblings])];
230
- const indexContent = generateIndexContent(allFiles);
231
- fs.writeFileSync(indexPath, indexContent, "utf-8");
232
- }
233
- out(chalk.green(` ✓ ${indexPath}`));
234
- }
235
-
236
- out("");
237
- if (skippedFiles.length > 0) {
238
- out(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
239
- out(chalk.gray(" Use --force to overwrite existing files."));
240
- out("");
241
- }
242
- out(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
243
- out(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
244
- out("");
245
-
246
- } catch (e) {
247
- outError(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
248
- process.exit(1);
249
- } finally {
250
- await client.end();
251
- }
252
- }
253
-
254
- main().catch((err) => {
255
- outError(String(err));
256
- process.exit(1);
257
- });
@@ -1,260 +0,0 @@
1
- import { describe, expect, it } from "@jest/globals";
2
- import { resolveCollectionRelations } from "@rebasepro/common";
3
- import type { CollectionConfig } from "@rebasepro/types";
4
-
5
- import { buildCollectionsFromSchema, introspectSchema, readRlsStatus, IntrospectedSchema, Queryable } from "./introspect-runtime";
6
- import { buildTablesMap, identifyJoinTables, TableColumn, ForeignKeyRow, PrimaryKeyRow } from "./introspect-db-logic";
7
-
8
- function column(overrides: Partial<TableColumn> & { table_name: string; column_name: string }): TableColumn {
9
- return {
10
- data_type: "text",
11
- udt_name: "text",
12
- is_nullable: "YES",
13
- column_default: null,
14
- atttypmod: null,
15
- ...overrides
16
- };
17
- }
18
-
19
- function schemaOf(
20
- columns: TableColumn[],
21
- pks: PrimaryKeyRow[],
22
- fks: ForeignKeyRow[] = [],
23
- enums: Map<string, string[]> = new Map()
24
- ): IntrospectedSchema {
25
- const tableNames = [...new Set(columns.map((c) => c.table_name))].map((table_name) => ({ table_name }));
26
- const tablesMap = buildTablesMap(tableNames, columns, pks, fks);
27
- return { tablesMap, enumMap: enums, joinTables: identifyJoinTables(tablesMap) };
28
- }
29
-
30
- describe("buildCollectionsFromSchema", () => {
31
- it("derives a collection per table with humanized names and the pg schema", () => {
32
- const schema = schemaOf(
33
- [
34
- column({ table_name: "blog_posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
35
- column({ table_name: "blog_posts", column_name: "title", is_nullable: "NO" })
36
- ],
37
- [{ table_name: "blog_posts", column_name: "id" }]
38
- );
39
-
40
- const [collection] = buildCollectionsFromSchema(schema, "public");
41
-
42
- expect(collection.slug).toBe("blog_posts");
43
- expect(collection.table).toBe("blog_posts");
44
- expect((collection as { schema?: string }).schema).toBe("public");
45
- expect(collection.name).toBe("Blog Posts");
46
- expect(collection.singularName).toBe("Blog Post");
47
- });
48
-
49
- it("maps a uuid primary key to isId uuid and a serial one to increment", () => {
50
- const schema = schemaOf(
51
- [
52
- column({ table_name: "users", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
53
- column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" })
54
- ],
55
- [
56
- { table_name: "users", column_name: "id" },
57
- { table_name: "orders", column_name: "id" }
58
- ]
59
- );
60
-
61
- const collections = buildCollectionsFromSchema(schema, "public");
62
- const users = collections.find((c) => c.slug === "users")!;
63
- const orders = collections.find((c) => c.slug === "orders")!;
64
-
65
- expect((users.properties as any).id.isId).toBe("uuid");
66
- expect((orders.properties as any).id.isId).toBe("increment");
67
- });
68
-
69
- it("marks NOT NULL columns without a default as required, and leaves others optional", () => {
70
- const schema = schemaOf(
71
- [
72
- column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
73
- column({ table_name: "posts", column_name: "title", is_nullable: "NO" }),
74
- column({ table_name: "posts", column_name: "subtitle", is_nullable: "YES" }),
75
- column({ table_name: "posts", column_name: "status", is_nullable: "NO", column_default: "'draft'" })
76
- ],
77
- [{ table_name: "posts", column_name: "id" }]
78
- );
79
-
80
- const props = buildCollectionsFromSchema(schema, "public")[0].properties as any;
81
-
82
- expect(props.title.validation).toEqual({ required: true });
83
- expect(props.subtitle.validation).toBeUndefined();
84
- // A default means the database fills it in, so the API must not demand it.
85
- expect(props.status.validation).toBeUndefined();
86
- });
87
-
88
- it("expands a pg enum column into enum entries", () => {
89
- const schema = schemaOf(
90
- [
91
- column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
92
- column({ table_name: "posts", column_name: "status", data_type: "USER-DEFINED", udt_name: "post_status" })
93
- ],
94
- [{ table_name: "posts", column_name: "id" }],
95
- [],
96
- new Map([["post_status", ["draft", "in_review"]]])
97
- );
98
-
99
- const props = buildCollectionsFromSchema(schema, "public")[0].properties as any;
100
-
101
- expect(props.status.type).toBe("string");
102
- expect(props.status.enum).toEqual([
103
- { id: "draft", label: "Draft" },
104
- { id: "in_review", label: "In Review" }
105
- ]);
106
- });
107
-
108
- it("turns a foreign key into an owning relation and drops the raw fk column", () => {
109
- const schema = schemaOf(
110
- [
111
- column({ table_name: "authors", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
112
- column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
113
- column({ table_name: "posts", column_name: "author_id", data_type: "uuid", udt_name: "uuid" })
114
- ],
115
- [
116
- { table_name: "authors", column_name: "id" },
117
- { table_name: "posts", column_name: "id" }
118
- ],
119
- [{ table_name: "posts", column_name: "author_id", foreign_table_name: "authors", foreign_column_name: "id" }]
120
- );
121
-
122
- const posts = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "posts")!;
123
- const props = posts.properties as any;
124
-
125
- expect(props.author_id).toBeUndefined();
126
- expect(props.author.name).toBe("Author");
127
- expect(props.author.type).toBe("relation");
128
- // The descriptor is nested under `relation`, carries `kind`, and its
129
- // `target` is a thunk — see the resolvability test below for why none of
130
- // those three are stylistic.
131
- expect(props.author.relation).toMatchObject({ kind: "belongsTo", localKey: "author_id" });
132
- expect(typeof props.author.relation.target).toBe("function");
133
- expect(props.author.relation.target().slug).toBe("authors");
134
- });
135
-
136
- /**
137
- * The shape has to be one the resolver actually reads.
138
- *
139
- * This path emitted `cardinality` / `direction` / a bare-string `target`
140
- * flat on the property — the shape `Relation` stopped accepting, and the
141
- * same drift `introspect-emits-valid-relations.test.ts` caught in the
142
- * generated-file path and this one was missed by. Nothing threw:
143
- * `resolveCollectionRelations` reads `property.relation`, found none, and
144
- * reported a collection with no relations at all.
145
- *
146
- * The visible cost was writes. `assertKnownWriteFields` learns an owning
147
- * relation's FK column from the *resolved* relation's `localKey`, and the FK
148
- * column is deliberately absent from `properties` ("surfaces as a
149
- * relation"), so with nothing resolving, `POST /api/data/orders` with
150
- * `product_id` came back 400 `has no field 'product_id'` — the column was
151
- * simultaneously the only way to set the relation and not a known field.
152
- *
153
- * Asserting the literal object is what let it drift, so this asserts
154
- * through the resolver instead: a future reshape has to keep it resolvable,
155
- * not merely keep the keys someone once wrote down.
156
- */
157
- it("emits a relation the resolver can read, so the fk column is writable", () => {
158
- const schema = schemaOf(
159
- [
160
- column({ table_name: "products", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
161
- column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
162
- column({ table_name: "orders", column_name: "product_id", data_type: "integer", udt_name: "int4" })
163
- ],
164
- [
165
- { table_name: "products", column_name: "id" },
166
- { table_name: "orders", column_name: "id" }
167
- ],
168
- [{ table_name: "orders", column_name: "product_id", foreign_table_name: "products", foreign_column_name: "id" }]
169
- );
170
-
171
- const orders = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "orders")!;
172
- const resolved = resolveCollectionRelations(orders as unknown as CollectionConfig);
173
-
174
- expect(Object.keys(resolved)).toEqual(["product"]);
175
- expect(resolved.product.kind).toBe("belongsTo");
176
- // `localKey` is the field `assertKnownWriteFields` adds to the known set,
177
- // which is what makes `product_id` writable. The write itself is covered
178
- // end-to-end by `scripts/smoke-baas.ts` against a real database.
179
- expect((resolved.product as { localKey: string }).localKey).toBe("product_id");
180
- });
181
-
182
- it("skips join tables — they are an edge between collections, not a collection", () => {
183
- const schema = schemaOf(
184
- [
185
- column({ table_name: "posts", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
186
- column({ table_name: "tags", column_name: "id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
187
- column({ table_name: "posts_tags", column_name: "post_id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
188
- column({ table_name: "posts_tags", column_name: "tag_id", data_type: "uuid", udt_name: "uuid", is_nullable: "NO" })
189
- ],
190
- [
191
- { table_name: "posts", column_name: "id" },
192
- { table_name: "tags", column_name: "id" },
193
- { table_name: "posts_tags", column_name: "post_id" },
194
- { table_name: "posts_tags", column_name: "tag_id" }
195
- ],
196
- [
197
- { table_name: "posts_tags", column_name: "post_id", foreign_table_name: "posts", foreign_column_name: "id" },
198
- { table_name: "posts_tags", column_name: "tag_id", foreign_table_name: "tags", foreign_column_name: "id" }
199
- ]
200
- );
201
-
202
- const slugs = buildCollectionsFromSchema(schema, "public").map((c) => c.slug);
203
-
204
- expect(slugs).toEqual(expect.arrayContaining(["posts", "tags"]));
205
- expect(slugs).not.toContain("posts_tags");
206
- });
207
- });
208
-
209
- describe("introspectSchema", () => {
210
- it("scopes every query to the requested schema", async () => {
211
- const calls: unknown[][] = [];
212
- const client: Queryable = {
213
- query: async (_text: string, values?: unknown[]) => {
214
- calls.push(values ?? []);
215
- return { rows: [] as any[] };
216
- }
217
- };
218
-
219
- await introspectSchema(client, "analytics");
220
-
221
- expect(calls.length).toBeGreaterThan(0);
222
- for (const values of calls) {
223
- expect(values).toEqual(["analytics"]);
224
- }
225
- });
226
- });
227
-
228
- describe("readRlsStatus", () => {
229
- it("reports RLS state and policy count per table", async () => {
230
- const client: Queryable = {
231
- query: async () => ({
232
- rows: [
233
- { table: "posts", rls_enabled: true, policy_count: "2" },
234
- { table: "secrets", rls_enabled: false, policy_count: "0" },
235
- { table: "locked", rls_enabled: true, policy_count: 0 }
236
- ] as never[]
237
- })
238
- };
239
-
240
- const status = await readRlsStatus(client, "public");
241
-
242
- // Unprotected: no authorization model, so baas must not serve it.
243
- expect(status.get("secrets")).toEqual({ table: "secrets", rlsEnabled: false, policyCount: 0 });
244
- // Protected and policied.
245
- expect(status.get("posts")).toEqual({ table: "posts", rlsEnabled: true, policyCount: 2 });
246
- // RLS on, no policies — legal, and returns nothing at all.
247
- expect(status.get("locked")).toEqual({ table: "locked", rlsEnabled: true, policyCount: 0 });
248
- });
249
-
250
- it("scopes the lookup to the requested schema", async () => {
251
- const calls: unknown[][] = [];
252
- const client: Queryable = {
253
- query: async (_t: string, v?: unknown[]) => { calls.push(v ?? []); return { rows: [] as never[] }; }
254
- };
255
-
256
- await readRlsStatus(client, "analytics");
257
-
258
- expect(calls[0]).toEqual(["analytics"]);
259
- });
260
- });