@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.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1720 -946
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +124 -11
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +299 -168
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +129 -25
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +366 -30
- package/src/schema/generate-drizzle-schema-logic.ts +146 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +277 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +14 -7
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +243 -22
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +3 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
- package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
- package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-CU6WZGYV.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|