@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.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 (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What the project being introspected into can actually import.
3
+ *
4
+ * The generator is pure — `introspect-db-logic.ts` reads no files — so the one
5
+ * question it cannot answer for itself lives here: which `defineCollection`, if
6
+ * any, will resolve from the directory the collections are written to.
7
+ */
8
+ import fs from "fs";
9
+ import path from "path";
10
+
11
+ import { ADMIN_TYPES_PACKAGE, COMMON_PACKAGE, type CollectionBuilder } from "./introspect-db-logic";
12
+
13
+ /** How far up to look before giving up, if no project root announces itself. */
14
+ const MAX_LEVELS = 8;
15
+
16
+ /** Every dependency name a manifest declares, in any of the three fields. */
17
+ function declaredDependencies(manifestPath: string): Set<string> {
18
+ const names = new Set<string>();
19
+ let parsed: unknown;
20
+ try {
21
+ parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
22
+ } catch {
23
+ // A malformed or unreadable package.json is not this function's problem
24
+ // to report — it answers "can this import resolve", and the answer is no.
25
+ return names;
26
+ }
27
+ if (typeof parsed !== "object" || parsed === null) return names;
28
+ const manifest = parsed as Record<string, unknown>;
29
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
30
+ const block = manifest[field];
31
+ if (typeof block === "object" && block !== null) {
32
+ for (const name of Object.keys(block)) names.add(name);
33
+ }
34
+ }
35
+ return names;
36
+ }
37
+
38
+ /**
39
+ * Which `defineCollection` to generate against, for collections written to `outDir`.
40
+ *
41
+ * **The detection is the package manifests above the output directory**, unioned from
42
+ * `outDir` up to and including the project root — the first ancestor holding a
43
+ * `rebase.json`, or `MAX_LEVELS` up if there is none.
44
+ *
45
+ * That is the rule Node itself applies: a file in `config/collections` resolves a bare
46
+ * specifier through `config/node_modules`, then `<project>/node_modules`, and so on up.
47
+ * Reading the manifests along that same path answers the only question that matters —
48
+ * *will this import resolve in the project I am writing into* — from the state on disk
49
+ * at the moment of generation. The alternatives are all proxies for it: `rebase.json`'s
50
+ * `apps` block says a CMS scaffold declared an admin app, and a `frontend/` directory
51
+ * says one was scaffolded, but neither is what the compiler consults, and either can be
52
+ * true of a project whose `config` package does not depend on `@rebasepro/admin-types`.
53
+ *
54
+ * Ambiguity resolves towards the admin panel: a project that declares both packages has
55
+ * a panel, and `@rebasepro/admin-types` is the flavour that keeps the `admin` block.
56
+ */
57
+ export function detectCollectionBuilder(outDir: string): CollectionBuilder {
58
+ let dir = path.resolve(outDir);
59
+ const declared = new Set<string>();
60
+
61
+ for (let level = 0; level < MAX_LEVELS; level++) {
62
+ const manifest = path.join(dir, "package.json");
63
+ if (fs.existsSync(manifest)) {
64
+ for (const name of declaredDependencies(manifest)) declared.add(name);
65
+ }
66
+ // The project root, and the last level worth reading: anything above it
67
+ // belongs to whatever the project happens to be checked out inside.
68
+ if (fs.existsSync(path.join(dir, "rebase.json"))) break;
69
+
70
+ const parent = path.dirname(dir);
71
+ if (parent === dir) break;
72
+ dir = parent;
73
+ }
74
+
75
+ if (declared.has(ADMIN_TYPES_PACKAGE)) return "admin-types";
76
+ if (declared.has(COMMON_PACKAGE)) return "common";
77
+ return "annotation";
78
+ }
@@ -14,10 +14,11 @@ import {
14
14
  mergeIndexContent,
15
15
  safeHostFromUrl
16
16
  } from "./introspect-db-logic";
17
+ import { detectCollectionBuilder } from "./introspect-db-project";
17
18
  import { countRowsUpTo, readSchemaMetadata } from "./introspect-db-queries";
18
19
  import { classifyTables, lookupCandidates, LOOKUP_MAX_ROWS } from "./introspect-db-structure";
19
20
  import { parseCheckConstraints } from "./introspect-db-constraints";
20
- import { logger } from "@rebasepro/server";
21
+ import { out, outWarn, outError } from "../cli-output";
21
22
 
22
23
  async function main() {
23
24
  const args = arg(
@@ -59,14 +60,14 @@ async function main() {
59
60
 
60
61
  for (const p of envPaths) {
61
62
  if (fs.existsSync(p)) {
62
- dotenv.config({ path: p });
63
+ dotenv.config({ path: p, quiet: true });
63
64
  break;
64
65
  }
65
66
  }
66
67
 
67
68
  const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
68
69
  if (!databaseUrl) {
69
- logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
70
+ outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
70
71
  process.exit(1);
71
72
  }
72
73
 
@@ -75,15 +76,15 @@ async function main() {
75
76
  try {
76
77
  await client.connect();
77
78
  } catch (err) {
78
- logger.error(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
79
- logger.error(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
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."));
80
81
  process.exit(1);
81
82
  }
82
83
 
83
84
  // Log the host portion safely — handle URLs without "@"
84
85
  const hostPart = safeHostFromUrl(databaseUrl);
85
- logger.info(chalk.gray(`Connected to database: ${hostPart}`));
86
- logger.info(chalk.gray(`Introspecting schema '${pgSchema}'...`));
86
+ out(chalk.gray(`Connected to database: ${hostPart}`));
87
+ out(chalk.gray(`Introspecting schema '${pgSchema}'...`));
87
88
 
88
89
  try {
89
90
  const metadata = await readSchemaMetadata(client, pgSchema);
@@ -101,12 +102,28 @@ async function main() {
101
102
  } catch (err) {
102
103
  // A table this run cannot read is simply not classified as a code
103
104
  // list; everything else about it still generates.
104
- logger.info(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
105
+ out(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
105
106
  }
106
107
  }
107
108
 
108
109
  const classifications = classifyTables(metadata, tablesMap);
109
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/admin-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/admin-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
+ }
110
127
  const joinTables = new Set(
111
128
  Array.from(classifications.values())
112
129
  .filter((c) => c.role === "junction")
@@ -116,8 +133,8 @@ async function main() {
116
133
  const roleCount = (role: string) =>
117
134
  Array.from(classifications.values()).filter((c) => c.role === role).length;
118
135
 
119
- logger.info(chalk.blue(`Found ${tablesMap.size} tables.`));
120
- logger.info(chalk.gray(
136
+ out(chalk.blue(`Found ${tablesMap.size} tables.`));
137
+ out(chalk.gray(
121
138
  ` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ` +
122
139
  `${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`
123
140
  ));
@@ -130,7 +147,7 @@ async function main() {
130
147
  } else if (!process.stdin.isTTY) {
131
148
  // No terminal to answer the question below (scaffolding scripts, CI,
132
149
  // `rebase init --introspect`) — asking would hang forever.
133
- logger.info(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
150
+ out(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
134
151
  } else {
135
152
  const rl = readline.createInterface({
136
153
  input: process.stdin,
@@ -142,7 +159,7 @@ async function main() {
142
159
  }
143
160
 
144
161
  if (runDataInference) {
145
- logger.info(chalk.gray("Sampling database rows for data inference..."));
162
+ out(chalk.gray("Sampling database rows for data inference..."));
146
163
  }
147
164
 
148
165
  // Generate Collections
@@ -169,7 +186,7 @@ async function main() {
169
186
  const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
170
187
  sampleData = rows;
171
188
  } catch (err) {
172
- logger.error(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
189
+ outError(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
173
190
  }
174
191
  }
175
192
 
@@ -181,12 +198,12 @@ async function main() {
181
198
  tablesMap,
182
199
  enumMap,
183
200
  sampleData,
184
- { metadata, classifications, checkFacts }
201
+ { metadata, classifications, checkFacts, builder }
185
202
  );
186
203
 
187
204
  fs.writeFileSync(filePath, fileContent, "utf-8");
188
205
  generatedFiles.push(tableName);
189
- logger.info(chalk.green(` ✓ ${filePath}`));
206
+ out(chalk.green(` ✓ ${filePath}`));
190
207
  }));
191
208
  }
192
209
 
@@ -213,21 +230,21 @@ async function main() {
213
230
  const indexContent = generateIndexContent(allFiles);
214
231
  fs.writeFileSync(indexPath, indexContent, "utf-8");
215
232
  }
216
- logger.info(chalk.green(` ✓ ${indexPath}`));
233
+ out(chalk.green(` ✓ ${indexPath}`));
217
234
  }
218
235
 
219
- logger.info("");
236
+ out("");
220
237
  if (skippedFiles.length > 0) {
221
- logger.info(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
222
- logger.info(chalk.gray(" Use --force to overwrite existing files."));
223
- logger.info("");
238
+ out(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
239
+ out(chalk.gray(" Use --force to overwrite existing files."));
240
+ out("");
224
241
  }
225
- logger.info(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
226
- logger.info(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
227
- logger.info("");
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("");
228
245
 
229
246
  } catch (e) {
230
- logger.error(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
247
+ outError(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
231
248
  process.exit(1);
232
249
  } finally {
233
250
  await client.end();
@@ -235,6 +252,6 @@ async function main() {
235
252
  }
236
253
 
237
254
  main().catch((err) => {
238
- logger.error(String(err));
255
+ outError(String(err));
239
256
  process.exit(1);
240
257
  });
@@ -28,6 +28,7 @@ import {
28
28
  getIconForTable
29
29
  } from "./introspect-db-logic";
30
30
  import { humanize } from "./introspect-db-naming";
31
+ import { firstFreeKey, toWireKey } from "@rebasepro/utils";
31
32
 
32
33
  export interface IntrospectedSchema {
33
34
  tablesMap: Map<string, TableMeta>;
@@ -169,6 +170,7 @@ function buildProperties(
169
170
  enumMap: Map<string, string[]>
170
171
  ): Record<string, Record<string, unknown>> {
171
172
  const properties: Record<string, Record<string, unknown>> = {};
173
+ const takenKeys = new Set<string>();
172
174
 
173
175
  for (const col of meta.columns) {
174
176
  const isPk = meta.pks.includes(col.column_name);
@@ -188,7 +190,17 @@ function buildProperties(
188
190
  type: propType
189
191
  };
190
192
 
191
- const key = col.column_name;
193
+ // The wire name, with `columnName` above carrying the column. The two
194
+ // are different names for different things — this used to serve the
195
+ // column, so a runtime-introspected collection answered `user_id` while
196
+ // every authored one beside it answered `displayName`.
197
+ //
198
+ // `user_id` and `userId` as two real columns camel-case to one key, so
199
+ // the first free candidate wins and the second falls back to its own
200
+ // column name, then to a numbered tail. Never dropped, and stable for a
201
+ // given database: columns arrive in ordinal order.
202
+ const key = firstFreeKey([toWireKey(col.column_name), col.column_name], takenKeys);
203
+ takenKeys.add(key);
192
204
 
193
205
  if (isPk) {
194
206
  property.isId = idKindFor(col, propType);
@@ -239,7 +251,7 @@ function buildRelations(
239
251
  if (!targetSlug) continue;
240
252
 
241
253
  // Strip the conventional _id suffix: author_id -> author
242
- let key = fk.column_name.replace(/_id$/, "");
254
+ let key = toWireKey(fk.column_name.replace(/_id$/, ""));
243
255
  if (meta.pks.includes(fk.column_name) && key === fk.column_name) {
244
256
  // The FK is also the PK and its name doesn't imply a relation (e.g.
245
257
  // "id"), so naming the relation after the column would collide with
@@ -58,6 +58,7 @@ import {
58
58
  DEFAULT_SEARCH_WEIGHT,
59
59
  DEFAULT_FUZZY_THRESHOLD
60
60
  } from "@rebasepro/types";
61
+ import { createHash } from "node:crypto";
61
62
  import { getTableName } from "@rebasepro/common";
62
63
  import { toSnakeCase, toPostgresIdentifier } from "@rebasepro/utils";
63
64
 
@@ -460,6 +461,90 @@ export const searchIndexStatements = (spec: SearchColumnSpec): string[] => {
460
461
  return statements;
461
462
  };
462
463
 
464
+ // ── Telling a changed `search` block from an unchanged one ──────────────────
465
+
466
+ /**
467
+ * Marker on the comment of every generated search column this module creates.
468
+ *
469
+ * Versioned because the fingerprint below is only comparable against itself: a
470
+ * future change to how it is computed has to read as "not stamped by this
471
+ * version" rather than as drift on every existing column.
472
+ */
473
+ export const SEARCH_STAMP_PREFIX = "rebase:search:v1:";
474
+
475
+ /**
476
+ * A stable fingerprint of one generated column's expression.
477
+ *
478
+ * Why a stamp rather than reading the expression back: Postgres stores a
479
+ * generated column's expression *parsed*, and hands it back deparsed — casts
480
+ * made explicit, identifiers requoted, schema qualifications added or dropped
481
+ * according to `search_path`. Comparing that text to the text we generated
482
+ * would report drift on wording, and this comparison decides whether a boot
483
+ * refuses, so a false positive is an outage. The stamp is written by the same
484
+ * code that writes the column, so equality means what it says.
485
+ */
486
+ export const searchExpressionFingerprint = (expression: string): string =>
487
+ `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
488
+
489
+ /** One generated column, with the fingerprint that identifies its expression. */
490
+ export interface SearchColumnStamp {
491
+ column: string;
492
+ /** The expression the column is generated from. */
493
+ expression: string;
494
+ fingerprint: string;
495
+ /** `COMMENT ON COLUMN …`, which is where the fingerprint is recorded. */
496
+ sql: string;
497
+ }
498
+
499
+ /**
500
+ * The stamps for a spec's generated columns — one per column, never shared.
501
+ *
502
+ * Per column on purpose: turning `fuzzy` on adds a second column and changes
503
+ * nothing about the first, and a spec-wide fingerprint would report the
504
+ * untouched `tsvector` column as drifted and refuse a boot over a change that
505
+ * is purely additive.
506
+ */
507
+ export const searchColumnStamps = (spec: SearchColumnSpec): SearchColumnStamp[] => {
508
+ const stamp = (column: string, expression: string): SearchColumnStamp => {
509
+ const fingerprint = searchExpressionFingerprint(expression);
510
+ return {
511
+ column,
512
+ expression,
513
+ fingerprint,
514
+ sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
515
+ };
516
+ };
517
+ const stamps = [stamp(spec.column, spec.expression)];
518
+ if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
519
+ return stamps;
520
+ };
521
+
522
+ /**
523
+ * The same drift check as the boot ensure, for the SQL file.
524
+ *
525
+ * Needed because {@link searchColumnStamps} would otherwise *launder* drift on
526
+ * the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that
527
+ * exists, so a re-generated `search.sql` would stamp a stale column with the
528
+ * new block's fingerprint and the next boot would find them in agreement.
529
+ * Guarding first means the file refuses instead — `rebase db push` is attended,
530
+ * and the operator reading the failure is the person who changed the block.
531
+ */
532
+ export const searchStampGuards = (spec: SearchColumnSpec): string[] =>
533
+ searchColumnStamps(spec).map(stamp => {
534
+ const relation = quote(`"${spec.schema}"."${spec.table}"`);
535
+ return `DO $rebase_search$
536
+ DECLARE recorded text;
537
+ BEGIN
538
+ SELECT col_description(a.attrelid, a.attnum) INTO recorded
539
+ FROM pg_attribute a
540
+ WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;
541
+ IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN
542
+ RAISE EXCEPTION 'Rebase: the search block for ${spec.schema}.${spec.table} changed after the generated column "${stamp.column}" was built (recorded %, expected ${stamp.fingerprint}). Postgres cannot alter a generated expression in place. Drop the column and re-apply this file — it rewrites the table and rebuilds the index: ALTER TABLE ${relation.slice(1, -1)} DROP COLUMN "${stamp.column}";', recorded;
543
+ END IF;
544
+ END
545
+ $rebase_search$;`;
546
+ });
547
+
463
548
  /**
464
549
  * The index names the spec creates.
465
550
  *
@@ -18,9 +18,50 @@ function collection(slug: string): CollectionConfig {
18
18
  } as unknown as CollectionConfig;
19
19
  }
20
20
 
21
- /** Stands in for pg_policies. */
22
- function dbWith(rows: Record<string, unknown>[]): Queryable {
23
- return { query: async () => ({ rows: rows as never[] }) };
21
+ /** A collection whose write rule is declared RESTRICTIVE, so the DDL says `AS RESTRICTIVE`. */
22
+ function restrictiveCollection(slug: string): CollectionConfig {
23
+ return {
24
+ name: slug,
25
+ slug,
26
+ table: slug,
27
+ schema: "public",
28
+ properties: { id: { name: "ID", type: "string", isId: "uuid" } },
29
+ securityRules: [
30
+ { operation: "select", access: "public" },
31
+ { operations: ["insert", "update", "delete"], roles: ["admin"], mode: "restrictive" }
32
+ ]
33
+ } as unknown as CollectionConfig;
34
+ }
35
+
36
+ /**
37
+ * Stands in for the two catalogue reads: `pg_policies`, then `pg_class`.
38
+ *
39
+ * The second one is dispatched on the query text rather than call order, so a
40
+ * test that stops caring about ordering does not silently start feeding policy
41
+ * rows to the RLS-status read — which would report every table as RLS-off,
42
+ * since a policy row has no `relrowsecurity` column and `undefined` is falsy.
43
+ *
44
+ * `rlsOff` names tables whose RLS switch is off. Anything not listed is on,
45
+ * which is what a healthy `db push` leaves behind.
46
+ */
47
+ function dbWith(rows: Record<string, unknown>[], rlsOff: string[] = []): Queryable {
48
+ return {
49
+ query: async (text: string, values?: unknown[]) => {
50
+ if (!text.includes("pg_class")) return { rows: rows as never[] };
51
+ const wanted = (values?.[0] as string[] | undefined) ?? [];
52
+ return {
53
+ rows: wanted.map((qualified) => {
54
+ const [schemaname, tablename] = qualified.split(".");
55
+ return {
56
+ schemaname,
57
+ tablename,
58
+ relrowsecurity: !rlsOff.includes(tablename),
59
+ relforcerowsecurity: !rlsOff.includes(tablename)
60
+ };
61
+ }) as never[]
62
+ };
63
+ }
64
+ };
24
65
  }
25
66
 
26
67
  /** A pg_policies row matching an expected policy, clauses and all. */
@@ -30,6 +71,7 @@ function liveRow(p: PolicyRef, overrides: Record<string, unknown> = {}) {
30
71
  // Postgres rewrites the text it stores; only presence is compared.
31
72
  qual: p.hasUsing ? "(rewritten by postgres)" : null,
32
73
  with_check: p.hasWithCheck ? "(rewritten by postgres)" : null,
74
+ permissive: p.mode ?? "PERMISSIVE",
33
75
  ...overrides
34
76
  };
35
77
  }
@@ -56,6 +98,65 @@ describe("checkPolicyDrift", () => {
56
98
  expect(formatPolicyDrift(drift)).toBe("");
57
99
  });
58
100
 
101
+ it("flags a table whose RLS switch is off, though every policy still matches", async () => {
102
+ // The whole point: DISABLE ROW LEVEL SECURITY leaves pg_policies
103
+ // untouched, so name, roles, command and clause presence all agree and
104
+ // every other check here passes. Before this category the report was
105
+ // empty on a table Postgres was filtering nothing on.
106
+ const cols = [collection("authors")];
107
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
108
+
109
+ const drift = await checkPolicyDrift(
110
+ dbWith(expected.map((p) => liveRow(p)), ["authors"]),
111
+ cols
112
+ );
113
+
114
+ expect(drift.missing).toHaveLength(0);
115
+ expect(drift.diverged).toHaveLength(0);
116
+ expect(drift.orphaned).toHaveLength(0);
117
+ expect(drift.rlsDisabled).toEqual([{ schema: "public", table: "authors", forced: false }]);
118
+ expect(hasDrift(drift)).toBe(true);
119
+ expect(formatPolicyDrift(drift)).toContain("RLS DISABLED");
120
+ });
121
+
122
+ it("flags a RESTRICTIVE rule the database stores as PERMISSIVE", async () => {
123
+ // A gate that should be ANDed in is now ORed in — it widens access
124
+ // instead of narrowing it, with identical name, roles, command and
125
+ // clauses. Only the `AS` clause differs, and it used to be discarded by
126
+ // the DDL parser and never selected from pg_policies.
127
+ const cols = [restrictiveCollection("ledger")];
128
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
129
+ const restrictive = expected.filter((p) => p.mode === "RESTRICTIVE");
130
+ // Guards the test itself: if the generator stops emitting AS RESTRICTIVE
131
+ // this must fail loudly rather than pass by having nothing to compare.
132
+ expect(restrictive.length).toBeGreaterThan(0);
133
+
134
+ const live = expected.map((p) =>
135
+ p.name === restrictive[0].name ? liveRow(p, { permissive: "PERMISSIVE" }) : liveRow(p)
136
+ );
137
+
138
+ const drift = await checkPolicyDrift(dbWith(live), cols);
139
+
140
+ expect(drift.diverged).toHaveLength(1);
141
+ expect(drift.diverged[0].differences.join(" ")).toContain("mode: expected RESTRICTIVE");
142
+ expect(formatPolicyDrift(drift)).toContain("widens access");
143
+ });
144
+
145
+ it("does not invent drift when either side's mode is unreadable", async () => {
146
+ // An older server or a driver that hands back a shape we do not know
147
+ // must not turn into a wall of false positives.
148
+ const cols = [collection("authors")];
149
+ const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
150
+
151
+ const drift = await checkPolicyDrift(
152
+ dbWith(expected.map((p) => liveRow(p, { permissive: null }))),
153
+ cols
154
+ );
155
+
156
+ expect(drift.diverged).toHaveLength(0);
157
+ expect(hasDrift(drift)).toBe(false);
158
+ });
159
+
59
160
  it("flags a policy the database never received as missing", async () => {
60
161
  const drift = await checkPolicyDrift(dbWith([]), [collection("authors")]);
61
162