@rebasepro/server-postgres 0.13.1-canary.gef9608c → 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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -34,6 +34,7 @@ import { sql } from "drizzle-orm";
34
34
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
35
35
  import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
36
36
  import { logger } from "@rebasepro/server";
37
+ import { revokeInternalTableSql } from "@rebasepro/common";
37
38
 
38
39
  /** How many messages a replay returns when the caller does not say. */
39
40
  const DEFAULT_REPLAY_LIMIT = 200;
@@ -198,6 +199,19 @@ export class ChannelHistoryStore {
198
199
  )
199
200
  `);
200
201
 
202
+ // Retained broadcasts for every channel in one table, with no RLS: who
203
+ // may replay a channel is decided before the read, by the channel gate
204
+ // in `realtimeService.authorizeChannelAction` — a replay is answered
205
+ // only for a client that has joined the channel, plus whatever an
206
+ // installed `ChannelAuthorizer` adds. That gate is the entire reason
207
+ // this table can sit outside the RLS model, so it fails closed; the
208
+ // rule language it does not yet have is written up in
209
+ // `docs/channel-authorization.md`. The driver's schema-wide
210
+ // grant reaches these (created here, after it ran), so take the
211
+ // privilege back.
212
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
213
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
214
+
201
215
  this.tablesReady = true;
202
216
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
203
217
  }
@@ -28,6 +28,7 @@
28
28
 
29
29
  import { sql } from "drizzle-orm";
30
30
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
31
+ import { revokeInternalTableSql } from "@rebasepro/common";
31
32
 
32
33
  /** A tracked client, as any instance sees it. */
33
34
  export interface PresenceRow {
@@ -71,6 +72,18 @@ export class ChannelPresenceStore {
71
72
  ON rebase.channel_presence (last_seen)
72
73
  `);
73
74
 
75
+ // The roster of every client on every channel, with no RLS — a row
76
+ // policy has nothing to match on here, since a channel name is a string
77
+ // a client invents rather than a row anyone owns. What guards it is the
78
+ // channel gate in `realtimeService.authorizeChannelAction`: presence is
79
+ // readable only to a client that has joined the channel, plus whatever
80
+ // an installed `ChannelAuthorizer` adds. That gate is the entire reason
81
+ // this table can sit outside the RLS model, so it fails closed —
82
+ // see `docs/channel-authorization.md` for what it does *not*
83
+ // yet decide. Revoke the schema-wide grant the driver handed out
84
+ // before this table existed.
85
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
86
+
74
87
  this.tablesReady = true;
75
88
  }
76
89
 
@@ -1,8 +1,9 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
+ import { getTableColumns } from "drizzle-orm";
2
3
  import { CollectionConfig, Property, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
- import { getTableName } from "@rebasepro/common";
5
- import { logger } from "@rebasepro/server";
5
+ import { fieldKeyForColumn, getTableName } from "@rebasepro/common";
6
+ import { ApiError, logger } from "@rebasepro/server";
6
7
 
7
8
  // Row identity is derived on both sides of the wire — the driver parses an
8
9
  // incoming address into key columns, the admin derives one from a served row —
@@ -76,6 +77,82 @@ export function getCollectionByPath(collectionPath: string, registry: PostgresCo
76
77
  return collection;
77
78
  }
78
79
 
80
+ /**
81
+ * Reject a write naming something that is not a column of the table.
82
+ *
83
+ * Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
84
+ * from `Object.keys(tableColumns)`, so a key the table does not carry is not
85
+ * rejected by anything — it is *left out of the statement*. The insert answers
86
+ * 201 having stored nothing under that name; the update, if the key was the
87
+ * only one, builds `update "posts" set where …` and Postgres raises a syntax
88
+ * error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
89
+ * 500 for what is a caller's typo.
90
+ *
91
+ * That makes this the last honest place to check, and the only one every write
92
+ * passes through. `assertKnownWriteFields` in the REST layer checks the same
93
+ * thing against the *config* and is skipped on four paths — `strictWrites:
94
+ * false`, a collection declaring no properties, an auth adapter that owns the
95
+ * body's shape, and a nested route whose target cannot be walked — and it never
96
+ * sees an in-process `rebase.data` write at all.
97
+ *
98
+ * It also gives `strictWrites: false` a truthful implementation. The flag is
99
+ * documented for "a column that really does exist which the config never
100
+ * declared", and skipping the config check alone could not deliver that: the
101
+ * value was dropped a layer later regardless. Skipping the config check and
102
+ * keeping this one does exactly what the flag says — the column must exist,
103
+ * the property need not.
104
+ */
105
+ export function assertWritableColumns(
106
+ values: Record<string, unknown>,
107
+ table: PgTable,
108
+ collectionPath: string
109
+ ): void {
110
+ // Only a real Drizzle table carries a column list. A stand-in that does not
111
+ // (a test double, a registry entry built by hand) has nothing to check
112
+ // against, and inventing an answer from its own keys would reject writes
113
+ // over the shape of the double rather than the shape of the table.
114
+ const columns = getTableColumns(table) as Record<string, unknown> | undefined;
115
+ if (!columns || Object.keys(columns).length === 0) return;
116
+
117
+ const unknown = Object.keys(values).filter(key => !(key in columns));
118
+ if (unknown.length === 0) return;
119
+
120
+ // The offending keys, and deliberately not the list of real ones: this
121
+ // error is reachable on paths where the REST field check was skipped, and
122
+ // an `excludeFromApi` column is documented as never being served to a
123
+ // caller. Naming what was sent is the actionable half anyway.
124
+ throw ApiError.badRequest(
125
+ `'${collectionPath}' has no column${unknown.length > 1 ? "s" : ""} ` +
126
+ `${unknown.map(key => `'${key}'`).join(", ")}, so the value${unknown.length > 1 ? "s" : ""} ` +
127
+ "would have been dropped before the statement was built.",
128
+ "VALIDATION_UNKNOWN_FIELDS"
129
+ );
130
+ }
131
+
132
+ /**
133
+ * A relation whose names do not resolve against the registered schema.
134
+ *
135
+ * Every one of these used to be a `logger.warn` followed by `continue`, so a
136
+ * save reported success for a relation it had not written and a read answered
137
+ * `[]` for one it could not resolve. `assertRelationsResolve` (validate-relations)
138
+ * fails boot on the same defects, which is where they belong — a server that
139
+ * refuses to start is recoverable in a minute. This is the second line, for the
140
+ * paths that assemble a registry by hand, and it exists so that "cannot resolve"
141
+ * is never again reported as "done".
142
+ *
143
+ * @param label `<collection>.<relation>`
144
+ * @param detail what does not resolve, in terms of the schema
145
+ */
146
+ export function relationMisconfigured(label: string, detail: string): ApiError {
147
+ return ApiError.internal(
148
+ `Relation '${label}' does not resolve against the registered schema: ${detail}. ` +
149
+ "The operation was refused rather than skipped — silently dropping it would report " +
150
+ "success for a write that never happened, or emptiness for rows that exist. Run " +
151
+ "`rebase schema generate` if the generated schema is older than the database.",
152
+ "RELATION_MISCONFIGURED"
153
+ );
154
+ }
155
+
79
156
  export function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any> {
80
157
  const tableName = getTableName(collection);
81
158
  const table = registry.getTable(tableName);
@@ -193,7 +270,13 @@ export function sourceKeyField(
193
270
  sourceCollection: CollectionConfig,
194
271
  registry: PostgresCollectionRegistry
195
272
  ): string {
196
- if (relation.sourceKey) return relation.sourceKey;
273
+ // A **field**, as the name says: callers index the Drizzle table and the
274
+ // rows it returns with what comes back from here, and both are keyed by the
275
+ // wire name. `sourceKey` is authored in *column* terms, like every other
276
+ // link on a relation, so it is translated here rather than at each of the
277
+ // call sites — where it used to be `tenant_id` looked up on a table whose
278
+ // key is `tenantId`, finding nothing.
279
+ if (relation.sourceKey) return fieldKeyForColumn(sourceCollection, relation.sourceKey);
197
280
  return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
198
281
  }
199
282
 
@@ -212,7 +295,9 @@ export function joinsOnNaturalKey(
212
295
  registry: PostgresCollectionRegistry
213
296
  ): boolean {
214
297
  if (!relation.sourceKey) return false;
215
- return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
298
+ // Compared as fields, because the right-hand side is one.
299
+ return fieldKeyForColumn(sourceCollection, relation.sourceKey)
300
+ !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
216
301
  }
217
302
 
218
303
  /**
@@ -83,10 +83,13 @@ export class DataService implements DataRepository {
83
83
  searchString: string,
84
84
  options: {
85
85
  filter?: FilterValues<Extract<keyof M, string>>;
86
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
87
+ logical?: LogicalCondition;
86
88
  orderBy?: string;
87
89
  order?: "desc" | "asc";
88
90
  limit?: number;
89
91
  databaseId?: string;
92
+ searchExplain?: boolean;
90
93
  } = {}
91
94
  ): Promise<Record<string, unknown>[]> {
92
95
  return this.fetchService.searchRows<M>(collectionPath, searchString, options);
@@ -0,0 +1,295 @@
1
+ import { and, eq, inArray, sql } from "drizzle-orm";
2
+ import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
+ import { ApiError, logger } from "@rebasepro/server";
4
+ import type { ResolvedVia } from "@rebasepro/types";
5
+ import { DrizzleClient } from "../interfaces";
6
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
+ import { relationMisconfigured } from "./collection-helpers";
8
+ import { explainZeroRowWrite } from "./write-denial";
9
+
10
+ /**
11
+ * Writing a many-to-many means writing rows in a junction table, and doing that
12
+ * needs three things: the table, the column naming the row being written from,
13
+ * and the column naming the far side.
14
+ *
15
+ * Those three used to be re-derived at each of the four call sites — twice for
16
+ * `through`, twice for `joinPath`, in the owning and the inverse direction —
17
+ * and the two `joinPath` derivations did not agree. The inverse one keyed off
18
+ * `step.table`; the owning one asked `getTableNamesFromColumns` which table a
19
+ * step's columns belonged to, and that answers `""` for an unqualified column
20
+ * name. So for `{ table: "posts_tags", on: { from: "id", to: "tag_id" } }` —
21
+ * the form the docs and every fixture use — no branch matched, both columns
22
+ * stayed null, and the write was skipped with a warning nobody reads. Writing
23
+ * a to-many `via` relation did nothing at all.
24
+ *
25
+ * Hence one binder, keyed on `step.table` (always present) and accepting the
26
+ * qualified `table.column` form where it is used.
27
+ */
28
+ export interface JunctionBinding {
29
+ table: PgTable;
30
+ /** The junction column holding the id of the row being written from. */
31
+ parentColumn: AnyPgColumn;
32
+ /** The junction column holding the id of the row on the far side. */
33
+ targetColumn: AnyPgColumn;
34
+ /** `<collection>.<relation>`, for error messages. */
35
+ label: string;
36
+ }
37
+
38
+ /** A junction that cannot be resolved is a broken relation, not a no-op. */
39
+ const misconfigured = (label: string, detail: string): ApiError =>
40
+ relationMisconfigured(label, detail);
41
+
42
+ function column(table: PgTable, name: string | undefined, label: string, role: string): AnyPgColumn {
43
+ const col = name ? table[name as keyof typeof table] as AnyPgColumn : undefined;
44
+ if (!col) {
45
+ throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
46
+ }
47
+ return col;
48
+ }
49
+
50
+ /** The junction a `manyToMany` names outright. */
51
+ export function bindThroughJunction(
52
+ registry: PostgresCollectionRegistry,
53
+ through: { table: string; sourceColumn: string; targetColumn: string },
54
+ label: string
55
+ ): JunctionBinding {
56
+ const table = registry.getTable(through.table);
57
+ if (!table) {
58
+ throw misconfigured(label, `no table '${through.table}' in the registry`);
59
+ }
60
+ return {
61
+ table,
62
+ parentColumn: column(table, through.sourceColumn, label, "source"),
63
+ targetColumn: column(table, through.targetColumn, label, "target"),
64
+ label
65
+ };
66
+ }
67
+
68
+ /** The bare column name, whether written as `col` or `table.col`. */
69
+ const columnPart = (spec: string | string[]): string => {
70
+ const one = Array.isArray(spec) ? spec[0] : spec;
71
+ return one.includes(".") ? one.split(".")[1] : one;
72
+ };
73
+
74
+ /** The table a column spec names, or undefined when it is unqualified. */
75
+ const tablePart = (spec: string | string[]): string | undefined => {
76
+ const one = Array.isArray(spec) ? spec[0] : spec;
77
+ return one.includes(".") ? one.split(".")[0] : undefined;
78
+ };
79
+
80
+ /**
81
+ * The junction a `via` relation reaches through, from either end.
82
+ *
83
+ * A step is `{ table: T, on: { from, to } }` where `from` names a column on
84
+ * whatever the walk was standing on and `to` names one on `T`. That positional
85
+ * meaning is what makes the unqualified form work at all, so the walk carries
86
+ * the previous table rather than asking the column names where they live.
87
+ */
88
+ export function bindJoinPathJunction(
89
+ registry: PostgresCollectionRegistry,
90
+ joinPath: ResolvedVia["joinPath"],
91
+ parentTableName: string,
92
+ targetTableName: string,
93
+ label: string
94
+ ): JunctionBinding {
95
+ const junctionName = joinPath
96
+ .map(step => step.table)
97
+ .find(table => table !== parentTableName && table !== targetTableName);
98
+
99
+ if (!junctionName) {
100
+ throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
101
+ }
102
+
103
+ const table = registry.getTable(junctionName);
104
+ if (!table) {
105
+ throw misconfigured(label, `no table '${junctionName}' in the registry`);
106
+ }
107
+
108
+ let parentColumnName: string | undefined;
109
+ let targetColumnName: string | undefined;
110
+ let previousTable = parentTableName;
111
+
112
+ for (const step of joinPath) {
113
+ const fromTable = tablePart(step.on.from) ?? previousTable;
114
+ const toTable = tablePart(step.on.to) ?? step.table;
115
+
116
+ // Take the column on the junction's own side of the step, and let the
117
+ // other side say which end of the relation this step reached.
118
+ if (toTable === junctionName && fromTable === parentTableName) {
119
+ parentColumnName = columnPart(step.on.to);
120
+ } else if (fromTable === junctionName && toTable === parentTableName) {
121
+ parentColumnName = columnPart(step.on.from);
122
+ } else if (toTable === junctionName && fromTable === targetTableName) {
123
+ targetColumnName = columnPart(step.on.to);
124
+ } else if (fromTable === junctionName && toTable === targetTableName) {
125
+ targetColumnName = columnPart(step.on.from);
126
+ }
127
+
128
+ previousTable = step.table;
129
+ }
130
+
131
+ if (!parentColumnName || !targetColumnName) {
132
+ throw misconfigured(
133
+ label,
134
+ `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`
135
+ );
136
+ }
137
+
138
+ return {
139
+ table,
140
+ parentColumn: column(table, parentColumnName, label, "source"),
141
+ targetColumn: column(table, targetColumnName, label, "target"),
142
+ label
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Remove one link, leaving the row on the far side alone.
148
+ *
149
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
150
+ * target is shared, so deleting the row would remove the tag from every other
151
+ * post that uses it.
152
+ */
153
+ export async function removeJunctionLink(
154
+ tx: DrizzleClient,
155
+ binding: JunctionBinding,
156
+ parentId: unknown,
157
+ targetId: unknown,
158
+ subject: { parent: string; relation: string }
159
+ ): Promise<void> {
160
+ const conditions = [
161
+ eq(binding.parentColumn, parentId),
162
+ eq(binding.targetColumn, targetId)
163
+ ];
164
+ const result = await tx.delete(binding.table).where(and(...conditions));
165
+
166
+ // The link is the row here, so the junction's own policies decide. Callers
167
+ // establish membership first, over this same handle, which is what makes a
168
+ // zero-row delete meaningful rather than ambiguous.
169
+ if ((result.rowCount ?? 0) === 0) {
170
+ throw await explainZeroRowWrite(
171
+ tx,
172
+ binding.table,
173
+ conditions,
174
+ `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": ` +
175
+ "a row-level security policy rejected the write.",
176
+ `No "${subject.relation}" link between "${subject.parent}" "${parentId}" ` +
177
+ `and "${targetId}" to remove.`
178
+ );
179
+ }
180
+
181
+ logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
182
+ }
183
+
184
+ /**
185
+ * Make the junction say that `parentId` is linked to exactly `targetIds`, by
186
+ * diffing against what is linked now rather than replacing the set.
187
+ *
188
+ * A save of the parent used to delete every junction row for it and re-insert
189
+ * the ids the browser sent — a list the browser assembled from a read it did
190
+ * earlier. Three things followed, all data loss rather than display:
191
+ *
192
+ * - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
193
+ * saves any field from a form that predates it, and X is gone with nothing
194
+ * reported to either of them.
195
+ * - **A partially-read set is a partially-deleted set.** The read that fills
196
+ * the form runs under RLS, so a user who may edit the parent but cannot see
197
+ * some of the linked rows gets a shorter list — and writing it back deleted
198
+ * the links they were never shown. The select that drives the diff runs in
199
+ * this same transaction under the same policies, so a link the caller cannot
200
+ * read is in neither list and survives the save.
201
+ * - **Junction payload columns.** A junction carrying its own columns
202
+ * (`position`, `role`, `created_at`) lost them on every save, because every
203
+ * row was re-inserted with only the two keys. Untouched links are left alone.
204
+ *
205
+ * The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
206
+ * concurrently is a no-op rather than a unique violation.
207
+ */
208
+ export async function applyJunctionMembership(
209
+ tx: DrizzleClient,
210
+ binding: JunctionBinding,
211
+ parentId: unknown,
212
+ targetIds: unknown[]
213
+ ): Promise<void> {
214
+ const existingRows = await tx
215
+ .select({ targetId: binding.targetColumn })
216
+ .from(binding.table)
217
+ .where(eq(binding.parentColumn, parentId));
218
+
219
+ // Keyed by `String(...)` because a junction key can come back from the
220
+ // driver as a string where the parsed value is a number, and a diff that
221
+ // missed that would delete and re-insert every link on every save.
222
+ const existingById = new Map<string, unknown>();
223
+ for (const row of existingRows as Array<{ targetId: unknown }>) {
224
+ if (row.targetId === null || row.targetId === undefined) continue;
225
+ existingById.set(String(row.targetId), row.targetId);
226
+ }
227
+
228
+ const wantedById = new Map<string, unknown>();
229
+ for (const targetId of targetIds) {
230
+ if (targetId === null || targetId === undefined) continue;
231
+ wantedById.set(String(targetId), targetId);
232
+ }
233
+
234
+ const removed = [...existingById.entries()]
235
+ .filter(([key]) => !wantedById.has(key))
236
+ .map(([, value]) => value);
237
+ const added = [...wantedById.entries()]
238
+ .filter(([key]) => !existingById.has(key))
239
+ .map(([, value]) => value);
240
+
241
+ if (removed.length > 0) {
242
+ await removeLinks(tx, binding, parentId, removed);
243
+ }
244
+
245
+ if (added.length > 0) {
246
+ await tx.insert(binding.table)
247
+ .values(added.map(targetId => ({
248
+ [binding.parentColumn.name]: parentId,
249
+ [binding.targetColumn.name]: targetId
250
+ })))
251
+ .onConflictDoNothing();
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Drop the named links, and refuse to call it done if the database kept any.
257
+ *
258
+ * Every id here came out of the select in {@link applyJunctionMembership}, on
259
+ * this same handle, so all of them were visible. Fewer deletions than that means
260
+ * something refused them — and a save that reports success while the membership
261
+ * it stored is not the membership it was given has told the caller something
262
+ * untrue about the database.
263
+ */
264
+ async function removeLinks(
265
+ tx: DrizzleClient,
266
+ binding: JunctionBinding,
267
+ parentId: unknown,
268
+ removed: unknown[]
269
+ ): Promise<void> {
270
+ const conditions = [
271
+ eq(binding.parentColumn, parentId),
272
+ inArray(binding.targetColumn, removed)
273
+ ];
274
+ const result = await tx.delete(binding.table).where(and(...conditions));
275
+ const deleted = result.rowCount ?? 0;
276
+
277
+ if (deleted >= removed.length) return;
278
+
279
+ const survivors = await tx
280
+ .select({ present: sql<number>`1` })
281
+ .from(binding.table)
282
+ .where(and(...conditions))
283
+ .limit(1);
284
+
285
+ // Nothing survived: a concurrent session removed the rest between the
286
+ // select and the delete. The membership is what the caller asked for,
287
+ // which is all this promised.
288
+ if (survivors.length === 0) return;
289
+
290
+ throw ApiError.forbidden(
291
+ `Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) ` +
292
+ `for relation '${binding.label}': a row-level security policy rejected the write.`,
293
+ "WRITE_DENIED"
294
+ );
295
+ }
@@ -111,7 +111,7 @@ export class PgNotifyListener {
111
111
  await client.connect();
112
112
  await client.query(`LISTEN ${channel}`);
113
113
  this.client = client;
114
- logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
114
+ logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
115
115
  } catch (err) {
116
116
  // Surface the initial failure so callers can choose to fall back;
117
117
  // for reconnects, keep retrying quietly in the background.