@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.
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- 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-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- 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/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/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/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -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 +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- 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 +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- 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 +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- 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/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- package/dist/websocket-D0TBU3ia.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -2,17 +2,18 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { t as createPostgresWebSocket } from "./websocket-
|
|
9
|
-
import {
|
|
10
|
-
import { t as
|
|
5
|
+
import { n as resolveClientListLimit, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
|
|
6
|
+
import { A as getEnumVarName, B as createRelationRefWithData, C as getEffectiveSecurityRules, D as fieldKeyForColumn, F as getDeclaredPrimaryKeys, G as legacyForeignKeyName, H as updateDateAutoValues, I as isAddressableId, J as getPolicyNamesForRule, L as parseIdValues, M as getTableVarName, N as resolveCollectionRelations, O as findRelation, P as buildCompositeId, Q as toSnakeCase, R as sortCollectionsBySlug, S as resolveJunctionSpecs, T as securityRuleToConditions, U as firstFreeKey, V as normalizeToEntityRelation, W as generateForeignKeyName, X as mergeDeep, Y as isPrototypePollutingKey, Z as camelCase, _ as CollectionRegistry, b as getJunctionCollectionConfig, g as buildSdkData, h as visibleColumnProjection, j as getTableName$1, k as getColumnName, l as buildSearchColumnSpec, nt as isManyToMany, q as toWireKey, r as authUsersColumnSql, rt as Vector, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as hasForeignKeyOnTarget, u as hiddenColumnsOption, v as relationalCollections, w as policyToPostgres, x as getJunctionSecurityRules, y as resolveStringColumnLength, z as createRelationRef } from "./auth-users-columns-BfQHf9JE.js";
|
|
7
|
+
import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, l as isRelationalCollectionConfig } from "./src-DCdn3Val.js";
|
|
8
|
+
import { t as createPostgresWebSocket } from "./websocket-C8ZqVBiV.js";
|
|
9
|
+
import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-BJ_3wxwg.js";
|
|
10
|
+
import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-BH0Dzo_h.js";
|
|
11
|
+
import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-69hYT8nr.js";
|
|
11
12
|
import { Client, Pool } from "pg";
|
|
12
13
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
13
14
|
import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
14
|
-
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
15
|
-
import { PgArray,
|
|
15
|
+
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
|
|
16
|
+
import { PgArray, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
16
17
|
import fs, { promises } from "fs";
|
|
17
18
|
import path from "path";
|
|
18
19
|
import chokidar from "chokidar";
|
|
@@ -134,140 +135,25 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
134
135
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
135
136
|
};
|
|
136
137
|
//#endregion
|
|
137
|
-
//#region ../common/src/
|
|
138
|
-
/**
|
|
139
|
-
* The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
|
|
140
|
-
* end-user role away from them.
|
|
141
|
-
*
|
|
142
|
-
* ## Why this exists
|
|
143
|
-
*
|
|
144
|
-
* Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
|
|
145
|
-
* provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
|
|
146
|
-
* in the schemas a project uses — including `rebase`, because a project's own
|
|
147
|
-
* collections are allowed to live there (the scaffold puts `users` there). It
|
|
148
|
-
* also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
|
|
149
|
-
* migrating role inherits the same grant.
|
|
150
|
-
*
|
|
151
|
-
* Every framework-internal table is created later: auth's tables come up during
|
|
152
|
-
* `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
|
|
153
|
-
* job registers, `idempotency_keys` on the first request that carries a key. So
|
|
154
|
-
* they all inherited full DML for the end-user role — and none of them enables
|
|
155
|
-
* row-level security, because none of them is a collection with
|
|
156
|
-
* `securityRules`. Measured on a freshly provisioned database, `SET ROLE
|
|
157
|
-
* rebase_user` could read `rebase.refresh_tokens` (session token hashes),
|
|
158
|
-
* `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
|
|
159
|
-
* `rebase.api_keys` (including its `admin` flag), and insert into
|
|
160
|
-
* `rebase.app_config`.
|
|
161
|
-
*
|
|
162
|
-
* Nothing routes a user-context query at those tables today, so this was not
|
|
163
|
-
* reachable over the API. That is the wrong thing to depend on: the documented
|
|
164
|
-
* model is that RLS is the authorization boundary, and these tables sat outside
|
|
165
|
-
* it. The boundary is now a privilege boundary instead — the role simply cannot
|
|
166
|
-
* address them.
|
|
167
|
-
*
|
|
168
|
-
* ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
|
|
169
|
-
*
|
|
170
|
-
* RLS with no policy denies every row, which is the same outcome, but it is the
|
|
171
|
-
* *weaker* statement: it leaves the grant in place, so a later policy — or a
|
|
172
|
-
* `FORCE` flag cleared by some future migration — reopens the table. There is no
|
|
173
|
-
* row of `refresh_tokens` any end user should ever reach, so the honest encoding
|
|
174
|
-
* is "this role has no privilege here at all". It also keeps the owner
|
|
175
|
-
* connection (which auth actually runs on) completely unaffected.
|
|
176
|
-
*
|
|
177
|
-
* ## Keeping it true
|
|
178
|
-
*
|
|
179
|
-
* `packages/rls-check` scans the `rebase` schema — it used to skip it as a
|
|
180
|
-
* "platform" schema — and its `rls-disabled` check fires on exactly the
|
|
181
|
-
* condition this module removes: RLS off *and* a DML grant to a reachable role.
|
|
182
|
-
* So a table added here without a revoke is caught by `pnpm rls:check`, not by
|
|
183
|
-
* someone re-reading this file.
|
|
184
|
-
*/
|
|
185
|
-
/**
|
|
186
|
-
* The Postgres role authenticated requests run as.
|
|
187
|
-
*
|
|
188
|
-
* Defined here rather than in the Postgres driver because both the driver (which
|
|
189
|
-
* provisions the role) and this module (which revokes on its behalf) need it,
|
|
190
|
-
* and a second spelling of a role name is a silent no-op waiting to happen.
|
|
191
|
-
*/
|
|
192
|
-
var REBASE_USER_ROLE = "rebase_user";
|
|
193
|
-
/**
|
|
194
|
-
* Framework-internal table names, unqualified.
|
|
195
|
-
*
|
|
196
|
-
* Deliberately NOT including `users`: the auth user table is also a collection,
|
|
197
|
-
* with `securityRules`, RLS enabled and policies applied. Users read their own
|
|
198
|
-
* row through it — revoking there would break sign-in.
|
|
199
|
-
*
|
|
200
|
-
* `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
|
|
201
|
-
* because `db migrate apply` passes `--revisions-schema rebase`.
|
|
202
|
-
*/
|
|
203
|
-
var REBASE_INTERNAL_TABLES = [
|
|
204
|
-
"user_identities",
|
|
205
|
-
"refresh_tokens",
|
|
206
|
-
"password_reset_tokens",
|
|
207
|
-
"magic_link_tokens",
|
|
208
|
-
"mfa_factors",
|
|
209
|
-
"mfa_challenges",
|
|
210
|
-
"recovery_codes",
|
|
211
|
-
"app_config",
|
|
212
|
-
"schema_meta",
|
|
213
|
-
"api_keys",
|
|
214
|
-
"cron_logs",
|
|
215
|
-
"cron_claims",
|
|
216
|
-
"idempotency_keys",
|
|
217
|
-
"entity_history",
|
|
218
|
-
"branches",
|
|
219
|
-
"channel_messages",
|
|
220
|
-
"channel_cursors",
|
|
221
|
-
"channel_presence",
|
|
222
|
-
"atlas_schema_revisions"
|
|
223
|
-
];
|
|
224
|
-
/** Postgres identifiers this module is willing to interpolate. */
|
|
225
|
-
var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
138
|
+
//#region ../common/src/data/filter-conditions.ts
|
|
226
139
|
/**
|
|
227
|
-
*
|
|
228
|
-
* end-user role.
|
|
140
|
+
* Read one field's filter as the list of conditions it stands for.
|
|
229
141
|
*
|
|
230
|
-
*
|
|
231
|
-
* happen in practice:
|
|
142
|
+
* Accepts both declared shapes and normalises them to a list:
|
|
232
143
|
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
|
|
144
|
+
* ```ts
|
|
145
|
+
* toFilterTuples(["==", "active"]) // [["==", "active"]]
|
|
146
|
+
* toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
|
|
147
|
+
* ```
|
|
238
148
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
149
|
+
* A falsy, non-array or empty param has no conditions in it — the empty list,
|
|
150
|
+
* so a caller iterating adds nothing rather than compiling a tuple of
|
|
151
|
+
* `undefined`s and logging about an operator nobody sent.
|
|
241
152
|
*/
|
|
242
|
-
function
|
|
243
|
-
if (!
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
return `
|
|
247
|
-
DO $rebase_revoke$
|
|
248
|
-
BEGIN
|
|
249
|
-
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
|
|
250
|
-
AND to_regclass('${qualified}') IS NOT NULL THEN
|
|
251
|
-
EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
|
|
252
|
-
END IF;
|
|
253
|
-
END
|
|
254
|
-
$rebase_revoke$;
|
|
255
|
-
`.trim();
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Revoke on every internal table in `schema`, one statement at a time.
|
|
259
|
-
*
|
|
260
|
-
* Best-effort per table: a connection that does not own one of them (a
|
|
261
|
-
* pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
|
|
262
|
-
* that must not take down a boot. The caller decides how loud to be — `onError`
|
|
263
|
-
* exists so the driver can warn without this module importing a logger.
|
|
264
|
-
*/
|
|
265
|
-
async function revokeInternalTableAccess(execute, schema, options) {
|
|
266
|
-
for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) try {
|
|
267
|
-
await execute(revokeInternalTableSql(schema, table));
|
|
268
|
-
} catch (error) {
|
|
269
|
-
options?.onError?.(table, error);
|
|
270
|
-
}
|
|
153
|
+
function toFilterTuples(filterParam) {
|
|
154
|
+
if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
|
|
155
|
+
if (Array.isArray(filterParam[0])) return filterParam;
|
|
156
|
+
return [filterParam];
|
|
271
157
|
}
|
|
272
158
|
//#endregion
|
|
273
159
|
//#region ../common/src/table-classification.ts
|
|
@@ -375,6 +261,55 @@ function getCollectionByPath(collectionPath, registry) {
|
|
|
375
261
|
}
|
|
376
262
|
return collection;
|
|
377
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Reject a write naming something that is not a column of the table.
|
|
266
|
+
*
|
|
267
|
+
* Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
|
|
268
|
+
* from `Object.keys(tableColumns)`, so a key the table does not carry is not
|
|
269
|
+
* rejected by anything — it is *left out of the statement*. The insert answers
|
|
270
|
+
* 201 having stored nothing under that name; the update, if the key was the
|
|
271
|
+
* only one, builds `update "posts" set where …` and Postgres raises a syntax
|
|
272
|
+
* error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
|
|
273
|
+
* 500 for what is a caller's typo.
|
|
274
|
+
*
|
|
275
|
+
* That makes this the last honest place to check, and the only one every write
|
|
276
|
+
* passes through. `assertKnownWriteFields` in the REST layer checks the same
|
|
277
|
+
* thing against the *config* and is skipped on four paths — `strictWrites:
|
|
278
|
+
* false`, a collection declaring no properties, an auth adapter that owns the
|
|
279
|
+
* body's shape, and a nested route whose target cannot be walked — and it never
|
|
280
|
+
* sees an in-process `rebase.data` write at all.
|
|
281
|
+
*
|
|
282
|
+
* It also gives `strictWrites: false` a truthful implementation. The flag is
|
|
283
|
+
* documented for "a column that really does exist which the config never
|
|
284
|
+
* declared", and skipping the config check alone could not deliver that: the
|
|
285
|
+
* value was dropped a layer later regardless. Skipping the config check and
|
|
286
|
+
* keeping this one does exactly what the flag says — the column must exist,
|
|
287
|
+
* the property need not.
|
|
288
|
+
*/
|
|
289
|
+
function assertWritableColumns(values, table, collectionPath) {
|
|
290
|
+
const columns = getTableColumns(table);
|
|
291
|
+
if (!columns || Object.keys(columns).length === 0) return;
|
|
292
|
+
const unknown = Object.keys(values).filter((key) => !(key in columns));
|
|
293
|
+
if (unknown.length === 0) return;
|
|
294
|
+
throw ApiError.badRequest(`'${collectionPath}' has no column${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => `'${key}'`).join(", ")}, so the value${unknown.length > 1 ? "s" : ""} would have been dropped before the statement was built.`, "VALIDATION_UNKNOWN_FIELDS");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A relation whose names do not resolve against the registered schema.
|
|
298
|
+
*
|
|
299
|
+
* Every one of these used to be a `logger.warn` followed by `continue`, so a
|
|
300
|
+
* save reported success for a relation it had not written and a read answered
|
|
301
|
+
* `[]` for one it could not resolve. `assertRelationsResolve` (validate-relations)
|
|
302
|
+
* fails boot on the same defects, which is where they belong — a server that
|
|
303
|
+
* refuses to start is recoverable in a minute. This is the second line, for the
|
|
304
|
+
* paths that assemble a registry by hand, and it exists so that "cannot resolve"
|
|
305
|
+
* is never again reported as "done".
|
|
306
|
+
*
|
|
307
|
+
* @param label `<collection>.<relation>`
|
|
308
|
+
* @param detail what does not resolve, in terms of the schema
|
|
309
|
+
*/
|
|
310
|
+
function relationMisconfigured(label, detail) {
|
|
311
|
+
return ApiError.internal(`Relation '${label}' does not resolve against the registered schema: ${detail}. The operation was refused rather than skipped — silently dropping it would report success for a write that never happened, or emptiness for rows that exist. Run \`rebase schema generate\` if the generated schema is older than the database.`, "RELATION_MISCONFIGURED");
|
|
312
|
+
}
|
|
378
313
|
function getTableForCollection(collection, registry) {
|
|
379
314
|
const tableName = getTableName$1(collection);
|
|
380
315
|
const table = registry.getTable(tableName);
|
|
@@ -466,7 +401,7 @@ function requirePrimaryKeys(collection, registry) {
|
|
|
466
401
|
* they were *duplicated* and could disagree, not because they existed.
|
|
467
402
|
*/
|
|
468
403
|
function sourceKeyField(relation, sourceCollection, registry) {
|
|
469
|
-
if (relation.sourceKey) return relation.sourceKey;
|
|
404
|
+
if (relation.sourceKey) return fieldKeyForColumn(sourceCollection, relation.sourceKey);
|
|
470
405
|
return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
471
406
|
}
|
|
472
407
|
/**
|
|
@@ -480,7 +415,7 @@ function sourceKeyField(relation, sourceCollection, registry) {
|
|
|
480
415
|
*/
|
|
481
416
|
function joinsOnNaturalKey(relation, sourceCollection, registry) {
|
|
482
417
|
if (!relation.sourceKey) return false;
|
|
483
|
-
return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
418
|
+
return fieldKeyForColumn(sourceCollection, relation.sourceKey) !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
484
419
|
}
|
|
485
420
|
/**
|
|
486
421
|
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
@@ -560,6 +495,91 @@ function deriveRowAddress(row, collection, registry) {
|
|
|
560
495
|
*/
|
|
561
496
|
var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
|
|
562
497
|
/**
|
|
498
|
+
* A user's search term, made safe to drop inside a `%…%` LIKE pattern.
|
|
499
|
+
*
|
|
500
|
+
* The term is already a bind parameter, so this is not about injection. It is
|
|
501
|
+
* about the two things a LIKE metacharacter does when it arrives from a search
|
|
502
|
+
* box:
|
|
503
|
+
*
|
|
504
|
+
* 1. **It changes the query.** `%` and `_` are wildcards, so searching for
|
|
505
|
+
* `50%` returned every row and `a_c` matched `abc`. Nothing the caller
|
|
506
|
+
* could type would find a literal `%`.
|
|
507
|
+
* 2. **It is a cost the caller chooses.** Postgres matches LIKE by
|
|
508
|
+
* backtracking: each `%` re-tries every remaining offset, so
|
|
509
|
+
* `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
|
|
510
|
+
* exponent — evaluated per row, OR-ed across every string property of the
|
|
511
|
+
* collection, on a sequential scan (a leading `%` cannot use an index), and
|
|
512
|
+
* the page limit does not bound it because the scan happens first.
|
|
513
|
+
*
|
|
514
|
+
* This is the server-side half of the pattern `like-pattern-redos.test.ts`
|
|
515
|
+
* hardened the offline evaluator against; that test's own note ("the same
|
|
516
|
+
* translation in the Mongo driver hands the expression to the database, where
|
|
517
|
+
* it occupies a server thread instead") describes this call site.
|
|
518
|
+
*
|
|
519
|
+
* Backslash is the default `ESCAPE` character for LIKE, and the pattern is
|
|
520
|
+
* bound rather than interpolated, so a single backslash here reaches the
|
|
521
|
+
* matcher as one. Escaping the escape character first is what keeps a term
|
|
522
|
+
* ending in `\` from swallowing the closing `%`.
|
|
523
|
+
*
|
|
524
|
+
* Note this is a *substring search*, not the `like` filter operator: a caller
|
|
525
|
+
* who wants wildcards has `?title=like.foo%` for that, where the pattern is the
|
|
526
|
+
* documented input.
|
|
527
|
+
*/
|
|
528
|
+
var escapeLikePattern = (value) => value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
529
|
+
/**
|
|
530
|
+
* The Drizzle column a relation's column name addresses on a table.
|
|
531
|
+
*
|
|
532
|
+
* A relation names its link in *column* terms — `localKey: "author_id"`,
|
|
533
|
+
* `foreignKeyOnTarget: "author_id"` — because that is what the database and
|
|
534
|
+
* every FK constraint call it. A Drizzle table is keyed by the *wire* name,
|
|
535
|
+
* `authorId`. Indexing the table with the column, which is what every one of
|
|
536
|
+
* these call sites used to do, therefore finds nothing the moment the two
|
|
537
|
+
* differ: for a `columnName`-carrying property that was already true, and it is
|
|
538
|
+
* now true of every derived foreign key.
|
|
539
|
+
*
|
|
540
|
+
* `undefined` rather than a throw: each caller already has a message naming the
|
|
541
|
+
* relation it was resolving, which is worth more than a generic one here.
|
|
542
|
+
*/
|
|
543
|
+
var relationColumn = (table, collection, column) => {
|
|
544
|
+
const key = fieldKeyForColumn(collection, column);
|
|
545
|
+
return (key in table ? table[key] : void 0) || void 0;
|
|
546
|
+
};
|
|
547
|
+
/** The target collection of a relation, or `undefined` if its thunk cannot resolve. */
|
|
548
|
+
var targetOf = (relation) => {
|
|
549
|
+
try {
|
|
550
|
+
return relation.target();
|
|
551
|
+
} catch {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
/** Column types `ILIKE '%…%'` is defined on. */
|
|
556
|
+
var ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
|
|
557
|
+
/**
|
|
558
|
+
* Can this column be matched with `ILIKE`?
|
|
559
|
+
*
|
|
560
|
+
* Asked of the column's *declared SQL type*, never with `instanceof`. The
|
|
561
|
+
* previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
|
|
562
|
+
* and `instanceof` compares class identity: it is only true when the column was
|
|
563
|
+
* constructed by the very same copy of `drizzle-orm` that this module imported.
|
|
564
|
+
*
|
|
565
|
+
* An application's generated schema builds its tables with the app's own
|
|
566
|
+
* `drizzle-orm`, and this driver declares its own dependency on one. When the
|
|
567
|
+
* two ranges do not overlap — an app scaffolded against `^0.44` with a driver
|
|
568
|
+
* asking for `^0.45` — a strict installer gives the driver a second copy, every
|
|
569
|
+
* check returns false, no condition is produced, and the caller compiles that
|
|
570
|
+
* into an impossible `WHERE`. The result is a 200 with an empty page for every
|
|
571
|
+
* search on every collection without a `search` block: the failure looks
|
|
572
|
+
* exactly like "nothing matched". Observed in production, not theorised.
|
|
573
|
+
*
|
|
574
|
+
* `getSQLType()` is a value the column reports about itself, so it crosses
|
|
575
|
+
* module instances the way a class identity cannot. It also happens to fix
|
|
576
|
+
* `citext`, which the `instanceof` list never covered.
|
|
577
|
+
*/
|
|
578
|
+
var supportsILike = (column) => {
|
|
579
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
580
|
+
return ILIKE_SQL_TYPES.test(sqlType);
|
|
581
|
+
};
|
|
582
|
+
/**
|
|
563
583
|
* Process-wide default, set once when the driver is constructed.
|
|
564
584
|
*
|
|
565
585
|
* The condition builder is a set of *static* methods reached from a dozen
|
|
@@ -657,7 +677,7 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
657
677
|
}
|
|
658
678
|
case "hasOne":
|
|
659
679
|
case "hasMany": {
|
|
660
|
-
const fkColumn = targetTable
|
|
680
|
+
const fkColumn = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
|
|
661
681
|
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
|
|
662
682
|
if (!relation.sourceKey) return eq(fkColumn, parentId);
|
|
663
683
|
const { table, idColumn } = parent();
|
|
@@ -745,14 +765,14 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
745
765
|
if (collection) {
|
|
746
766
|
const relation = resolveCollectionRelations(collection)[field];
|
|
747
767
|
if (relation?.kind === "belongsTo") {
|
|
748
|
-
const foreignKey =
|
|
768
|
+
const foreignKey = relationColumn(table, collection, relation.localKey);
|
|
749
769
|
if (foreignKey) return {
|
|
750
770
|
kind: "column",
|
|
751
771
|
column: foreignKey
|
|
752
772
|
};
|
|
753
773
|
}
|
|
754
774
|
if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
|
|
755
|
-
const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ?
|
|
775
|
+
const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ? relationColumn(table, collection, relation.sourceKey) : sourceIdColumn;
|
|
756
776
|
if (!correlationColumn) throw new Error(`\`sourceKey: "${relation.sourceKey}"\` on relation '${relation.relationName}' is not a column on '${collectionPath}', so a filter on that relation has nothing to correlate against.`);
|
|
757
777
|
return {
|
|
758
778
|
kind: "relation",
|
|
@@ -762,7 +782,12 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
762
782
|
};
|
|
763
783
|
}
|
|
764
784
|
}
|
|
765
|
-
for (const guess of [
|
|
785
|
+
for (const guess of [
|
|
786
|
+
`${field}Id`,
|
|
787
|
+
toWireKey(generateForeignKeyName(field)),
|
|
788
|
+
`${field}_id`,
|
|
789
|
+
generateForeignKeyName(field)
|
|
790
|
+
]) {
|
|
766
791
|
const foreignKey = columnAt(guess);
|
|
767
792
|
if (foreignKey) return {
|
|
768
793
|
kind: "column",
|
|
@@ -793,8 +818,7 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
793
818
|
if (!filterParam) continue;
|
|
794
819
|
const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
|
|
795
820
|
if (!target) continue;
|
|
796
|
-
|
|
797
|
-
for (const [op, value] of paramsList) {
|
|
821
|
+
for (const [op, value] of toFilterTuples(filterParam)) {
|
|
798
822
|
const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
|
|
799
823
|
if (condition) conditions.push(condition);
|
|
800
824
|
}
|
|
@@ -871,7 +895,7 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
871
895
|
const targetCollection = relation.target();
|
|
872
896
|
const targetTable = registry.getTable(getTableName$1(targetCollection));
|
|
873
897
|
if (!targetTable) throw new Error(`Table not found for the target of relation '${relation.relationName}' (collection '${targetCollection.slug}')`);
|
|
874
|
-
const fkColumn = targetTable
|
|
898
|
+
const fkColumn = relationColumn(targetTable, targetCollection, relation.foreignKeyOnTarget);
|
|
875
899
|
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
|
|
876
900
|
const targetIdColumn = this.primaryKeyColumn(targetTable);
|
|
877
901
|
if (!targetIdColumn) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}', so a filter on it has nothing to match against.`);
|
|
@@ -1023,9 +1047,10 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1023
1047
|
case "not-ilike": return sql`${column} NOT ILIKE ${String(value)}`;
|
|
1024
1048
|
case "is-null": return sql`${column} IS NULL`;
|
|
1025
1049
|
case "is-not-null": return sql`${column} IS NOT NULL`;
|
|
1026
|
-
default:
|
|
1027
|
-
|
|
1028
|
-
|
|
1050
|
+
default: throw ApiError.badRequest(`Unknown filter operator '${op}'. Valid operators: ${ALL_WHERE_FILTER_OPS.join(", ")}.`, "UNKNOWN_FILTER_OPERATOR", {
|
|
1051
|
+
operator: op,
|
|
1052
|
+
validOperators: ALL_WHERE_FILTER_OPS
|
|
1053
|
+
});
|
|
1029
1054
|
}
|
|
1030
1055
|
}
|
|
1031
1056
|
/**
|
|
@@ -1222,7 +1247,7 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1222
1247
|
if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
|
|
1223
1248
|
return match(targetIdCol);
|
|
1224
1249
|
}
|
|
1225
|
-
const foreignKeyCol = targetTable
|
|
1250
|
+
const foreignKeyCol = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
|
|
1226
1251
|
if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A link through a junction is \`kind: "manyToMany"\`.`);
|
|
1227
1252
|
return match(foreignKeyCol);
|
|
1228
1253
|
}
|
|
@@ -1251,7 +1276,8 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1251
1276
|
* `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
|
|
1252
1277
|
* inside JSONB and arrays, and uses the GIN index.
|
|
1253
1278
|
* - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
|
|
1254
|
-
* string properties,
|
|
1279
|
+
* string properties, with the term escaped (see {@link escapeLikePattern})
|
|
1280
|
+
* so it is matched as the literal text the user typed.
|
|
1255
1281
|
*
|
|
1256
1282
|
* The second is the default and stays the default. A collection that has
|
|
1257
1283
|
* not opted in compiles to exactly the SQL it compiled to before this
|
|
@@ -1265,15 +1291,16 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1265
1291
|
const searchConditions = [];
|
|
1266
1292
|
const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
|
|
1267
1293
|
if (ftsCondition) return [ftsCondition];
|
|
1294
|
+
let declaredStringProperties = 0;
|
|
1268
1295
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1269
1296
|
const p = prop;
|
|
1270
1297
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
1298
|
+
declaredStringProperties++;
|
|
1271
1299
|
const fieldColumn = table[key];
|
|
1272
|
-
if (fieldColumn) {
|
|
1273
|
-
if (fieldColumn instanceof PgVarchar || fieldColumn instanceof PgText || fieldColumn instanceof PgChar || fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
|
|
1274
|
-
}
|
|
1300
|
+
if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
|
|
1275
1301
|
}
|
|
1276
1302
|
}
|
|
1303
|
+
if (declaredStringProperties > 0 && searchConditions.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
|
|
1277
1304
|
return searchConditions;
|
|
1278
1305
|
}
|
|
1279
1306
|
/**
|
|
@@ -1485,10 +1512,19 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1485
1512
|
* - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
|
|
1486
1513
|
* - `filter`: optional WHERE clause for distance threshold
|
|
1487
1514
|
* - `distanceSelect`: SQL expression for selecting the distance as `_distance`
|
|
1515
|
+
*
|
|
1516
|
+
* `property` is `?vector_search=` off the querystring, so it is an untrusted
|
|
1517
|
+
* *name*, and it used to be looked up straight in the drizzle table object.
|
|
1518
|
+
* Two ways that went wrong, both answering 500 to a malformed request:
|
|
1519
|
+
* `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
|
|
1520
|
+
* database rejects with "operator does not exist"; and a table object also
|
|
1521
|
+
* carries non-column keys (`_`, methods), which passed the `if (!column)`
|
|
1522
|
+
* guard and compiled to nonsense. The name is resolved against the table's
|
|
1523
|
+
* actual columns and required to be a `vector` — anything else is the
|
|
1524
|
+
* caller's mistake and gets a 400 that says so.
|
|
1488
1525
|
*/
|
|
1489
1526
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
1490
|
-
const column = table
|
|
1491
|
-
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1527
|
+
const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
|
|
1492
1528
|
if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
1493
1529
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1494
1530
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
@@ -1510,7 +1546,33 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1510
1546
|
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
|
|
1511
1547
|
};
|
|
1512
1548
|
}
|
|
1549
|
+
/**
|
|
1550
|
+
* The `vector` column a request named, or a 400 explaining what it named.
|
|
1551
|
+
*
|
|
1552
|
+
* `getTableColumns` rather than a key lookup: it returns only the columns,
|
|
1553
|
+
* so `_`, `getSQL` and every other property of a drizzle table stop looking
|
|
1554
|
+
* like candidates. The type check is on the *physical* column
|
|
1555
|
+
* (`vector(1536)`) rather than on the declared property, so it holds for an
|
|
1556
|
+
* introspected collection too, where the property carries no Rebase type.
|
|
1557
|
+
*/
|
|
1558
|
+
static resolveVectorColumn(table, property) {
|
|
1559
|
+
const columns = getTableColumns(table);
|
|
1560
|
+
const column = columns?.[property];
|
|
1561
|
+
if (!column) {
|
|
1562
|
+
const known = Object.entries(columns ?? {}).filter(([, c]) => isVectorColumn(c)).map(([name]) => name);
|
|
1563
|
+
throw ApiError.badRequest(`Unknown vector property "${property}". ` + (known.length > 0 ? `This collection's vector properties are: ${known.join(", ")}.` : "This collection declares no `vector` property to search."), "UNKNOWN_VECTOR_PROPERTY");
|
|
1564
|
+
}
|
|
1565
|
+
if (!isVectorColumn(column)) throw ApiError.badRequest(`Property "${property}" is not a vector column (it is \`${columnSqlType(column) || "unknown"}\`), so it has no distance operator. Name the property declared as \`{ type: "vector" }\`.`, "UNKNOWN_VECTOR_PROPERTY");
|
|
1566
|
+
return column;
|
|
1567
|
+
}
|
|
1513
1568
|
};
|
|
1569
|
+
/** The column's SQL type, for a value that may not be a drizzle column at all. */
|
|
1570
|
+
var columnSqlType = (column) => {
|
|
1571
|
+
const getSQLType = column?.getSQLType;
|
|
1572
|
+
return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
|
|
1573
|
+
};
|
|
1574
|
+
/** True for `vector(1536)` and its pgvector siblings, whatever the width. */
|
|
1575
|
+
var isVectorColumn = (column) => /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
|
|
1514
1576
|
/**
|
|
1515
1577
|
* Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
|
|
1516
1578
|
* This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
|
|
@@ -1559,10 +1621,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1559
1621
|
const joinPathRelationUpdates = [];
|
|
1560
1622
|
const foreignKeys = /* @__PURE__ */ new Set();
|
|
1561
1623
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1562
|
-
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
1624
|
+
if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
|
|
1563
1625
|
});
|
|
1564
1626
|
for (const [key, value] of Object.entries(row)) {
|
|
1565
1627
|
if (isPrototypePollutingKey(key)) continue;
|
|
1628
|
+
if (value === void 0) continue;
|
|
1566
1629
|
const property = properties[key];
|
|
1567
1630
|
const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
|
|
1568
1631
|
if (!property) {
|
|
@@ -1573,11 +1636,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1573
1636
|
const relation = findRelation(resolvedRelations, key);
|
|
1574
1637
|
if (relation) {
|
|
1575
1638
|
if (relation.kind === "belongsTo") {
|
|
1576
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1577
|
-
if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
|
|
1639
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1640
|
+
if (serializedValue !== void 0) result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
|
|
1578
1641
|
continue;
|
|
1579
1642
|
} else if (hasForeignKeyOnTarget(relation)) {
|
|
1580
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1643
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1581
1644
|
inverseRelationUpdates.push({
|
|
1582
1645
|
relationKey: key,
|
|
1583
1646
|
relation,
|
|
@@ -1585,7 +1648,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1585
1648
|
});
|
|
1586
1649
|
continue;
|
|
1587
1650
|
} else if (relation.kind === "via") {
|
|
1588
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1651
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1589
1652
|
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
1590
1653
|
relationKey: key,
|
|
1591
1654
|
relation,
|
|
@@ -1600,7 +1663,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1600
1663
|
}
|
|
1601
1664
|
}
|
|
1602
1665
|
}
|
|
1603
|
-
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
1666
|
+
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
1604
1667
|
}
|
|
1605
1668
|
return {
|
|
1606
1669
|
scalarData: result,
|
|
@@ -1609,19 +1672,39 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1609
1672
|
};
|
|
1610
1673
|
}
|
|
1611
1674
|
/**
|
|
1612
|
-
*
|
|
1675
|
+
* How to name a rejected value in an error, without quoting it back.
|
|
1676
|
+
*
|
|
1677
|
+
* The value may be anything the caller sent, including a secret in the wrong
|
|
1678
|
+
* field, so the message describes its shape rather than echoing it — an echoed
|
|
1679
|
+
* value ends up in logs and in error-reporting services.
|
|
1680
|
+
*/
|
|
1681
|
+
function describeValue(value) {
|
|
1682
|
+
if (value === null) return "null";
|
|
1683
|
+
if (Array.isArray(value)) return "an array";
|
|
1684
|
+
if (value instanceof Date) return "a date";
|
|
1685
|
+
const type = typeof value;
|
|
1686
|
+
return type === "object" ? "an object" : `a ${type}`;
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Serialize a single property value for database storage.
|
|
1690
|
+
*
|
|
1691
|
+
* `propertyKey` is only ever used to phrase errors and warnings. Without it the
|
|
1692
|
+
* one trace a bad value left was `Expected array value for array property, got
|
|
1693
|
+
* string` — no collection, no property, no value, which in the log of a
|
|
1694
|
+
* thousand-row import names nothing at all.
|
|
1613
1695
|
*/
|
|
1614
|
-
function serializePropertyToServer(value, property) {
|
|
1696
|
+
function serializePropertyToServer(value, property, propertyKey) {
|
|
1615
1697
|
if (value === null || value === void 0) return value;
|
|
1698
|
+
const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
|
|
1616
1699
|
switch (property.type) {
|
|
1617
1700
|
case "relation":
|
|
1618
|
-
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property));
|
|
1701
|
+
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
|
|
1619
1702
|
else if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
1620
1703
|
if (value === "") return null;
|
|
1621
1704
|
return value;
|
|
1622
1705
|
case "array":
|
|
1623
1706
|
if (Array.isArray(value)) {
|
|
1624
|
-
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of));
|
|
1707
|
+
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
|
|
1625
1708
|
else if (property.oneOf) {
|
|
1626
1709
|
const typeField = property.oneOf.typeField ?? "type";
|
|
1627
1710
|
const valueField = property.oneOf.valueField ?? "value";
|
|
@@ -1634,20 +1717,28 @@ function serializePropertyToServer(value, property) {
|
|
|
1634
1717
|
if (!type || !childProperty) return e;
|
|
1635
1718
|
return {
|
|
1636
1719
|
[typeField]: type,
|
|
1637
|
-
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
1720
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
1638
1721
|
};
|
|
1639
1722
|
});
|
|
1640
1723
|
}
|
|
1641
1724
|
return value;
|
|
1642
1725
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1726
|
+
throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
1727
|
+
case "geopoint": {
|
|
1728
|
+
if (typeof value !== "object" || Array.isArray(value)) throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
1729
|
+
const point = value;
|
|
1730
|
+
if (typeof point.latitude !== "number" || typeof point.longitude !== "number") throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`, "VALIDATION_INVALID_VALUE");
|
|
1731
|
+
return {
|
|
1732
|
+
latitude: point.latitude,
|
|
1733
|
+
longitude: point.longitude
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1645
1736
|
case "map":
|
|
1646
1737
|
if (typeof value === "object" && property.properties) {
|
|
1647
1738
|
const result = {};
|
|
1648
1739
|
for (const [subKey, subValue] of Object.entries(value)) {
|
|
1649
1740
|
const subProperty = property.properties[subKey];
|
|
1650
|
-
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty);
|
|
1741
|
+
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
1651
1742
|
else result[subKey] = subValue;
|
|
1652
1743
|
}
|
|
1653
1744
|
return result;
|
|
@@ -1682,8 +1773,9 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1682
1773
|
for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
|
|
1683
1774
|
const relation = findRelation(resolvedRelations, propKey);
|
|
1684
1775
|
if (relation) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1776
|
+
const localField = relation.kind === "belongsTo" ? fieldKeyForColumn(collection, relation.localKey) : "";
|
|
1777
|
+
if (relation.kind === "belongsTo" && localField in data) {
|
|
1778
|
+
const fkValue = data[localField];
|
|
1687
1779
|
if (fkValue !== null && fkValue !== void 0) try {
|
|
1688
1780
|
const targetCollection = relation.target();
|
|
1689
1781
|
result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
|
|
@@ -1696,7 +1788,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1696
1788
|
const pks = getPrimaryKeys(collection, registry);
|
|
1697
1789
|
const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId(data, pks);
|
|
1698
1790
|
if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
|
|
1699
|
-
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
1791
|
+
const foreignKeyColumn = targetTable[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
1700
1792
|
if (foreignKeyColumn) {
|
|
1701
1793
|
const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
|
|
1702
1794
|
if (relatedRows.length > 0) if (relation.cardinality === "one") {
|
|
@@ -1885,6 +1977,7 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1885
1977
|
return isNaN(parsed) ? null : parsed;
|
|
1886
1978
|
}
|
|
1887
1979
|
return value;
|
|
1980
|
+
case "geopoint": return value;
|
|
1888
1981
|
case "vector": {
|
|
1889
1982
|
let nums = [];
|
|
1890
1983
|
if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
|
|
@@ -1944,7 +2037,9 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
|
|
|
1944
2037
|
const result = {};
|
|
1945
2038
|
const internalFKColumns = /* @__PURE__ */ new Set();
|
|
1946
2039
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1947
|
-
if (relation.kind
|
|
2040
|
+
if (relation.kind !== "belongsTo") return;
|
|
2041
|
+
const localField = fieldKeyForColumn(collection, relation.localKey);
|
|
2042
|
+
if (!properties[localField]) internalFKColumns.add(localField);
|
|
1948
2043
|
});
|
|
1949
2044
|
for (const [key, value] of Object.entries(data)) {
|
|
1950
2045
|
if (internalFKColumns.has(key)) {
|
|
@@ -1977,34 +2072,179 @@ function normalizeDbValues(data, collection) {
|
|
|
1977
2072
|
return normalizeScalarValues(data, properties, collection, resolveCollectionRelations(collection), { skipRelations: true });
|
|
1978
2073
|
}
|
|
1979
2074
|
//#endregion
|
|
1980
|
-
//#region src/services/
|
|
2075
|
+
//#region src/services/write-denial.ts
|
|
2076
|
+
/**
|
|
2077
|
+
* Explain a write that matched no rows.
|
|
2078
|
+
*
|
|
2079
|
+
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
2080
|
+
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
2081
|
+
* like a successful one that happened to match nothing. Left unchecked, a
|
|
2082
|
+
* caller cannot tell "denied" from "done" — the write returns 200/204 and the
|
|
2083
|
+
* row is untouched. An agent handed a key with `orders:delete` and no delete
|
|
2084
|
+
* policy is the case that makes it concrete: it deletes nothing, forever, and
|
|
2085
|
+
* is told it worked every time.
|
|
2086
|
+
*
|
|
2087
|
+
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
2088
|
+
* cases. A visible row means the policy rejected the write (403); an invisible
|
|
2089
|
+
* one means there is nothing there to write for this caller (404, matching what
|
|
2090
|
+
* a GET would say). The re-read is bound by the caller's own policies, so it
|
|
2091
|
+
* discloses nothing a plain read wouldn't.
|
|
2092
|
+
*
|
|
2093
|
+
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
2094
|
+
*
|
|
2095
|
+
* It lives here, rather than beside its first caller, because every zero-row
|
|
2096
|
+
* write has to answer the same question and answer it identically: the rule
|
|
2097
|
+
* that a readable-but-unwritable target is a 403 is the contract, and a second
|
|
2098
|
+
* copy of it is a second chance to get it wrong.
|
|
2099
|
+
*
|
|
2100
|
+
* @param handle The RLS-scoped connection the write ran on — not a fresh
|
|
2101
|
+
* one, or the re-read would answer for a different caller.
|
|
2102
|
+
* @param table The table the write targeted (the junction, for a link).
|
|
2103
|
+
* @param conditions The write's own WHERE terms, reused verbatim.
|
|
2104
|
+
* @param denied Message for the 403: the target is there and was refused.
|
|
2105
|
+
* @param missing Message for the 404: there is nothing there for this caller.
|
|
2106
|
+
*/
|
|
2107
|
+
async function explainZeroRowWrite(handle, table, conditions, denied, missing) {
|
|
2108
|
+
if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(denied, "WRITE_DENIED");
|
|
2109
|
+
return ApiError.notFound(missing);
|
|
2110
|
+
}
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/services/junction-writes.ts
|
|
2113
|
+
/** A junction that cannot be resolved is a broken relation, not a no-op. */
|
|
2114
|
+
var misconfigured = (label, detail) => relationMisconfigured(label, detail);
|
|
2115
|
+
function column(table, name, label, role) {
|
|
2116
|
+
const col = name ? table[name] : void 0;
|
|
2117
|
+
if (!col) throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
|
|
2118
|
+
return col;
|
|
2119
|
+
}
|
|
2120
|
+
/** The junction a `manyToMany` names outright. */
|
|
2121
|
+
function bindThroughJunction(registry, through, label) {
|
|
2122
|
+
const table = registry.getTable(through.table);
|
|
2123
|
+
if (!table) throw misconfigured(label, `no table '${through.table}' in the registry`);
|
|
2124
|
+
return {
|
|
2125
|
+
table,
|
|
2126
|
+
parentColumn: column(table, through.sourceColumn, label, "source"),
|
|
2127
|
+
targetColumn: column(table, through.targetColumn, label, "target"),
|
|
2128
|
+
label
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
/** The bare column name, whether written as `col` or `table.col`. */
|
|
2132
|
+
var columnPart = (spec) => {
|
|
2133
|
+
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2134
|
+
return one.includes(".") ? one.split(".")[1] : one;
|
|
2135
|
+
};
|
|
2136
|
+
/** The table a column spec names, or undefined when it is unqualified. */
|
|
2137
|
+
var tablePart = (spec) => {
|
|
2138
|
+
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2139
|
+
return one.includes(".") ? one.split(".")[0] : void 0;
|
|
2140
|
+
};
|
|
1981
2141
|
/**
|
|
1982
|
-
* The
|
|
1983
|
-
*
|
|
1984
|
-
* A
|
|
1985
|
-
* the
|
|
1986
|
-
* what
|
|
1987
|
-
*
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
if (
|
|
2001
|
-
if (
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
});
|
|
2142
|
+
* The junction a `via` relation reaches through, from either end.
|
|
2143
|
+
*
|
|
2144
|
+
* A step is `{ table: T, on: { from, to } }` where `from` names a column on
|
|
2145
|
+
* whatever the walk was standing on and `to` names one on `T`. That positional
|
|
2146
|
+
* meaning is what makes the unqualified form work at all, so the walk carries
|
|
2147
|
+
* the previous table rather than asking the column names where they live.
|
|
2148
|
+
*/
|
|
2149
|
+
function bindJoinPathJunction(registry, joinPath, parentTableName, targetTableName, label) {
|
|
2150
|
+
const junctionName = joinPath.map((step) => step.table).find((table) => table !== parentTableName && table !== targetTableName);
|
|
2151
|
+
if (!junctionName) throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
|
|
2152
|
+
const table = registry.getTable(junctionName);
|
|
2153
|
+
if (!table) throw misconfigured(label, `no table '${junctionName}' in the registry`);
|
|
2154
|
+
let parentColumnName;
|
|
2155
|
+
let targetColumnName;
|
|
2156
|
+
let previousTable = parentTableName;
|
|
2157
|
+
for (const step of joinPath) {
|
|
2158
|
+
const fromTable = tablePart(step.on.from) ?? previousTable;
|
|
2159
|
+
const toTable = tablePart(step.on.to) ?? step.table;
|
|
2160
|
+
if (toTable === junctionName && fromTable === parentTableName) parentColumnName = columnPart(step.on.to);
|
|
2161
|
+
else if (fromTable === junctionName && toTable === parentTableName) parentColumnName = columnPart(step.on.from);
|
|
2162
|
+
else if (toTable === junctionName && fromTable === targetTableName) targetColumnName = columnPart(step.on.to);
|
|
2163
|
+
else if (fromTable === junctionName && toTable === targetTableName) targetColumnName = columnPart(step.on.from);
|
|
2164
|
+
previousTable = step.table;
|
|
2165
|
+
}
|
|
2166
|
+
if (!parentColumnName || !targetColumnName) throw misconfigured(label, `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`);
|
|
2167
|
+
return {
|
|
2168
|
+
table,
|
|
2169
|
+
parentColumn: column(table, parentColumnName, label, "source"),
|
|
2170
|
+
targetColumn: column(table, targetColumnName, label, "target"),
|
|
2171
|
+
label
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Remove one link, leaving the row on the far side alone.
|
|
2176
|
+
*
|
|
2177
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
2178
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
2179
|
+
* post that uses it.
|
|
2180
|
+
*/
|
|
2181
|
+
async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
2182
|
+
const conditions = [eq(binding.parentColumn, parentId), eq(binding.targetColumn, targetId)];
|
|
2183
|
+
if (((await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0) === 0) throw await explainZeroRowWrite(tx, binding.table, conditions, `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": a row-level security policy rejected the write.`, `No "${subject.relation}" link between "${subject.parent}" "${parentId}" and "${targetId}" to remove.`);
|
|
2184
|
+
logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Make the junction say that `parentId` is linked to exactly `targetIds`, by
|
|
2188
|
+
* diffing against what is linked now rather than replacing the set.
|
|
2189
|
+
*
|
|
2190
|
+
* A save of the parent used to delete every junction row for it and re-insert
|
|
2191
|
+
* the ids the browser sent — a list the browser assembled from a read it did
|
|
2192
|
+
* earlier. Three things followed, all data loss rather than display:
|
|
2193
|
+
*
|
|
2194
|
+
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
|
|
2195
|
+
* saves any field from a form that predates it, and X is gone with nothing
|
|
2196
|
+
* reported to either of them.
|
|
2197
|
+
* - **A partially-read set is a partially-deleted set.** The read that fills
|
|
2198
|
+
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
2199
|
+
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
2200
|
+
* the links they were never shown. The select that drives the diff runs in
|
|
2201
|
+
* this same transaction under the same policies, so a link the caller cannot
|
|
2202
|
+
* read is in neither list and survives the save.
|
|
2203
|
+
* - **Junction payload columns.** A junction carrying its own columns
|
|
2204
|
+
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
2205
|
+
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
2206
|
+
*
|
|
2207
|
+
* The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
|
|
2208
|
+
* concurrently is a no-op rather than a unique violation.
|
|
2209
|
+
*/
|
|
2210
|
+
async function applyJunctionMembership(tx, binding, parentId, targetIds) {
|
|
2211
|
+
const existingRows = await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
|
|
2212
|
+
const existingById = /* @__PURE__ */ new Map();
|
|
2213
|
+
for (const row of existingRows) {
|
|
2214
|
+
if (row.targetId === null || row.targetId === void 0) continue;
|
|
2215
|
+
existingById.set(String(row.targetId), row.targetId);
|
|
2216
|
+
}
|
|
2217
|
+
const wantedById = /* @__PURE__ */ new Map();
|
|
2218
|
+
for (const targetId of targetIds) {
|
|
2219
|
+
if (targetId === null || targetId === void 0) continue;
|
|
2220
|
+
wantedById.set(String(targetId), targetId);
|
|
2221
|
+
}
|
|
2222
|
+
const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
|
|
2223
|
+
const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, value]) => value);
|
|
2224
|
+
if (removed.length > 0) await removeLinks(tx, binding, parentId, removed);
|
|
2225
|
+
if (added.length > 0) await tx.insert(binding.table).values(added.map((targetId) => ({
|
|
2226
|
+
[binding.parentColumn.name]: parentId,
|
|
2227
|
+
[binding.targetColumn.name]: targetId
|
|
2228
|
+
}))).onConflictDoNothing();
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Drop the named links, and refuse to call it done if the database kept any.
|
|
2232
|
+
*
|
|
2233
|
+
* Every id here came out of the select in {@link applyJunctionMembership}, on
|
|
2234
|
+
* this same handle, so all of them were visible. Fewer deletions than that means
|
|
2235
|
+
* something refused them — and a save that reports success while the membership
|
|
2236
|
+
* it stored is not the membership it was given has told the caller something
|
|
2237
|
+
* untrue about the database.
|
|
2238
|
+
*/
|
|
2239
|
+
async function removeLinks(tx, binding, parentId, removed) {
|
|
2240
|
+
const conditions = [eq(binding.parentColumn, parentId), inArray(binding.targetColumn, removed)];
|
|
2241
|
+
const deleted = (await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0;
|
|
2242
|
+
if (deleted >= removed.length) return;
|
|
2243
|
+
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2244
|
+
throw ApiError.forbidden(`Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) for relation '${binding.label}': a row-level security policy rejected the write.`, "WRITE_DENIED");
|
|
2007
2245
|
}
|
|
2246
|
+
//#endregion
|
|
2247
|
+
//#region src/services/RelationService.ts
|
|
2008
2248
|
/**
|
|
2009
2249
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
2010
2250
|
* Drizzle's `$dynamic()` queries lose the `innerJoin` method from
|
|
@@ -2112,6 +2352,10 @@ var RelationService = class {
|
|
|
2112
2352
|
const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
|
|
2113
2353
|
return keyByParentId.get(String(parentId));
|
|
2114
2354
|
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Shared with {@link RelationWriteService}: a write needs the same source
|
|
2357
|
+
* key a read does, and resolving it twice is how the two would disagree.
|
|
2358
|
+
*/
|
|
2115
2359
|
async resolveSourceKeys(parentCollection, relation, parentIds, db = this.db) {
|
|
2116
2360
|
const keyByParentId = /* @__PURE__ */ new Map();
|
|
2117
2361
|
const parentIdByKey = /* @__PURE__ */ new Map();
|
|
@@ -2282,30 +2526,6 @@ var RelationService = class {
|
|
|
2282
2526
|
return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
|
|
2283
2527
|
}
|
|
2284
2528
|
/**
|
|
2285
|
-
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
2286
|
-
* row itself alone.
|
|
2287
|
-
*
|
|
2288
|
-
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
2289
|
-
* target is shared, so deleting the row would remove the tag from every other
|
|
2290
|
-
* post that uses it. It used to do exactly that — resolve the path to the
|
|
2291
|
-
* `tags` table and delete by primary key.
|
|
2292
|
-
*/
|
|
2293
|
-
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
2294
|
-
if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
2295
|
-
const through = hop.relation.through;
|
|
2296
|
-
const junctionTable = this.registry.getTable(through.table);
|
|
2297
|
-
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
2298
|
-
const sourceJunctionColumn = junctionTable[through.sourceColumn];
|
|
2299
|
-
const targetJunctionColumn = junctionTable[through.targetColumn];
|
|
2300
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
2301
|
-
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
2302
|
-
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
2303
|
-
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
2304
|
-
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
2305
|
-
await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
|
|
2306
|
-
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
2307
|
-
}
|
|
2308
|
-
/**
|
|
2309
2529
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
2310
2530
|
*/
|
|
2311
2531
|
async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
|
|
@@ -2353,7 +2573,7 @@ var RelationService = class {
|
|
|
2353
2573
|
}
|
|
2354
2574
|
if (relation.kind === "belongsTo") {
|
|
2355
2575
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
2356
|
-
const localKeyCol = parentTable[relation.localKey];
|
|
2576
|
+
const localKeyCol = parentTable[fieldKeyForColumn(parentCollection, relation.localKey)];
|
|
2357
2577
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
2358
2578
|
const fkRows = await this.db.select({
|
|
2359
2579
|
parentId: parentIdCol,
|
|
@@ -2399,7 +2619,7 @@ var RelationService = class {
|
|
|
2399
2619
|
for (const row of results) {
|
|
2400
2620
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
2401
2621
|
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
2402
|
-
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
|
|
2622
|
+
const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
2403
2623
|
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
2404
2624
|
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
2405
2625
|
if (parentId !== void 0) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
@@ -2456,17 +2676,7 @@ var RelationService = class {
|
|
|
2456
2676
|
}
|
|
2457
2677
|
if (relation.kind === "manyToMany") {
|
|
2458
2678
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
2459
|
-
const junctionTable = this.registry
|
|
2460
|
-
if (!junctionTable) {
|
|
2461
|
-
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
2462
|
-
return /* @__PURE__ */ new Map();
|
|
2463
|
-
}
|
|
2464
|
-
const sourceJunctionCol = junctionTable[relation.through.sourceColumn];
|
|
2465
|
-
const targetJunctionCol = junctionTable[relation.through.targetColumn];
|
|
2466
|
-
if (!sourceJunctionCol || !targetJunctionCol) {
|
|
2467
|
-
logger.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
|
|
2468
|
-
return /* @__PURE__ */ new Map();
|
|
2469
|
-
}
|
|
2679
|
+
const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relation.relationName}`);
|
|
2470
2680
|
const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(inArray(sourceJunctionCol, parsedParentIds));
|
|
2471
2681
|
const resultMap = /* @__PURE__ */ new Map();
|
|
2472
2682
|
const targetTableName = getTableName$1(targetCollection);
|
|
@@ -2494,7 +2704,7 @@ var RelationService = class {
|
|
|
2494
2704
|
for (const row of results) {
|
|
2495
2705
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
2496
2706
|
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
2497
|
-
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
|
|
2707
|
+
const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
2498
2708
|
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
2499
2709
|
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
2500
2710
|
if (parentId !== void 0) {
|
|
@@ -2506,346 +2716,6 @@ var RelationService = class {
|
|
|
2506
2716
|
}
|
|
2507
2717
|
return resultMap;
|
|
2508
2718
|
}
|
|
2509
|
-
/**
|
|
2510
|
-
* Update many-to-many and junction relations
|
|
2511
|
-
*/
|
|
2512
|
-
async updateRelationsUsingJoins(tx, collection, id, relationValues) {
|
|
2513
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2514
|
-
for (const [key, value] of Object.entries(relationValues)) {
|
|
2515
|
-
const relation = findRelation(resolvedRelations, key);
|
|
2516
|
-
if (!relation || relation.cardinality !== "many") continue;
|
|
2517
|
-
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
2518
|
-
const targetCollection = relation.target();
|
|
2519
|
-
if (relation.kind === "via") {
|
|
2520
|
-
const parentTableName = getTableName$1(collection);
|
|
2521
|
-
const targetTableName = getTableName$1(targetCollection);
|
|
2522
|
-
let junctionTable = void 0;
|
|
2523
|
-
let sourceJunctionColumn = null;
|
|
2524
|
-
let targetJunctionColumn = null;
|
|
2525
|
-
const junctionTableName = relation.joinPath.find((step) => step.table !== parentTableName && step.table !== targetTableName)?.table;
|
|
2526
|
-
if (junctionTableName) {
|
|
2527
|
-
junctionTable = this.registry.getTable(junctionTableName);
|
|
2528
|
-
if (junctionTable) for (const joinStep of relation.joinPath) {
|
|
2529
|
-
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
|
|
2530
|
-
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
|
|
2531
|
-
if (fromTable === parentTableName && toTable === junctionTableName) {
|
|
2532
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
2533
|
-
sourceJunctionColumn = junctionTable[columnNames[0]];
|
|
2534
|
-
} else if (fromTable === junctionTableName && toTable === parentTableName) {
|
|
2535
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
2536
|
-
sourceJunctionColumn = junctionTable[columnNames[0]];
|
|
2537
|
-
}
|
|
2538
|
-
if (fromTable === junctionTableName && toTable === targetTableName) {
|
|
2539
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
2540
|
-
targetJunctionColumn = junctionTable[columnNames[0]];
|
|
2541
|
-
} else if (fromTable === targetTableName && toTable === junctionTableName) {
|
|
2542
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
2543
|
-
targetJunctionColumn = junctionTable[columnNames[0]];
|
|
2544
|
-
}
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
|
|
2548
|
-
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
2549
|
-
continue;
|
|
2550
|
-
}
|
|
2551
|
-
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
2552
|
-
const parentIdInfo = parentPks[0];
|
|
2553
|
-
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2554
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
2555
|
-
if (targetEntityIds.length > 0) {
|
|
2556
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2557
|
-
const targetIdInfo = targetPks[0];
|
|
2558
|
-
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2559
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
2560
|
-
[targetJunctionColumn.name]: targetId
|
|
2561
|
-
}));
|
|
2562
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2563
|
-
}
|
|
2564
|
-
} else if (relation.kind === "manyToMany") {
|
|
2565
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
2566
|
-
if (!junctionTable) {
|
|
2567
|
-
logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
|
|
2568
|
-
continue;
|
|
2569
|
-
}
|
|
2570
|
-
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn];
|
|
2571
|
-
const targetJunctionColumn = junctionTable[relation.through.targetColumn];
|
|
2572
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2573
|
-
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
2574
|
-
continue;
|
|
2575
|
-
}
|
|
2576
|
-
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
2577
|
-
const parentIdInfo = parentPks[0];
|
|
2578
|
-
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2579
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
2580
|
-
if (targetEntityIds.length > 0) {
|
|
2581
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2582
|
-
const targetIdInfo = targetPks[0];
|
|
2583
|
-
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2584
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
2585
|
-
[targetJunctionColumn.name]: targetId
|
|
2586
|
-
}));
|
|
2587
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2588
|
-
}
|
|
2589
|
-
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
2590
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2591
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2592
|
-
const targetIdInfo = targetPks[0];
|
|
2593
|
-
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
2594
|
-
const fkCol = targetTable[relation.foreignKeyOnTarget];
|
|
2595
|
-
if (!fkCol || !targetIdCol) {
|
|
2596
|
-
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
2597
|
-
continue;
|
|
2598
|
-
}
|
|
2599
|
-
const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
2600
|
-
if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
|
|
2601
|
-
if (targetEntityIds.length > 0) {
|
|
2602
|
-
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2603
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
|
|
2604
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
2605
|
-
} else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol, parentKeyValue));
|
|
2606
|
-
} else logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
/**
|
|
2610
|
-
* Update inverse relations (where FK is on the target table)
|
|
2611
|
-
*/
|
|
2612
|
-
async updateInverseRelations(tx, sourceCollection, sourceEntityId, inverseRelationUpdates) {
|
|
2613
|
-
for (const update of inverseRelationUpdates) {
|
|
2614
|
-
const { relation, newValue } = update;
|
|
2615
|
-
try {
|
|
2616
|
-
const targetCollection = relation.target();
|
|
2617
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2618
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2619
|
-
const targetIdInfo = targetPks[0];
|
|
2620
|
-
requirePrimaryKeys(sourceCollection, this.registry)[0];
|
|
2621
|
-
if (relation.kind === "via") {
|
|
2622
|
-
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
2623
|
-
continue;
|
|
2624
|
-
}
|
|
2625
|
-
if (isManyToMany(relation)) {
|
|
2626
|
-
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
|
|
2627
|
-
table: relation.through.table,
|
|
2628
|
-
sourceColumn: relation.through.sourceColumn,
|
|
2629
|
-
targetColumn: relation.through.targetColumn
|
|
2630
|
-
});
|
|
2631
|
-
continue;
|
|
2632
|
-
}
|
|
2633
|
-
if (!hasForeignKeyOnTarget(relation)) {
|
|
2634
|
-
logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
|
|
2635
|
-
continue;
|
|
2636
|
-
}
|
|
2637
|
-
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
2638
|
-
if (!foreignKeyColumn) {
|
|
2639
|
-
logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
|
|
2640
|
-
continue;
|
|
2641
|
-
}
|
|
2642
|
-
const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
2643
|
-
if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
|
|
2644
|
-
if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
2645
|
-
else {
|
|
2646
|
-
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
2647
|
-
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
2648
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
2649
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
2650
|
-
}
|
|
2651
|
-
} catch (e) {
|
|
2652
|
-
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
/**
|
|
2657
|
-
* Handle inverse relations with joinPath
|
|
2658
|
-
*/
|
|
2659
|
-
async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
|
|
2660
|
-
try {
|
|
2661
|
-
const sourceTableName = getTableName$1(sourceCollection);
|
|
2662
|
-
const targetTableName = getTableName$1(targetCollection);
|
|
2663
|
-
const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
|
|
2664
|
-
if (intermediateTables.length === 1 && relation.cardinality === "many") {
|
|
2665
|
-
const junctionTableName = intermediateTables[0];
|
|
2666
|
-
const junctionTable = this.registry.getTable(junctionTableName);
|
|
2667
|
-
if (!junctionTable) {
|
|
2668
|
-
logger.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
|
|
2669
|
-
return;
|
|
2670
|
-
}
|
|
2671
|
-
let sourceJunctionColumn = null;
|
|
2672
|
-
let targetJunctionColumn = null;
|
|
2673
|
-
for (const step of relation.joinPath) if (step.table === junctionTableName) {
|
|
2674
|
-
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
|
|
2675
|
-
const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
|
|
2676
|
-
const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
|
|
2677
|
-
if (fromTable === sourceTableName) sourceJunctionColumn = junctionTable[toColumnNames[0]];
|
|
2678
|
-
else if (fromTable === targetTableName) targetJunctionColumn = junctionTable[toColumnNames[0]];
|
|
2679
|
-
else {
|
|
2680
|
-
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
|
|
2681
|
-
if (toTable === sourceTableName) sourceJunctionColumn = junctionTable[fromColumnNames[0]];
|
|
2682
|
-
else if (toTable === targetTableName) targetJunctionColumn = junctionTable[fromColumnNames[0]];
|
|
2683
|
-
}
|
|
2684
|
-
}
|
|
2685
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2686
|
-
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
2687
|
-
return;
|
|
2688
|
-
}
|
|
2689
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2690
|
-
const sourceIdInfo = sourcePks[0];
|
|
2691
|
-
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
2692
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
2693
|
-
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
2694
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2695
|
-
const targetIdInfo = targetPks[0];
|
|
2696
|
-
const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2697
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2698
|
-
[targetJunctionColumn.name]: targetId
|
|
2699
|
-
}));
|
|
2700
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2701
|
-
} else if (newValue && !Array.isArray(newValue)) {
|
|
2702
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2703
|
-
const targetIdInfo = targetPks[0];
|
|
2704
|
-
const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
|
|
2705
|
-
const newLink = {
|
|
2706
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2707
|
-
[targetJunctionColumn.name]: parsedTargetId
|
|
2708
|
-
};
|
|
2709
|
-
await tx.insert(junctionTable).values(newLink);
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
} catch (error) {
|
|
2713
|
-
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
2714
|
-
throw error;
|
|
2715
|
-
}
|
|
2716
|
-
}
|
|
2717
|
-
/**
|
|
2718
|
-
* Handle many-to-many inverse relation updates using junction tables
|
|
2719
|
-
*/
|
|
2720
|
-
async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
|
|
2721
|
-
try {
|
|
2722
|
-
const junctionTable = this.registry.getTable(junctionInfo.table);
|
|
2723
|
-
if (!junctionTable) {
|
|
2724
|
-
logger.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
|
|
2725
|
-
return;
|
|
2726
|
-
}
|
|
2727
|
-
const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn];
|
|
2728
|
-
const targetJunctionColumn = junctionTable[junctionInfo.targetColumn];
|
|
2729
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2730
|
-
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
2731
|
-
return;
|
|
2732
|
-
}
|
|
2733
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2734
|
-
const sourceIdInfo = sourcePks[0];
|
|
2735
|
-
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
2736
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
2737
|
-
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
2738
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2739
|
-
const targetIdInfo = targetPks[0];
|
|
2740
|
-
const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2741
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2742
|
-
[targetJunctionColumn.name]: targetId
|
|
2743
|
-
}));
|
|
2744
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2745
|
-
}
|
|
2746
|
-
} catch (error) {
|
|
2747
|
-
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
2748
|
-
throw error;
|
|
2749
|
-
}
|
|
2750
|
-
}
|
|
2751
|
-
/**
|
|
2752
|
-
* Update one-to-one relations that use joinPath
|
|
2753
|
-
*/
|
|
2754
|
-
async updateJoinPathOneToOneRelations(tx, parentCollection, parentId, updates) {
|
|
2755
|
-
for (const upd of updates) {
|
|
2756
|
-
const { relation, newTargetId } = upd;
|
|
2757
|
-
const targetCollection = relation.target();
|
|
2758
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2759
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2760
|
-
const targetIdInfo = targetPks[0];
|
|
2761
|
-
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
2762
|
-
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
2763
|
-
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
2764
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2765
|
-
const parentIdInfo = parentPks[0];
|
|
2766
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
2767
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
2768
|
-
const parentSourceCol = parentTable[parentSourceColName];
|
|
2769
|
-
const targetFKCol = targetTable[targetFKColName];
|
|
2770
|
-
if (!parentSourceCol) {
|
|
2771
|
-
logger.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
2772
|
-
continue;
|
|
2773
|
-
}
|
|
2774
|
-
if (!targetFKCol) {
|
|
2775
|
-
logger.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
2776
|
-
continue;
|
|
2777
|
-
}
|
|
2778
|
-
const parentRows = await tx.select({ val: parentSourceCol }).from(parentTable).where(eq(parentIdCol, parsedParentId)).limit(1);
|
|
2779
|
-
if (parentRows.length === 0) continue;
|
|
2780
|
-
const parentFKValue = parentRows[0].val;
|
|
2781
|
-
if (newTargetId === null || newTargetId === void 0) {
|
|
2782
|
-
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
2783
|
-
continue;
|
|
2784
|
-
}
|
|
2785
|
-
const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
2786
|
-
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
2787
|
-
else {
|
|
2788
|
-
logger.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
|
|
2789
|
-
continue;
|
|
2790
|
-
}
|
|
2791
|
-
await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
|
|
2792
|
-
}
|
|
2793
|
-
}
|
|
2794
|
-
/**
|
|
2795
|
-
* Resolve joinPath write mapping for one-to-one relations
|
|
2796
|
-
*/
|
|
2797
|
-
resolveJoinPathWriteMapping(parentCollection, relation) {
|
|
2798
|
-
if (!relation.joinPath || relation.joinPath.length === 0) throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
2799
|
-
const parentTableName = getTableName$1(parentCollection);
|
|
2800
|
-
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
2801
|
-
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
2802
|
-
let currentFrom = lastStep.on.from;
|
|
2803
|
-
let safety = 0;
|
|
2804
|
-
while (safety++ < 10) {
|
|
2805
|
-
if (DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0] === parentTableName) break;
|
|
2806
|
-
const prevStep = relation.joinPath.find((s) => {
|
|
2807
|
-
return (Array.isArray(s.on.to) ? s.on.to[0] : s.on.to) === currentFrom;
|
|
2808
|
-
});
|
|
2809
|
-
if (!prevStep) throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
2810
|
-
currentFrom = prevStep.on.from;
|
|
2811
|
-
}
|
|
2812
|
-
return {
|
|
2813
|
-
targetFKColName,
|
|
2814
|
-
parentSourceColName: DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0]
|
|
2815
|
-
};
|
|
2816
|
-
}
|
|
2817
|
-
/**
|
|
2818
|
-
* Handle junction table creation for many-to-many path-based saves
|
|
2819
|
-
*/
|
|
2820
|
-
async handleJunctionTableCreation(tx, newEntityId, junctionTableInfo) {
|
|
2821
|
-
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
2822
|
-
const targetCollection = relation.target();
|
|
2823
|
-
try {
|
|
2824
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
2825
|
-
if (!junctionTable) {
|
|
2826
|
-
logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
|
|
2827
|
-
return;
|
|
2828
|
-
}
|
|
2829
|
-
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn];
|
|
2830
|
-
const targetJunctionColumn = junctionTable[relation.through.targetColumn];
|
|
2831
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2832
|
-
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
2833
|
-
return;
|
|
2834
|
-
}
|
|
2835
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2836
|
-
const targetIdInfo = targetPks[0];
|
|
2837
|
-
const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
|
|
2838
|
-
const junctionData = {
|
|
2839
|
-
[sourceJunctionColumn.name]: parentId,
|
|
2840
|
-
[targetJunctionColumn.name]: parsedNewEntityId
|
|
2841
|
-
};
|
|
2842
|
-
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
2843
|
-
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
2844
|
-
} catch (error) {
|
|
2845
|
-
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
|
|
2846
|
-
throw error;
|
|
2847
|
-
}
|
|
2848
|
-
}
|
|
2849
2719
|
};
|
|
2850
2720
|
//#endregion
|
|
2851
2721
|
//#region src/services/row-pipeline.ts
|
|
@@ -3293,8 +3163,7 @@ function sanitizeErrorForClient(error, context) {
|
|
|
3293
3163
|
column: pgError.column,
|
|
3294
3164
|
table: pgError.table,
|
|
3295
3165
|
constraint: pgError.constraint,
|
|
3296
|
-
dataType: pgError.dataType
|
|
3297
|
-
drizzleMessage: error instanceof Error ? error.message : String(error)
|
|
3166
|
+
dataType: pgError.dataType
|
|
3298
3167
|
});
|
|
3299
3168
|
return pgErrorToFriendlyMessage(pgError, context);
|
|
3300
3169
|
}
|
|
@@ -3410,10 +3279,15 @@ var FetchService = class FetchService {
|
|
|
3410
3279
|
if (direct) return direct;
|
|
3411
3280
|
const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : void 0;
|
|
3412
3281
|
if (declaredRelation?.kind === "belongsTo") {
|
|
3413
|
-
const foreignKey = columnAt(declaredRelation.localKey);
|
|
3282
|
+
const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
|
|
3414
3283
|
if (foreignKey) return foreignKey;
|
|
3415
3284
|
}
|
|
3416
|
-
for (const guess of [
|
|
3285
|
+
for (const guess of [
|
|
3286
|
+
`${orderBy}Id`,
|
|
3287
|
+
toWireKey(generateForeignKeyName(orderBy)),
|
|
3288
|
+
`${orderBy}_id`,
|
|
3289
|
+
generateForeignKeyName(orderBy)
|
|
3290
|
+
]) {
|
|
3417
3291
|
const foreignKey = columnAt(guess);
|
|
3418
3292
|
if (foreignKey) return foreignKey;
|
|
3419
3293
|
}
|
|
@@ -3487,6 +3361,7 @@ var FetchService = class FetchService {
|
|
|
3487
3361
|
row[key] = createRelationRefWithData(e.id, e.path, e);
|
|
3488
3362
|
} else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
|
|
3489
3363
|
} catch (e) {
|
|
3364
|
+
if (reachedDatabase(e)) throw e;
|
|
3490
3365
|
logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
|
|
3491
3366
|
}
|
|
3492
3367
|
});
|
|
@@ -3522,6 +3397,7 @@ var FetchService = class FetchService {
|
|
|
3522
3397
|
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
3523
3398
|
}
|
|
3524
3399
|
} catch (e) {
|
|
3400
|
+
if (reachedDatabase(e)) throw e;
|
|
3525
3401
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
3526
3402
|
}
|
|
3527
3403
|
}
|
|
@@ -3686,6 +3562,7 @@ var FetchService = class FetchService {
|
|
|
3686
3562
|
values[key] = createRelationRef(e.id, e.path);
|
|
3687
3563
|
}
|
|
3688
3564
|
} catch (e) {
|
|
3565
|
+
if (reachedDatabase(e)) throw e;
|
|
3689
3566
|
logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
|
|
3690
3567
|
}
|
|
3691
3568
|
}
|
|
@@ -3823,6 +3700,7 @@ var FetchService = class FetchService {
|
|
|
3823
3700
|
if (relatedRow) item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
3824
3701
|
});
|
|
3825
3702
|
} catch (e) {
|
|
3703
|
+
if (reachedDatabase(e)) throw e;
|
|
3826
3704
|
logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
|
|
3827
3705
|
}
|
|
3828
3706
|
}
|
|
@@ -3836,6 +3714,7 @@ var FetchService = class FetchService {
|
|
|
3836
3714
|
item.values[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
|
|
3837
3715
|
});
|
|
3838
3716
|
} catch (e) {
|
|
3717
|
+
if (reachedDatabase(e)) throw e;
|
|
3839
3718
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
3840
3719
|
}
|
|
3841
3720
|
}
|
|
@@ -3969,6 +3848,7 @@ var FetchService = class FetchService {
|
|
|
3969
3848
|
if (related) row[key] = { ...related.values };
|
|
3970
3849
|
}
|
|
3971
3850
|
} catch (e) {
|
|
3851
|
+
if (reachedDatabase(e)) throw e;
|
|
3972
3852
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
3973
3853
|
}
|
|
3974
3854
|
}
|
|
@@ -3981,6 +3861,7 @@ var FetchService = class FetchService {
|
|
|
3981
3861
|
row[key] = batchResults.get(String(eid)) || [];
|
|
3982
3862
|
}
|
|
3983
3863
|
} catch (e) {
|
|
3864
|
+
if (reachedDatabase(e)) throw e;
|
|
3984
3865
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
3985
3866
|
}
|
|
3986
3867
|
}
|
|
@@ -4043,6 +3924,7 @@ var FetchService = class FetchService {
|
|
|
4043
3924
|
...e.values
|
|
4044
3925
|
}));
|
|
4045
3926
|
} catch (e) {
|
|
3927
|
+
if (reachedDatabase(e)) throw e;
|
|
4046
3928
|
logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
|
|
4047
3929
|
}
|
|
4048
3930
|
}
|
|
@@ -4079,6 +3961,10 @@ var FetchService = class FetchService {
|
|
|
4079
3961
|
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
4080
3962
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
4081
3963
|
}
|
|
3964
|
+
if (options.logical) {
|
|
3965
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3966
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3967
|
+
}
|
|
4082
3968
|
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
4083
3969
|
if (allConditions.length > 0) {
|
|
4084
3970
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -4090,55 +3976,318 @@ var FetchService = class FetchService {
|
|
|
4090
3976
|
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
4091
3977
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
4092
3978
|
}
|
|
4093
|
-
orderExpressions.push(desc(idField));
|
|
4094
|
-
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
4095
|
-
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
4096
|
-
if (limitValue) query = query.limit(limitValue);
|
|
4097
|
-
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
4098
|
-
const rawResults = await query;
|
|
4099
|
-
if (vectorMeta) return rawResults.map((r) => ({
|
|
4100
|
-
...r.table_row,
|
|
4101
|
-
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
4102
|
-
}));
|
|
4103
|
-
if (rankSelect) return rawResults.map((r) => ({
|
|
4104
|
-
...r.table_row,
|
|
4105
|
-
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
4106
|
-
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
4107
|
-
}));
|
|
4108
|
-
return rawResults;
|
|
3979
|
+
orderExpressions.push(desc(idField));
|
|
3980
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
3981
|
+
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
3982
|
+
if (limitValue) query = query.limit(limitValue);
|
|
3983
|
+
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
3984
|
+
const rawResults = await query;
|
|
3985
|
+
if (vectorMeta) return rawResults.map((r) => ({
|
|
3986
|
+
...r.table_row,
|
|
3987
|
+
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3988
|
+
}));
|
|
3989
|
+
if (rankSelect) return rawResults.map((r) => ({
|
|
3990
|
+
...r.table_row,
|
|
3991
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
3992
|
+
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
3993
|
+
}));
|
|
3994
|
+
return rawResults;
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Check if the Drizzle instance has the relational query API available
|
|
3998
|
+
* for a given collection path.
|
|
3999
|
+
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
4000
|
+
*/
|
|
4001
|
+
hasDrizzleQueryAPI(collectionPath) {
|
|
4002
|
+
if (!this.getQueryBuilder("__probe__")) return false;
|
|
4003
|
+
const tableName = getTableName(getTableForCollection(getCollectionByPath(collectionPath, this.registry), this.registry));
|
|
4004
|
+
return !!this.getQueryBuilder(tableName);
|
|
4005
|
+
}
|
|
4006
|
+
/**
|
|
4007
|
+
* Fallback path used when db.query is unavailable.
|
|
4008
|
+
* The primary path uses db.query.findMany with `with` config, which
|
|
4009
|
+
* loads all relations in a single query.
|
|
4010
|
+
*
|
|
4011
|
+
* Batch fetch many-to-many related rows for multiple parent IDs.
|
|
4012
|
+
* Groups results by parent ID to avoid N+1.
|
|
4013
|
+
*/
|
|
4014
|
+
async batchFetchManyRelatedRows(parentCollectionPath, parentIds, relationKey) {
|
|
4015
|
+
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
4016
|
+
const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
|
|
4017
|
+
if (!relation) {
|
|
4018
|
+
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
4019
|
+
return /* @__PURE__ */ new Map();
|
|
4020
|
+
}
|
|
4021
|
+
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
4022
|
+
const flatMap = /* @__PURE__ */ new Map();
|
|
4023
|
+
for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
|
|
4024
|
+
...e.values,
|
|
4025
|
+
id: e.id
|
|
4026
|
+
})));
|
|
4027
|
+
return flatMap;
|
|
4028
|
+
}
|
|
4029
|
+
};
|
|
4030
|
+
//#endregion
|
|
4031
|
+
//#region src/services/RelationWriteService.ts
|
|
4032
|
+
/**
|
|
4033
|
+
* The ids in a to-many relation write, whatever shape the caller sent.
|
|
4034
|
+
*
|
|
4035
|
+
* A membership list is written as either the related rows (`[{ id: 1 }]`, what
|
|
4036
|
+
* the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
|
|
4037
|
+
* what anyone writing the API by hand sends). Only the first was read, via a
|
|
4038
|
+
* blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
|
|
4039
|
+
* on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
|
|
4040
|
+
* and on a string-keyed one it did not surface at all — `String(undefined)`
|
|
4041
|
+
* wrote a junction row pointing at the literal `"undefined"`, which no read
|
|
4042
|
+
* would ever match. Both shapes are accepted here, in one place, because both
|
|
4043
|
+
* call sites had the same assumption.
|
|
4044
|
+
*
|
|
4045
|
+
* An element that carries no key is refused rather than skipped: dropping it
|
|
4046
|
+
* would silently write a shorter membership list than the caller asked for.
|
|
4047
|
+
*/
|
|
4048
|
+
function relationTargetIds(value, relationName, collectionSlug) {
|
|
4049
|
+
if (!Array.isArray(value)) return [];
|
|
4050
|
+
return value.map((element, index) => {
|
|
4051
|
+
if (typeof element === "string" || typeof element === "number") return element;
|
|
4052
|
+
if (element && typeof element === "object") {
|
|
4053
|
+
const id = element.id;
|
|
4054
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
4055
|
+
}
|
|
4056
|
+
throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
|
|
4057
|
+
});
|
|
4058
|
+
}
|
|
4059
|
+
/**
|
|
4060
|
+
* Writing relations: junction membership, foreign-key stamping, and the links a
|
|
4061
|
+
* nested path creates or removes.
|
|
4062
|
+
*
|
|
4063
|
+
* Split from {@link RelationService}, which now only reads. The two had grown
|
|
4064
|
+
* into one 1700-line class doing two unrelated jobs, and sharing one habit —
|
|
4065
|
+
* warning about a relation it could not resolve and carrying on, which surfaces
|
|
4066
|
+
* as an empty list on the read side and as a successful save on the write side.
|
|
4067
|
+
* Separating them is what made that visible as one class of defect rather than
|
|
4068
|
+
* eighteen scattered log lines.
|
|
4069
|
+
*
|
|
4070
|
+
* The reads it still needs — the source key a link joins on — it asks
|
|
4071
|
+
* {@link RelationService} for rather than reimplementing.
|
|
4072
|
+
*/
|
|
4073
|
+
var RelationWriteService = class {
|
|
4074
|
+
db;
|
|
4075
|
+
registry;
|
|
4076
|
+
reads;
|
|
4077
|
+
constructor(db, registry) {
|
|
4078
|
+
this.db = db;
|
|
4079
|
+
this.registry = registry;
|
|
4080
|
+
this.reads = new RelationService(db, registry);
|
|
4081
|
+
}
|
|
4082
|
+
/**
|
|
4083
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
4084
|
+
* row itself alone.
|
|
4085
|
+
*
|
|
4086
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
4087
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
4088
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
4089
|
+
* `tags` table and delete by primary key.
|
|
4090
|
+
*/
|
|
4091
|
+
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
4092
|
+
if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
4093
|
+
await removeJunctionLink(tx, bindThroughJunction(this.registry, hop.relation.through, `${hop.parentCollection.slug}.${hop.relationKey}`), this.parsedId(hop.parentCollection, hop.parentId), this.parsedId(hop.targetCollection, targetId), {
|
|
4094
|
+
parent: hop.parentCollection.slug,
|
|
4095
|
+
relation: hop.relationKey
|
|
4096
|
+
});
|
|
4097
|
+
}
|
|
4098
|
+
/** A collection's id, parsed to the type its primary key column holds. */
|
|
4099
|
+
parsedId(collection, id) {
|
|
4100
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4101
|
+
return parseIdValues(id, pks)[pks[0].fieldName];
|
|
4102
|
+
}
|
|
4103
|
+
/** The same, for the membership list a to-many write names. */
|
|
4104
|
+
parsedIds(collection, ids) {
|
|
4105
|
+
if (ids.length === 0) return [];
|
|
4106
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4107
|
+
return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
|
|
4108
|
+
}
|
|
4109
|
+
/**
|
|
4110
|
+
* Update many-to-many and junction relations
|
|
4111
|
+
*/
|
|
4112
|
+
async updateRelationsUsingJoins(tx, collection, id, relationValues) {
|
|
4113
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
4114
|
+
for (const [key, value] of Object.entries(relationValues)) {
|
|
4115
|
+
const relation = findRelation(resolvedRelations, key);
|
|
4116
|
+
if (!relation || relation.cardinality !== "many") continue;
|
|
4117
|
+
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
4118
|
+
const targetCollection = relation.target();
|
|
4119
|
+
const label = `${collection.slug}.${key}`;
|
|
4120
|
+
if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
|
|
4121
|
+
else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
|
|
4122
|
+
else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
4123
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4124
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
4125
|
+
const targetIdInfo = targetPks[0];
|
|
4126
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
4127
|
+
const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
|
|
4128
|
+
const fkCol = targetTable[fkField];
|
|
4129
|
+
if (!fkCol || !targetIdCol) throw relationMisconfigured(label, `the target table '${getTableName$1(targetCollection)}' has no ${fkCol ? `'${targetIdInfo.fieldName}' key column` : `'${relation.foreignKeyOnTarget}' foreign-key column`}`);
|
|
4130
|
+
const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
4131
|
+
if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
|
|
4132
|
+
if (targetEntityIds.length > 0) {
|
|
4133
|
+
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
4134
|
+
await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds)));
|
|
4135
|
+
await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
4136
|
+
} else await tx.update(targetTable).set({ [fkField]: null }).where(eq(fkCol, parentKeyValue));
|
|
4137
|
+
} else throw relationMisconfigured(label, "it is a to-many relation with no way to write links — a `manyToMany` needs `through`, a `hasMany` needs `foreignKeyOnTarget`");
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
/**
|
|
4141
|
+
* Update inverse relations (where FK is on the target table)
|
|
4142
|
+
*/
|
|
4143
|
+
async updateInverseRelations(tx, sourceCollection, sourceEntityId, inverseRelationUpdates) {
|
|
4144
|
+
for (const update of inverseRelationUpdates) {
|
|
4145
|
+
const { relation, newValue } = update;
|
|
4146
|
+
try {
|
|
4147
|
+
const targetCollection = relation.target();
|
|
4148
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4149
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
4150
|
+
const targetIdInfo = targetPks[0];
|
|
4151
|
+
requirePrimaryKeys(sourceCollection, this.registry)[0];
|
|
4152
|
+
if (relation.kind === "via") {
|
|
4153
|
+
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
4154
|
+
continue;
|
|
4155
|
+
}
|
|
4156
|
+
if (isManyToMany(relation)) {
|
|
4157
|
+
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
|
|
4158
|
+
table: relation.through.table,
|
|
4159
|
+
sourceColumn: relation.through.sourceColumn,
|
|
4160
|
+
targetColumn: relation.through.targetColumn
|
|
4161
|
+
});
|
|
4162
|
+
continue;
|
|
4163
|
+
}
|
|
4164
|
+
const label = `${sourceCollection.slug}.${relation.relationName}`;
|
|
4165
|
+
if (!hasForeignKeyOnTarget(relation)) throw relationMisconfigured(label, `a '${relation.kind}' relation names no column on the target to write the link into`);
|
|
4166
|
+
const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
|
|
4167
|
+
const foreignKeyColumn = targetTable[fkField];
|
|
4168
|
+
if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
|
|
4169
|
+
const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
4170
|
+
if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
|
|
4171
|
+
if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
4172
|
+
else {
|
|
4173
|
+
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
4174
|
+
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
4175
|
+
await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
4176
|
+
await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
4177
|
+
}
|
|
4178
|
+
} catch (e) {
|
|
4179
|
+
if (e instanceof ApiError || e?.name === "ApiError") throw e;
|
|
4180
|
+
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* Handle inverse relations with joinPath
|
|
4186
|
+
*/
|
|
4187
|
+
async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
|
|
4188
|
+
const sourceTableName = getTableName$1(sourceCollection);
|
|
4189
|
+
const targetTableName = getTableName$1(targetCollection);
|
|
4190
|
+
if (relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName).length !== 1 || relation.cardinality !== "many") return;
|
|
4191
|
+
try {
|
|
4192
|
+
const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
|
|
4193
|
+
const targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationTargetIds([newValue], relation.relationName, sourceCollection.slug);
|
|
4194
|
+
await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
|
|
4195
|
+
} catch (error) {
|
|
4196
|
+
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
4197
|
+
throw error;
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
/**
|
|
4201
|
+
* Handle many-to-many inverse relation updates using junction tables
|
|
4202
|
+
*/
|
|
4203
|
+
async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
|
|
4204
|
+
try {
|
|
4205
|
+
const targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : [];
|
|
4206
|
+
await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, `${sourceCollection.slug}.${relation.relationName}`), this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
|
|
4207
|
+
} catch (error) {
|
|
4208
|
+
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
4209
|
+
throw error;
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
/**
|
|
4213
|
+
* Update one-to-one relations that use joinPath
|
|
4214
|
+
*/
|
|
4215
|
+
async updateJoinPathOneToOneRelations(tx, parentCollection, parentId, updates) {
|
|
4216
|
+
for (const upd of updates) {
|
|
4217
|
+
const { relation, newTargetId } = upd;
|
|
4218
|
+
const targetCollection = relation.target();
|
|
4219
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4220
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
4221
|
+
const targetIdInfo = targetPks[0];
|
|
4222
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
4223
|
+
const { targetFKColName: targetFKColumn, parentSourceColName: parentSourceColumn } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
4224
|
+
const targetFKColName = fieldKeyForColumn(targetCollection, targetFKColumn);
|
|
4225
|
+
const parentSourceColName = fieldKeyForColumn(parentCollection, parentSourceColumn);
|
|
4226
|
+
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
4227
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
4228
|
+
const parentIdInfo = parentPks[0];
|
|
4229
|
+
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
4230
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
4231
|
+
const parentSourceCol = parentTable[parentSourceColName];
|
|
4232
|
+
const targetFKCol = targetTable[targetFKColName];
|
|
4233
|
+
const label = `${parentCollection.slug}.${relation.relationName}`;
|
|
4234
|
+
if (!parentSourceCol) throw relationMisconfigured(label, `its joinPath reads '${parentSourceColName}', which is not a column on '${getTableName$1(parentCollection)}'`);
|
|
4235
|
+
if (!targetFKCol) throw relationMisconfigured(label, `its joinPath writes '${targetFKColName}', which is not a column on '${getTableName$1(targetCollection)}'`);
|
|
4236
|
+
const parentRows = await tx.select({ val: parentSourceCol }).from(parentTable).where(eq(parentIdCol, parsedParentId)).limit(1);
|
|
4237
|
+
if (parentRows.length === 0) continue;
|
|
4238
|
+
const parentFKValue = parentRows[0].val;
|
|
4239
|
+
if (newTargetId === null || newTargetId === void 0) {
|
|
4240
|
+
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
4241
|
+
continue;
|
|
4242
|
+
}
|
|
4243
|
+
const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
4244
|
+
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
4245
|
+
else throw ApiError.badRequest(`Cannot write relation '${label}': row '${parentId}' has no value in '${parentSourceColName}', which is the column its joinPath joins on, so there is nothing for the related row to point at.`, "RELATION_SOURCE_KEY_EMPTY");
|
|
4246
|
+
await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
|
|
4247
|
+
}
|
|
4109
4248
|
}
|
|
4110
4249
|
/**
|
|
4111
|
-
*
|
|
4112
|
-
* for a given collection path.
|
|
4113
|
-
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
4250
|
+
* Resolve joinPath write mapping for one-to-one relations
|
|
4114
4251
|
*/
|
|
4115
|
-
|
|
4116
|
-
if (!
|
|
4117
|
-
const
|
|
4118
|
-
|
|
4252
|
+
resolveJoinPathWriteMapping(parentCollection, relation) {
|
|
4253
|
+
if (!relation.joinPath || relation.joinPath.length === 0) throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
4254
|
+
const parentTableName = getTableName$1(parentCollection);
|
|
4255
|
+
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
4256
|
+
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
4257
|
+
let currentFrom = lastStep.on.from;
|
|
4258
|
+
let safety = 0;
|
|
4259
|
+
while (safety++ < 10) {
|
|
4260
|
+
if (DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0] === parentTableName) break;
|
|
4261
|
+
const prevStep = relation.joinPath.find((s) => {
|
|
4262
|
+
return (Array.isArray(s.on.to) ? s.on.to[0] : s.on.to) === currentFrom;
|
|
4263
|
+
});
|
|
4264
|
+
if (!prevStep) throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
4265
|
+
currentFrom = prevStep.on.from;
|
|
4266
|
+
}
|
|
4267
|
+
return {
|
|
4268
|
+
targetFKColName,
|
|
4269
|
+
parentSourceColName: DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0]
|
|
4270
|
+
};
|
|
4119
4271
|
}
|
|
4120
4272
|
/**
|
|
4121
|
-
*
|
|
4122
|
-
* The primary path uses db.query.findMany with `with` config, which
|
|
4123
|
-
* loads all relations in a single query.
|
|
4124
|
-
*
|
|
4125
|
-
* Batch fetch many-to-many related rows for multiple parent IDs.
|
|
4126
|
-
* Groups results by parent ID to avoid N+1.
|
|
4273
|
+
* Handle junction table creation for many-to-many path-based saves
|
|
4127
4274
|
*/
|
|
4128
|
-
async
|
|
4129
|
-
|
|
4130
|
-
const
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4275
|
+
async handleJunctionTableCreation(tx, newEntityId, junctionTableInfo) {
|
|
4276
|
+
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
4277
|
+
const targetCollection = relation.target();
|
|
4278
|
+
try {
|
|
4279
|
+
const binding = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relationKey}`);
|
|
4280
|
+
const parsedNewEntityId = this.parsedId(targetCollection, newEntityId);
|
|
4281
|
+
const junctionData = {
|
|
4282
|
+
[binding.parentColumn.name]: parentId,
|
|
4283
|
+
[binding.targetColumn.name]: parsedNewEntityId
|
|
4284
|
+
};
|
|
4285
|
+
await tx.insert(binding.table).values(junctionData).onConflictDoNothing();
|
|
4286
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
4287
|
+
} catch (error) {
|
|
4288
|
+
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
|
|
4289
|
+
throw error;
|
|
4134
4290
|
}
|
|
4135
|
-
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
4136
|
-
const flatMap = /* @__PURE__ */ new Map();
|
|
4137
|
-
for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
|
|
4138
|
-
...e.values,
|
|
4139
|
-
id: e.id
|
|
4140
|
-
})));
|
|
4141
|
-
return flatMap;
|
|
4142
4291
|
}
|
|
4143
4292
|
};
|
|
4144
4293
|
//#endregion
|
|
@@ -4150,34 +4299,24 @@ var FetchService = class FetchService {
|
|
|
4150
4299
|
var PersistService = class {
|
|
4151
4300
|
db;
|
|
4152
4301
|
registry;
|
|
4302
|
+
/** Reads: whether a row is under a parent, the key a link joins on. */
|
|
4153
4303
|
relationService;
|
|
4304
|
+
/** Writes: junction membership, foreign-key stamping, links. */
|
|
4305
|
+
relationWrites;
|
|
4154
4306
|
fetchService;
|
|
4155
4307
|
constructor(db, registry) {
|
|
4156
4308
|
this.db = db;
|
|
4157
4309
|
this.registry = registry;
|
|
4158
4310
|
this.relationService = new RelationService(db, registry);
|
|
4311
|
+
this.relationWrites = new RelationWriteService(db, registry);
|
|
4159
4312
|
this.fetchService = new FetchService(db, registry);
|
|
4160
4313
|
}
|
|
4161
4314
|
/**
|
|
4162
|
-
* Explain a write that matched
|
|
4163
|
-
*
|
|
4164
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
4165
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
4166
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
4167
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
4168
|
-
* the row is untouched.
|
|
4169
|
-
*
|
|
4170
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
4171
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
4172
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
4173
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
4174
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
4175
|
-
*
|
|
4176
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
4315
|
+
* Explain a row write that matched nothing — see {@link explainZeroRowWrite}
|
|
4316
|
+
* for why a zero-row write cannot be reported as success.
|
|
4177
4317
|
*/
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
4318
|
+
explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
|
|
4319
|
+
return explainZeroRowWrite(handle, table, conditions, `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, `No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
4181
4320
|
}
|
|
4182
4321
|
/**
|
|
4183
4322
|
* Delete an row by ID
|
|
@@ -4189,7 +4328,7 @@ var PersistService = class {
|
|
|
4189
4328
|
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
4190
4329
|
if (isJunctionBackedRelation(hop.relation)) {
|
|
4191
4330
|
if (!isManyToMany(hop.relation)) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
|
|
4192
|
-
await this.
|
|
4331
|
+
await this.relationWrites.unlinkRelatedEntity(this.db, hop, id);
|
|
4193
4332
|
return;
|
|
4194
4333
|
}
|
|
4195
4334
|
}
|
|
@@ -4213,8 +4352,14 @@ var PersistService = class {
|
|
|
4213
4352
|
await this.db.delete(table);
|
|
4214
4353
|
}
|
|
4215
4354
|
/**
|
|
4216
|
-
* The
|
|
4217
|
-
*
|
|
4355
|
+
* The field on the *target* row that records the parent, for a create under
|
|
4356
|
+
* a nested one-to-many path.
|
|
4357
|
+
*
|
|
4358
|
+
* A **field**, not the column: the value is stamped into the caller's
|
|
4359
|
+
* payload, which is keyed by wire names — `authorId`, never the `author_id`
|
|
4360
|
+
* the relation names its link by. Stamping the column instead put a key on
|
|
4361
|
+
* the payload that no property answers to, and `strictWrites` rejected the
|
|
4362
|
+
* request the framework had just written to.
|
|
4218
4363
|
*
|
|
4219
4364
|
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
4220
4365
|
* `joinPath`), so the caller writes the row without stamping anything.
|
|
@@ -4227,8 +4372,8 @@ var PersistService = class {
|
|
|
4227
4372
|
const { relation } = hop;
|
|
4228
4373
|
switch (relation.kind) {
|
|
4229
4374
|
case "hasOne":
|
|
4230
|
-
case "hasMany": return relation.foreignKeyOnTarget;
|
|
4231
|
-
case "via": return relation.joinPath.length === 1 ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0] : void 0;
|
|
4375
|
+
case "hasMany": return fieldKeyForColumn(hop.targetCollection, relation.foreignKeyOnTarget);
|
|
4376
|
+
case "via": return relation.joinPath.length === 1 ? fieldKeyForColumn(hop.targetCollection, DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]) : void 0;
|
|
4232
4377
|
default: return;
|
|
4233
4378
|
}
|
|
4234
4379
|
}
|
|
@@ -4300,12 +4445,13 @@ var PersistService = class {
|
|
|
4300
4445
|
const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
|
|
4301
4446
|
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
4302
4447
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
4448
|
+
assertWritableColumns(entityData, table, effectiveCollectionPath);
|
|
4303
4449
|
savedId = await this.db.transaction(async (tx) => {
|
|
4304
4450
|
let currentId;
|
|
4305
4451
|
if (id && !options?.upsert) {
|
|
4306
4452
|
currentId = id;
|
|
4307
4453
|
const idValues = parseIdValues(id, idInfoArray);
|
|
4308
|
-
if (joinPathRelationUpdates.length > 0) await this.
|
|
4454
|
+
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
4309
4455
|
if (Object.keys(entityData).length > 0) {
|
|
4310
4456
|
const updateQuery = tx.update(table).set(entityData);
|
|
4311
4457
|
const conditions = [];
|
|
@@ -4326,6 +4472,7 @@ var PersistService = class {
|
|
|
4326
4472
|
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
4327
4473
|
const set = { ...dataForInsert };
|
|
4328
4474
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
4475
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
|
|
4329
4476
|
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
4330
4477
|
target,
|
|
4331
4478
|
set
|
|
@@ -4335,11 +4482,11 @@ var PersistService = class {
|
|
|
4335
4482
|
if (!resultRow) if (id) currentId = id;
|
|
4336
4483
|
else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
|
|
4337
4484
|
else currentId = buildCompositeId(resultRow, idInfoArray);
|
|
4338
|
-
if (joinPathRelationUpdates.length > 0) await this.
|
|
4485
|
+
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
4339
4486
|
}
|
|
4340
|
-
if (inverseRelationUpdates.length > 0) await this.
|
|
4341
|
-
if (Object.keys(relationValues).length > 0) await this.
|
|
4342
|
-
if (junctionTableInfo) await this.
|
|
4487
|
+
if (inverseRelationUpdates.length > 0) await this.relationWrites.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
4488
|
+
if (Object.keys(relationValues).length > 0) await this.relationWrites.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
4489
|
+
if (junctionTableInfo) await this.relationWrites.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
4343
4490
|
return currentId;
|
|
4344
4491
|
});
|
|
4345
4492
|
} catch (error) {
|
|
@@ -4356,6 +4503,15 @@ var PersistService = class {
|
|
|
4356
4503
|
return this.relationService;
|
|
4357
4504
|
}
|
|
4358
4505
|
/**
|
|
4506
|
+
* The write half, for external use. Separate from
|
|
4507
|
+
* {@link getRelationService} because they are separate objects now: reads
|
|
4508
|
+
* answer questions, writes change rows, and the callers of one are not the
|
|
4509
|
+
* callers of the other.
|
|
4510
|
+
*/
|
|
4511
|
+
getRelationWriteService() {
|
|
4512
|
+
return this.relationWrites;
|
|
4513
|
+
}
|
|
4514
|
+
/**
|
|
4359
4515
|
* Get the FetchService instance for external use
|
|
4360
4516
|
*/
|
|
4361
4517
|
getFetchService() {
|
|
@@ -4701,284 +4857,6 @@ var BranchService = class {
|
|
|
4701
4857
|
}
|
|
4702
4858
|
};
|
|
4703
4859
|
//#endregion
|
|
4704
|
-
//#region src/security/rls-enforcement.ts
|
|
4705
|
-
var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
|
|
4706
|
-
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
4707
|
-
var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
4708
|
-
/**
|
|
4709
|
-
* Warn when the connection role shares its name with an existing schema.
|
|
4710
|
-
*
|
|
4711
|
-
* Postgres resolves unqualified names through `search_path`, which defaults to
|
|
4712
|
-
* `"$user", public` — and `$user` is the connection ROLE. When a schema of that
|
|
4713
|
-
* name exists it sits ahead of `public`, so every unqualified statement
|
|
4714
|
-
* silently operates on it instead:
|
|
4715
|
-
*
|
|
4716
|
-
* CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
|
|
4717
|
-
*
|
|
4718
|
-
* Nothing errors. You get a second table of the same name in the wrong schema,
|
|
4719
|
-
* and reads that pin `public` cannot see it — which reads as "missing table" and
|
|
4720
|
-
* sends people to re-run a push that creates a *third* copy. The bootstrapper
|
|
4721
|
-
* has a whole branch dedicated to recognising the symptom after the fact.
|
|
4722
|
-
*
|
|
4723
|
-
* Rebase shipped straight into this: it creates a schema named `rebase` while
|
|
4724
|
-
* every template named the database role `rebase` too. The scaffold uses
|
|
4725
|
-
* `rebase_app` now, and every pool Rebase opens pins `search_path=public`
|
|
4726
|
-
* (`pinSearchPath`), which covers the paths the framework controls. This covers
|
|
4727
|
-
* the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
|
|
4728
|
-
* a hand-written migration — because the hazard is a property of the two NAMES,
|
|
4729
|
-
* not of any one connection.
|
|
4730
|
-
*
|
|
4731
|
-
* A warning rather than a boot failure: the database works, the framework's own
|
|
4732
|
-
* traffic is pinned, and refusing to start over a naming choice a user may have
|
|
4733
|
-
* inherited would be worse than the risk.
|
|
4734
|
-
*/
|
|
4735
|
-
async function warnOnRoleSchemaCollision(run) {
|
|
4736
|
-
try {
|
|
4737
|
-
const rows = await run(`
|
|
4738
|
-
SELECT current_user AS role,
|
|
4739
|
-
EXISTS (
|
|
4740
|
-
SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
|
|
4741
|
-
) AS collides
|
|
4742
|
-
`);
|
|
4743
|
-
if (rows[0]?.collides !== true) return;
|
|
4744
|
-
const role = String(rows[0]?.role ?? "the connection role");
|
|
4745
|
-
logger.warn(`⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the hazard entirely, connect as a role whose name is not also a schema — the scaffold uses "rebase_app".`);
|
|
4746
|
-
} catch {}
|
|
4747
|
-
}
|
|
4748
|
-
async function detectConnectionPosture(run) {
|
|
4749
|
-
const row = (await run(`
|
|
4750
|
-
SELECT current_user AS role,
|
|
4751
|
-
r.rolsuper AS superuser,
|
|
4752
|
-
r.rolbypassrls AS bypassrls,
|
|
4753
|
-
EXISTS (
|
|
4754
|
-
SELECT 1 FROM pg_tables t
|
|
4755
|
-
WHERE t.tableowner = current_user
|
|
4756
|
-
AND t.schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
4757
|
-
) AS owns_tables
|
|
4758
|
-
FROM pg_roles r
|
|
4759
|
-
WHERE r.rolname = current_user
|
|
4760
|
-
`))[0] ?? {};
|
|
4761
|
-
const superuser = row.superuser === true;
|
|
4762
|
-
const bypassRLS = row.bypassrls === true;
|
|
4763
|
-
const ownsTables = row.owns_tables === true;
|
|
4764
|
-
return {
|
|
4765
|
-
role: String(row.role ?? "unknown"),
|
|
4766
|
-
superuser,
|
|
4767
|
-
bypassRLS,
|
|
4768
|
-
ownsTables,
|
|
4769
|
-
privileged: superuser || bypassRLS || ownsTables
|
|
4770
|
-
};
|
|
4771
|
-
}
|
|
4772
|
-
/**
|
|
4773
|
-
* Human-actionable instructions for when the connection cannot provision the
|
|
4774
|
-
* user role itself (no CREATEROLE and role not pre-created by the platform).
|
|
4775
|
-
*/
|
|
4776
|
-
function appRoleSetupInstructions(connectionRole, schemas) {
|
|
4777
|
-
const grants = schemas.map((s) => `GRANT USAGE ON SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};\nGRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};\nGRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};`).join("\n");
|
|
4778
|
-
return `Rebase enforces row-level security by running authenticated requests as the restricted role "${REBASE_USER_ROLE}", but the connection role "${connectionRole}" bypasses RLS and cannot create that role itself.\nRun the following as a database administrator, then restart:\n\nCREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;\nGRANT ${REBASE_USER_ROLE} TO ${quoteIdent$1(connectionRole)};\n` + grants;
|
|
4779
|
-
}
|
|
4780
|
-
/**
|
|
4781
|
-
* Idempotently provision the `rebase_user` role, membership for the current
|
|
4782
|
-
* connection role, and DML grants (+ default privileges for future tables)
|
|
4783
|
-
* on every existing schema in `schemas`.
|
|
4784
|
-
*
|
|
4785
|
-
* Split into privilege tiers so it works both when the connection is a
|
|
4786
|
-
* superuser (creates everything) and when the platform pre-created the role
|
|
4787
|
-
* and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is
|
|
4788
|
-
* merely the table owner — owners can always run the grant tier themselves.
|
|
4789
|
-
*
|
|
4790
|
-
* RLS still filters every row: these grants only make the tables *reachable*
|
|
4791
|
-
* by the role; the policies decide which rows/commands actually pass.
|
|
4792
|
-
*
|
|
4793
|
-
* Throws with precise setup instructions when the role is missing and the
|
|
4794
|
-
* connection cannot create it.
|
|
4795
|
-
*/
|
|
4796
|
-
async function ensureAppRole(run, schemas) {
|
|
4797
|
-
const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
|
|
4798
|
-
if ((await run(`SELECT 1 FROM pg_roles WHERE rolname = 'rebase_user'`)).length === 0) try {
|
|
4799
|
-
await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
|
|
4800
|
-
} catch (err) {
|
|
4801
|
-
throw new Error(`Failed to create the "${REBASE_USER_ROLE}" role: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions("current connection role", uniqueSchemas));
|
|
4802
|
-
}
|
|
4803
|
-
const memberRows = await run(`
|
|
4804
|
-
SELECT (pg_has_role(current_user, '${REBASE_USER_ROLE}', 'MEMBER')
|
|
4805
|
-
OR (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)) AS can_set,
|
|
4806
|
-
current_user AS role
|
|
4807
|
-
`);
|
|
4808
|
-
if (memberRows[0]?.can_set !== true) try {
|
|
4809
|
-
await run(`GRANT ${REBASE_USER_ROLE} TO CURRENT_USER`);
|
|
4810
|
-
} catch (err) {
|
|
4811
|
-
throw new Error(`The connection role is not a member of "${REBASE_USER_ROLE}" and cannot grant itself membership: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions(String(memberRows[0]?.role ?? "current connection role"), uniqueSchemas));
|
|
4812
|
-
}
|
|
4813
|
-
const nspRows = await run("SELECT nspname FROM pg_namespace");
|
|
4814
|
-
const existing = new Set(nspRows.map((r) => String(r.nspname)));
|
|
4815
|
-
for (const schema of uniqueSchemas) {
|
|
4816
|
-
if (!existing.has(schema)) continue;
|
|
4817
|
-
const s = quoteIdent$1(schema);
|
|
4818
|
-
await run(`GRANT USAGE ON SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4819
|
-
await run(`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4820
|
-
await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4821
|
-
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
4822
|
-
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
4823
|
-
await revokeInternalTableAccess(async (text) => {
|
|
4824
|
-
await run(text);
|
|
4825
|
-
}, schema, { onError: (table, error) => logger.warn(`🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — it stays reachable by authenticated requests: ` + (error instanceof Error ? error.message : String(error))) });
|
|
4826
|
-
}
|
|
4827
|
-
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
4828
|
-
}
|
|
4829
|
-
/**
|
|
4830
|
-
* Apply the authenticated context to a transaction: the `app.*` GUCs that RLS
|
|
4831
|
-
* policies read via `auth.uid()` / `auth.roles()` / `auth.jwt()`, and — when
|
|
4832
|
-
* `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this
|
|
4833
|
-
* transaction (reads *and* writes).
|
|
4834
|
-
*
|
|
4835
|
-
* GUCs are set with `is_local = true` and the role switch is `LOCAL`: both
|
|
4836
|
-
* reset at commit/rollback, so pooled connections are never polluted.
|
|
4837
|
-
*
|
|
4838
|
-
* Fails closed by construction: if the role switch errors, the transaction
|
|
4839
|
-
* aborts instead of proceeding privileged.
|
|
4840
|
-
*
|
|
4841
|
-
* SECURITY: this function is only ever called on the **user** path (the server
|
|
4842
|
-
* context uses the base/owner driver and never calls it). The default policies
|
|
4843
|
-
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
4844
|
-
* is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
|
|
4845
|
-
* be read as NULL and silently escalate a user request to server privileges.
|
|
4846
|
-
* Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
|
|
4847
|
-
* rather than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
4848
|
-
* That sentinel is exported from `@rebasepro/types` because it leaks into rule
|
|
4849
|
-
* semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
|
|
4850
|
-
*/
|
|
4851
|
-
async function applyAuthContext(tx, auth, userRole) {
|
|
4852
|
-
const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
|
|
4853
|
-
const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
|
|
4854
|
-
await tx.execute(sql`
|
|
4855
|
-
SELECT
|
|
4856
|
-
set_config('app.uid', ${uid}, true),
|
|
4857
|
-
set_config('app.user_id', ${uid}, true),
|
|
4858
|
-
set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
|
|
4859
|
-
set_config('app.jwt', ${JSON.stringify({
|
|
4860
|
-
sub: uid,
|
|
4861
|
-
roles: auth.roles
|
|
4862
|
-
})}, true)
|
|
4863
|
-
`);
|
|
4864
|
-
if (userRole) await tx.execute(sql.raw(`SET LOCAL ROLE ${quoteIdent$1(userRole)}`));
|
|
4865
|
-
}
|
|
4866
|
-
/** Role names from other BaaS platforms that people reach for out of habit. */
|
|
4867
|
-
var FOREIGN_CONVENTION_ROLES = {
|
|
4868
|
-
authenticated: "Supabase",
|
|
4869
|
-
anon: "Supabase",
|
|
4870
|
-
service_role: "Supabase"
|
|
4871
|
-
};
|
|
4872
|
-
/**
|
|
4873
|
-
* Warn about rules that read as "signed-in users only" but admit anonymous
|
|
4874
|
-
* callers — `auth.uid() IS NOT NULL`, or a comparison against another
|
|
4875
|
-
* platform's magic user id such as `'anon'`.
|
|
4876
|
-
*
|
|
4877
|
-
* The sibling of {@link validatePolicyPgRoles}, for the more dangerous spelling
|
|
4878
|
-
* of the same habit. A foreign `pgRoles` value makes a policy unreachable and
|
|
4879
|
-
* the table reads empty — loud, and that guard throws. These do the opposite:
|
|
4880
|
-
* the rule compiles to a grant, and nothing looks wrong until the data is
|
|
4881
|
-
* already public.
|
|
4882
|
-
*
|
|
4883
|
-
* Warns rather than throws. Unlike an unreachable `pgRoles`, these rules are
|
|
4884
|
-
* serving traffic today: refusing to boot would take an app offline to report a
|
|
4885
|
-
* problem it already has, and on the read path it would take it offline
|
|
4886
|
-
* *because* its data was exposed. Rewriting the author's SQL is not an option
|
|
4887
|
-
* either — this is the escape hatch whose whole promise is that it means what it
|
|
4888
|
-
* says. So: say so, loudly, and leave the rule alone.
|
|
4889
|
-
*/
|
|
4890
|
-
function warnOnAnonymousGrants(collections) {
|
|
4891
|
-
const byRisk = /* @__PURE__ */ new Map();
|
|
4892
|
-
for (const collection of collections) for (const rule of collection.securityRules ?? []) {
|
|
4893
|
-
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
4894
|
-
const risks = [usingExpr, withCheckExpr].filter((e) => e !== null).flatMap(findAnonymousGrants);
|
|
4895
|
-
for (const risk of risks) {
|
|
4896
|
-
const key = `${risk.pattern}:${risk.detail}`;
|
|
4897
|
-
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
4898
|
-
const entry = byRisk.get(key) ?? {
|
|
4899
|
-
risk,
|
|
4900
|
-
sites: []
|
|
4901
|
-
};
|
|
4902
|
-
if (!entry.sites.includes(site)) entry.sites.push(site);
|
|
4903
|
-
byRisk.set(key, entry);
|
|
4904
|
-
}
|
|
4905
|
-
}
|
|
4906
|
-
if (byRisk.size === 0) return;
|
|
4907
|
-
const problems = [...byRisk.values()].map(({ risk, sites }) => ` • ${risk.explanation}\n ${sites.length} rule(s): ${sites.join(", ")}`);
|
|
4908
|
-
logger.warn(`Security rules that read as a lockdown but grant access to anonymous requests. Every caller from a client carries a user id ('${ANONYMOUS_USER_ID}' when nobody is signed in), so these clauses are true for everyone:\n\n` + problems.join("\n\n") + "\n");
|
|
4909
|
-
}
|
|
4910
|
-
/**
|
|
4911
|
-
* Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
|
|
4912
|
-
*
|
|
4913
|
-
* The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
|
|
4914
|
-
* database, so nothing is broken and no policy is wrong — which is exactly why
|
|
4915
|
-
* this has to be said out loud. A silent rewrite that works forever is not a
|
|
4916
|
-
* migration, it is a second supported spelling nobody wrote down, and the next
|
|
4917
|
-
* person to read those rules will copy the old one.
|
|
4918
|
-
*
|
|
4919
|
-
* Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
|
|
4920
|
-
* `policy.rolesOverlap(...)`) compile from the model and were never affected.
|
|
4921
|
-
*/
|
|
4922
|
-
function warnOnLegacyRlsFunctions(collections) {
|
|
4923
|
-
const sites = [];
|
|
4924
|
-
for (const collection of collections) for (const rule of collection.securityRules ?? []) {
|
|
4925
|
-
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
4926
|
-
if (![usingExpr, withCheckExpr].filter((e) => e !== null).some(containsLegacyRlsCall)) continue;
|
|
4927
|
-
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
4928
|
-
if (!sites.includes(site)) sites.push(site);
|
|
4929
|
-
}
|
|
4930
|
-
if (sites.length === 0) return;
|
|
4931
|
-
logger.warn(`These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), which never had to be spelled by hand:\n\n` + sites.map((s) => ` • ${s}`).join("\n") + "\n");
|
|
4932
|
-
}
|
|
4933
|
-
/** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
|
|
4934
|
-
function containsLegacyRlsCall(expr) {
|
|
4935
|
-
switch (expr.kind) {
|
|
4936
|
-
case "raw": return usesLegacyRlsFunctions(expr.sql);
|
|
4937
|
-
case "and":
|
|
4938
|
-
case "or": return expr.operands.some(containsLegacyRlsCall);
|
|
4939
|
-
case "not": return containsLegacyRlsCall(expr.operand);
|
|
4940
|
-
case "existsIn": return containsLegacyRlsCall(expr.where);
|
|
4941
|
-
default: return false;
|
|
4942
|
-
}
|
|
4943
|
-
}
|
|
4944
|
-
/**
|
|
4945
|
-
* Reject `pgRoles` that this server can never satisfy.
|
|
4946
|
-
*
|
|
4947
|
-
* `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
|
|
4948
|
-
* role the request never runs as simply never applies — and RLS then filters
|
|
4949
|
-
* every row. The table reads as empty, which is indistinguishable from having
|
|
4950
|
-
* no data, so the mistake survives review and ships.
|
|
4951
|
-
*
|
|
4952
|
-
* Requests run as `rebase_user`, so a policy is only reachable if it targets
|
|
4953
|
-
* `public` or a role `rebase_user` holds. Anything else is a configuration
|
|
4954
|
-
* error worth failing the boot for.
|
|
4955
|
-
*/
|
|
4956
|
-
async function validatePolicyPgRoles(run, collections, requestRole = REBASE_USER_ROLE) {
|
|
4957
|
-
const wanted = /* @__PURE__ */ new Map();
|
|
4958
|
-
for (const collection of collections) for (const rule of collection.securityRules ?? []) for (const role of rule.pgRoles ?? []) {
|
|
4959
|
-
if (role === "public") continue;
|
|
4960
|
-
wanted.set(role, [...wanted.get(role) ?? [], collection.slug ?? "(unnamed)"]);
|
|
4961
|
-
}
|
|
4962
|
-
if (wanted.size === 0) return;
|
|
4963
|
-
const names = [...wanted.keys()].map((r) => `'${r.replace(/'/g, "''")}'`).join(",");
|
|
4964
|
-
const rows = await run(`
|
|
4965
|
-
SELECT r.rolname AS role,
|
|
4966
|
-
COALESCE(pg_has_role(to_regrole('${requestRole.replace(/'/g, "''")}'), r.oid, 'MEMBER'), false) AS reachable
|
|
4967
|
-
FROM pg_roles r
|
|
4968
|
-
WHERE r.rolname IN (${names})
|
|
4969
|
-
`);
|
|
4970
|
-
const reachable = new Map(rows.map((row) => [String(row.role), row.reachable === true]));
|
|
4971
|
-
const problems = [];
|
|
4972
|
-
for (const [role, slugs] of wanted) {
|
|
4973
|
-
if (reachable.get(role) === true) continue;
|
|
4974
|
-
const why = reachable.has(role) ? `"${requestRole}" is not a member of it` : "no such role exists in this database";
|
|
4975
|
-
const platform = FOREIGN_CONVENTION_ROLES[role];
|
|
4976
|
-
const hint = platform ? `"${role}" is a ${platform} convention, not a PostgreSQL role. Application roles belong in \`roles: ["${role === "service_role" ? "admin" : role}"]\`, which is checked inside the policy via auth.roles().` : `Either grant it (GRANT ${role} TO ${requestRole}) or drop \`pgRoles\` so the policy targets \`public\`.`;
|
|
4977
|
-
problems.push(` • pgRoles: ["${role}"] on ${slugs.join(", ")} — ${why}.\n ${hint}`);
|
|
4978
|
-
}
|
|
4979
|
-
if (problems.length > 0) throw new Error(`Security rules target PostgreSQL roles this server cannot use. Requests run as "${requestRole}", so these policies would never apply and every row would be filtered out — the collections would look empty rather than error.\n\n` + problems.join("\n\n") + "\n");
|
|
4980
|
-
}
|
|
4981
|
-
//#endregion
|
|
4982
4860
|
//#region src/PostgresBackendDriver.ts
|
|
4983
4861
|
var PostgresBackendDriver = class PostgresBackendDriver {
|
|
4984
4862
|
db;
|
|
@@ -6222,6 +6100,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
6222
6100
|
* that rotates immediately after it.
|
|
6223
6101
|
*/
|
|
6224
6102
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
6103
|
+
/**
|
|
6104
|
+
* The assurance level the sign-in was established at — `aal2` only
|
|
6105
|
+
* where a second factor was actually presented. Carried across
|
|
6106
|
+
* rotations, because refresh is not a new authentication and has
|
|
6107
|
+
* nothing else to read the level from.
|
|
6108
|
+
*/
|
|
6109
|
+
aal: text("aal"),
|
|
6225
6110
|
userAgent: text("user_agent"),
|
|
6226
6111
|
ipAddress: text("ip_address"),
|
|
6227
6112
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -6267,6 +6152,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
6267
6152
|
secretEncrypted: text("secret_encrypted").notNull(),
|
|
6268
6153
|
friendlyName: text("friendly_name"),
|
|
6269
6154
|
verified: boolean("verified").default(false).notNull(),
|
|
6155
|
+
/**
|
|
6156
|
+
* The highest TOTP time step ever accepted for this factor. RFC 6238
|
|
6157
|
+
* §5.2 forbids accepting an OTP twice, and the ±1 step window that
|
|
6158
|
+
* exists for clock drift is also a 90-second replay window: without
|
|
6159
|
+
* this, one observed code buys a fresh session for a minute and a half.
|
|
6160
|
+
*/
|
|
6161
|
+
lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
|
|
6270
6162
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
6271
6163
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
6272
6164
|
});
|
|
@@ -6284,6 +6176,8 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
6284
6176
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
6285
6177
|
verifiedAt: timestamp("verified_at"),
|
|
6286
6178
|
ipAddress: text("ip_address"),
|
|
6179
|
+
/** Failed guesses recorded against this challenge; bounded by the route. */
|
|
6180
|
+
attempts: integer("attempts").default(0).notNull(),
|
|
6287
6181
|
expiresAt: timestamp("expires_at").notNull()
|
|
6288
6182
|
}),
|
|
6289
6183
|
recoveryCodes: tableCreator("recovery_codes", {
|
|
@@ -6360,6 +6254,26 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
6360
6254
|
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
6361
6255
|
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
6362
6256
|
*/
|
|
6257
|
+
var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
6258
|
+
/**
|
|
6259
|
+
* A string literal for the generated schema file.
|
|
6260
|
+
*
|
|
6261
|
+
* Column names, table names and enum values are all written into this file as
|
|
6262
|
+
* literals, and none of them is constrained to be quote-free: a Postgres
|
|
6263
|
+
* identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
|
|
6264
|
+
* Interpolating them raw ended the literal early — for enum values, inside
|
|
6265
|
+
* single quotes, where an apostrophe is not an edge case.
|
|
6266
|
+
*/
|
|
6267
|
+
var quote$1 = (value) => JSON.stringify(value);
|
|
6268
|
+
/** An object key: verbatim when it is an identifier, quoted otherwise. */
|
|
6269
|
+
var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
|
|
6270
|
+
/**
|
|
6271
|
+
* A property access on a generated table variable.
|
|
6272
|
+
*
|
|
6273
|
+
* `users.full name` is not an expression; `users["full name"]` is, and Drizzle
|
|
6274
|
+
* treats the two identically.
|
|
6275
|
+
*/
|
|
6276
|
+
var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
|
|
6363
6277
|
var resolveColumnName = (propName, prop) => {
|
|
6364
6278
|
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
6365
6279
|
return toSnakeCase(propName);
|
|
@@ -6390,26 +6304,16 @@ var getPrimaryKeyProp = (collection) => {
|
|
|
6390
6304
|
};
|
|
6391
6305
|
};
|
|
6392
6306
|
/**
|
|
6393
|
-
* Given a raw DB column name (e.g. "client_id"),
|
|
6394
|
-
*
|
|
6395
|
-
* (a) it has an explicit `columnName` equal to the given column, OR
|
|
6396
|
-
* (b) its snake_case form equals the given column.
|
|
6307
|
+
* Given a raw DB column name (e.g. "client_id"), the Drizzle property key that
|
|
6308
|
+
* maps to it.
|
|
6397
6309
|
*
|
|
6398
|
-
*
|
|
6399
|
-
*
|
|
6310
|
+
* One line, because the rule is shared: the Drizzle object key is the wire
|
|
6311
|
+
* name, and {@link fieldKeyForColumn} is the one definition of what a column is
|
|
6312
|
+
* named on the wire. This used to be a private copy that fell back to the
|
|
6313
|
+
* column verbatim, which is how a derived foreign key ended up served as
|
|
6314
|
+
* `author_id` beside a hand-authored `displayName`.
|
|
6400
6315
|
*/
|
|
6401
|
-
var resolvePropertyKeyForColumn = (collection, column) =>
|
|
6402
|
-
if (!collection.properties) return column;
|
|
6403
|
-
for (const [propKey, prop] of Object.entries(collection.properties)) {
|
|
6404
|
-
const p = prop;
|
|
6405
|
-
if ("columnName" in p && typeof p.columnName === "string") {
|
|
6406
|
-
if (p.columnName === column) return propKey;
|
|
6407
|
-
}
|
|
6408
|
-
if (toSnakeCase(propKey) === column) return propKey;
|
|
6409
|
-
if (propKey === column) return propKey;
|
|
6410
|
-
}
|
|
6411
|
-
return column;
|
|
6412
|
-
};
|
|
6316
|
+
var resolvePropertyKeyForColumn = (collection, column) => fieldKeyForColumn(collection, column);
|
|
6413
6317
|
var isNumericId = (collection) => {
|
|
6414
6318
|
return getPrimaryKeyProp(collection).type === "number";
|
|
6415
6319
|
};
|
|
@@ -6420,18 +6324,25 @@ var isIdProperty = (propName, prop, collection) => {
|
|
|
6420
6324
|
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
6421
6325
|
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
6422
6326
|
};
|
|
6327
|
+
/**
|
|
6328
|
+
* The Drizzle column declaration a property compiles to, or `null` when the
|
|
6329
|
+
* property puts no column on *this* table (an inverse relation, whose column
|
|
6330
|
+
* lives on the target). Exported so it can be checked against its DDL twin
|
|
6331
|
+
* `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
|
|
6332
|
+
* with a database column and no Drizzle key.
|
|
6333
|
+
*/
|
|
6423
6334
|
var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
6424
6335
|
const colName = resolveColumnName(propName, prop);
|
|
6425
6336
|
let columnDefinition;
|
|
6426
6337
|
switch (prop.type) {
|
|
6427
6338
|
case "string": {
|
|
6428
6339
|
const stringProp = prop;
|
|
6429
|
-
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(
|
|
6430
|
-
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(
|
|
6431
|
-
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(
|
|
6432
|
-
else if (stringProp.columnType === "char") columnDefinition = `char(
|
|
6433
|
-
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(
|
|
6434
|
-
else columnDefinition = `text(
|
|
6340
|
+
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
|
|
6341
|
+
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
6342
|
+
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
6343
|
+
else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
6344
|
+
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
6345
|
+
else columnDefinition = `text(${quote$1(colName)})`;
|
|
6435
6346
|
if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
|
|
6436
6347
|
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
6437
6348
|
if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
|
|
@@ -6447,10 +6358,10 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6447
6358
|
case "number": {
|
|
6448
6359
|
const numProp = prop;
|
|
6449
6360
|
const isId = isIdProperty(propName, prop, collection);
|
|
6450
|
-
let baseType = numProp.validation?.integer || isId ? `integer(
|
|
6451
|
-
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(
|
|
6452
|
-
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(
|
|
6453
|
-
else baseType = `${numProp.columnType}(
|
|
6361
|
+
let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
|
|
6362
|
+
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
|
|
6363
|
+
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
|
|
6364
|
+
else baseType = `${numProp.columnType}(${quote$1(colName)})`;
|
|
6454
6365
|
if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
6455
6366
|
else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
6456
6367
|
columnDefinition = baseType;
|
|
@@ -6462,19 +6373,22 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6462
6373
|
break;
|
|
6463
6374
|
}
|
|
6464
6375
|
case "boolean":
|
|
6465
|
-
columnDefinition = `boolean(
|
|
6376
|
+
columnDefinition = `boolean(${quote$1(colName)})`;
|
|
6466
6377
|
break;
|
|
6467
6378
|
case "date": {
|
|
6468
6379
|
const dateProp = prop;
|
|
6469
|
-
if (dateProp.columnType === "date") columnDefinition = `date(
|
|
6470
|
-
else if (dateProp.columnType === "time") columnDefinition = `time(
|
|
6471
|
-
else columnDefinition = `timestamp(
|
|
6380
|
+
if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
|
|
6381
|
+
else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
|
|
6382
|
+
else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
|
|
6472
6383
|
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
|
|
6473
6384
|
break;
|
|
6474
6385
|
}
|
|
6475
6386
|
case "map":
|
|
6476
|
-
if (prop.columnType === "json") columnDefinition = `json(
|
|
6477
|
-
else columnDefinition = `jsonb(
|
|
6387
|
+
if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
6388
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6389
|
+
break;
|
|
6390
|
+
case "geopoint":
|
|
6391
|
+
columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6478
6392
|
break;
|
|
6479
6393
|
case "array": {
|
|
6480
6394
|
const arrayProp = prop;
|
|
@@ -6485,25 +6399,28 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6485
6399
|
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
6486
6400
|
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
6487
6401
|
}
|
|
6488
|
-
if (colType === "json") columnDefinition = `json(
|
|
6489
|
-
else if (colType === "text[]") columnDefinition = `text(
|
|
6490
|
-
else if (colType === "integer[]") columnDefinition = `integer(
|
|
6491
|
-
else if (colType === "boolean[]") columnDefinition = `boolean(
|
|
6492
|
-
else if (colType === "numeric[]") columnDefinition = `numeric(
|
|
6493
|
-
else columnDefinition = `jsonb(
|
|
6402
|
+
if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
6403
|
+
else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
|
|
6404
|
+
else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
|
|
6405
|
+
else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
|
|
6406
|
+
else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
|
|
6407
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6494
6408
|
break;
|
|
6495
6409
|
}
|
|
6496
|
-
case "vector":
|
|
6497
|
-
|
|
6410
|
+
case "vector": {
|
|
6411
|
+
const vp = prop;
|
|
6412
|
+
columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
|
|
6498
6413
|
break;
|
|
6414
|
+
}
|
|
6499
6415
|
case "binary":
|
|
6500
|
-
columnDefinition = `customType({ dataType() { return 'bytea'; } })(
|
|
6416
|
+
columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
|
|
6501
6417
|
break;
|
|
6502
6418
|
case "relation": {
|
|
6503
6419
|
const refProp = prop;
|
|
6504
6420
|
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
6505
6421
|
if (!relation || relation.kind !== "belongsTo") return null;
|
|
6506
|
-
|
|
6422
|
+
const fkFieldKey = fieldKeyForColumn(collection, relation.localKey);
|
|
6423
|
+
if (collection.properties[fkFieldKey] && propName !== fkFieldKey) return null;
|
|
6507
6424
|
let targetCollection;
|
|
6508
6425
|
try {
|
|
6509
6426
|
targetCollection = relation.target();
|
|
@@ -6519,30 +6436,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6519
6436
|
const required = prop.validation?.required;
|
|
6520
6437
|
const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
|
|
6521
6438
|
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
6522
|
-
let columnDef = `${baseColumn}.references(() => ${targetTableVar
|
|
6439
|
+
let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
|
|
6523
6440
|
if (required) columnDef += ".notNull()";
|
|
6524
|
-
return ` ${
|
|
6441
|
+
return ` ${propKey(fkFieldKey)}: ${columnDef}`;
|
|
6525
6442
|
}
|
|
6526
6443
|
case "reference": {
|
|
6527
6444
|
const refProp = prop;
|
|
6528
6445
|
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
|
|
6529
6446
|
if (!targetCollection) {
|
|
6530
|
-
columnDefinition = `text(
|
|
6447
|
+
columnDefinition = `text(${quote$1(colName)})`;
|
|
6531
6448
|
break;
|
|
6532
6449
|
}
|
|
6533
6450
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
6534
6451
|
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
6535
6452
|
const targetIdField = pkProp.name;
|
|
6536
|
-
const baseColumn = pkProp.type === "number" ? `integer(
|
|
6453
|
+
const baseColumn = pkProp.type === "number" ? `integer(${quote$1(colName)})` : pkProp.isUuid ? `uuid(${quote$1(colName)})` : `text(${quote$1(colName)})`;
|
|
6537
6454
|
const required = prop.validation?.required;
|
|
6538
|
-
|
|
6455
|
+
const refOptions = `{ onDelete: "${required ? "cascade" : "set null"}" }`;
|
|
6456
|
+
columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
|
|
6539
6457
|
if (required) columnDefinition += ".notNull()";
|
|
6540
|
-
return ` ${propName}: ${columnDefinition}`;
|
|
6458
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
6541
6459
|
}
|
|
6542
|
-
default:
|
|
6460
|
+
default: throw new Error(`No Postgres column mapping for property '${propName}' of type '${prop.type}' in collection '${collection.slug}'. Add a case to \`getDrizzleColumn\` (and to \`getSqlColumnType\`, which must agree).`);
|
|
6543
6461
|
}
|
|
6544
6462
|
if (prop.validation?.required) columnDefinition += ".notNull()";
|
|
6545
|
-
return ` ${propName}: ${columnDefinition}`;
|
|
6463
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
6546
6464
|
};
|
|
6547
6465
|
/**
|
|
6548
6466
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
@@ -6575,7 +6493,7 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
|
|
|
6575
6493
|
parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
|
|
6576
6494
|
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
6577
6495
|
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
6578
|
-
return ` pgPolicy(
|
|
6496
|
+
return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
|
|
6579
6497
|
};
|
|
6580
6498
|
/**
|
|
6581
6499
|
* Computes a deterministic shared relation name for Drizzle.
|
|
@@ -6610,7 +6528,7 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
|
6610
6528
|
return fallback;
|
|
6611
6529
|
};
|
|
6612
6530
|
var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
6613
|
-
const collections = relationalCollections(allCollections);
|
|
6531
|
+
const collections = sortCollectionsBySlug(relationalCollections(allCollections));
|
|
6614
6532
|
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
6615
6533
|
const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
|
|
6616
6534
|
const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
|
|
@@ -6661,7 +6579,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
|
6661
6579
|
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
6662
6580
|
const values = Array.isArray(prop.enum) ? prop.enum.map((v) => String(typeof v === "object" && v !== null && "id" in v ? v.id : v)) : Object.keys(prop.enum);
|
|
6663
6581
|
if (values.length > 0) {
|
|
6664
|
-
schemaContent += `export const ${enumVarName} = pgEnum(
|
|
6582
|
+
schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
|
|
6665
6583
|
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
6666
6584
|
}
|
|
6667
6585
|
}
|
|
@@ -6763,9 +6681,9 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
|
6763
6681
|
break;
|
|
6764
6682
|
}
|
|
6765
6683
|
} catch {}
|
|
6766
|
-
tableRelations.push(`
|
|
6684
|
+
tableRelations.push(` ${quote$1(relation.through.sourceColumn)}: one(${sourceTableVar}, {\n fields: [${member(tableVarName, relation.through.sourceColumn)}],\n references: [${member(sourceTableVar, sourceId)}],\n relationName: ${quote$1(owningRelationName)}\n })`);
|
|
6767
6685
|
const targetRelationName = inverseRelationName ? inverseRelationName : `${tableName}_${relation.through.targetColumn}`;
|
|
6768
|
-
tableRelations.push(`
|
|
6686
|
+
tableRelations.push(` ${quote$1(relation.through.targetColumn)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, relation.through.targetColumn)}],\n references: [${member(targetTableVar, targetId)}],\n relationName: ${quote$1(targetRelationName)}\n })`);
|
|
6769
6687
|
}
|
|
6770
6688
|
} else {
|
|
6771
6689
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
@@ -6780,7 +6698,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
|
6780
6698
|
switch (rel.kind) {
|
|
6781
6699
|
case "belongsTo": {
|
|
6782
6700
|
const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
|
|
6783
|
-
tableRelations.push(`
|
|
6701
|
+
tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6784
6702
|
break;
|
|
6785
6703
|
}
|
|
6786
6704
|
case "hasOne":
|
|
@@ -6811,7 +6729,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
|
6811
6729
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
6812
6730
|
const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
|
|
6813
6731
|
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
6814
|
-
tableRelations.push(`
|
|
6732
|
+
tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6815
6733
|
emittedRelationNames.add(deduplicationKey);
|
|
6816
6734
|
}
|
|
6817
6735
|
}
|
|
@@ -6831,6 +6749,44 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
|
6831
6749
|
return schemaContent;
|
|
6832
6750
|
};
|
|
6833
6751
|
//#endregion
|
|
6752
|
+
//#region src/cli-output.ts
|
|
6753
|
+
/**
|
|
6754
|
+
* Terminal output for the `rebase db|schema|doctor` commands.
|
|
6755
|
+
*
|
|
6756
|
+
* These commands used to write every line through `logger`, and that is a
|
|
6757
|
+
* category error with three separate consequences:
|
|
6758
|
+
*
|
|
6759
|
+
* - `logger` prefixes each line with its own level, so a box-drawn report
|
|
6760
|
+
* arrived as `ℹ️ [INFO] ┌─ ✗ Missing Column ───` and the frame no longer
|
|
6761
|
+
* lined up with anything;
|
|
6762
|
+
* - `logger` is gated by `LOG_LEVEL`, which ships in the scaffold's own
|
|
6763
|
+
* `.env.example` — a developer who quietened their dev server with
|
|
6764
|
+
* `LOG_LEVEL=warn` got a `rebase db push` that printed almost nothing and
|
|
6765
|
+
* still exited non-zero, indistinguishable from a crash;
|
|
6766
|
+
* - under `NODE_ENV=production` `logger` emits JSON, so the whole report
|
|
6767
|
+
* became log records with the chalk escape codes embedded in them.
|
|
6768
|
+
*
|
|
6769
|
+
* A CLI's report *is* its return value. It goes to the terminal unconditionally
|
|
6770
|
+
* and unadorned. `packages/cli` has always written its output this way; this is
|
|
6771
|
+
* the same three functions for the plugin CLI that `rebase` delegates to.
|
|
6772
|
+
*
|
|
6773
|
+
* `logger` still belongs in this package's *runtime* — a request handler has no
|
|
6774
|
+
* terminal and its lines want levels, timestamps and redaction. The rule is the
|
|
6775
|
+
* caller, not the severity: anything a developer reads because they typed a
|
|
6776
|
+
* command goes here, anything a server emits while running goes to `logger`.
|
|
6777
|
+
*
|
|
6778
|
+
* Errors and warnings go to stderr so `rebase db push > plan.txt` keeps the
|
|
6779
|
+
* diagnosis on the terminal where it is readable.
|
|
6780
|
+
*/
|
|
6781
|
+
/** One line of human-facing output on stdout. */
|
|
6782
|
+
var out = (line = "") => {
|
|
6783
|
+
console.log(line);
|
|
6784
|
+
};
|
|
6785
|
+
/** One line of human-facing error output on stderr. */
|
|
6786
|
+
var outError = (line = "") => {
|
|
6787
|
+
console.error(line);
|
|
6788
|
+
};
|
|
6789
|
+
//#endregion
|
|
6834
6790
|
//#region src/schema/generate-drizzle-schema.ts
|
|
6835
6791
|
var formatTerminalText = (text, options = {}) => {
|
|
6836
6792
|
let codes = "";
|
|
@@ -6858,7 +6814,7 @@ var formatTerminalText = (text, options = {}) => {
|
|
|
6858
6814
|
var runGeneration = async (collectionsFilePath, outputPath) => {
|
|
6859
6815
|
try {
|
|
6860
6816
|
if (!collectionsFilePath) {
|
|
6861
|
-
|
|
6817
|
+
outError("Error: No collections file path provided. Skipping schema generation.");
|
|
6862
6818
|
return;
|
|
6863
6819
|
}
|
|
6864
6820
|
let collections = await loadCollectionsFromDirectory(path.resolve(collectionsFilePath));
|
|
@@ -6869,18 +6825,18 @@ var runGeneration = async (collectionsFilePath, outputPath) => {
|
|
|
6869
6825
|
const outputDir = path.dirname(outputPath);
|
|
6870
6826
|
await promises.mkdir(outputDir, { recursive: true });
|
|
6871
6827
|
await promises.writeFile(outputPath, schemaContent);
|
|
6872
|
-
|
|
6828
|
+
out(`✅ Drizzle schema generated successfully at ${outputPath}`);
|
|
6873
6829
|
} else {
|
|
6874
|
-
|
|
6875
|
-
|
|
6830
|
+
out("✅ Drizzle schema generated successfully.");
|
|
6831
|
+
out(String(schemaContent));
|
|
6876
6832
|
}
|
|
6877
|
-
|
|
6833
|
+
out(`You can now run ${formatTerminalText("rebase db generate", {
|
|
6878
6834
|
bold: true,
|
|
6879
6835
|
backgroundColor: "blue",
|
|
6880
6836
|
textColor: "black"
|
|
6881
6837
|
})} to generate the SQL migration files.`);
|
|
6882
6838
|
} catch (error) {
|
|
6883
|
-
|
|
6839
|
+
outError(`Error generating schema: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
6884
6840
|
}
|
|
6885
6841
|
};
|
|
6886
6842
|
var main = () => {
|
|
@@ -6890,18 +6846,18 @@ var main = () => {
|
|
|
6890
6846
|
const outputPath = outputPathArg ? outputPathArg.split("=")[1] : void 0;
|
|
6891
6847
|
const watch = process.argv.includes("--watch");
|
|
6892
6848
|
if (!collectionsFilePath) {
|
|
6893
|
-
|
|
6849
|
+
out("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
|
|
6894
6850
|
return;
|
|
6895
6851
|
}
|
|
6896
6852
|
const resolvedPath = path.resolve(process.cwd(), collectionsFilePath);
|
|
6897
6853
|
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : void 0;
|
|
6898
6854
|
if (watch) {
|
|
6899
|
-
|
|
6855
|
+
out(`Watching for changes in ${resolvedPath}...`);
|
|
6900
6856
|
chokidar.watch(resolvedPath, {
|
|
6901
6857
|
persistent: true,
|
|
6902
6858
|
ignoreInitial: false
|
|
6903
6859
|
}).on("all", (event, filePath) => {
|
|
6904
|
-
|
|
6860
|
+
out(`[${event}] ${filePath}. Regenerating schema...`);
|
|
6905
6861
|
runGeneration(resolvedPath, resolvedOutputPath);
|
|
6906
6862
|
});
|
|
6907
6863
|
} else runGeneration(resolvedPath, resolvedOutputPath);
|
|
@@ -7085,7 +7041,7 @@ async function provisionTriggerCdc(run, tables) {
|
|
|
7085
7041
|
logger.warn(`⚠️ [CDC] Could not attach change-capture trigger to "${key}" — is the table migrated? Writes to it won't emit database-level events.`, { detail: reason });
|
|
7086
7042
|
}
|
|
7087
7043
|
}
|
|
7088
|
-
logger.
|
|
7044
|
+
logger.debug(`📡 [CDC] Trigger-based change capture provisioned on ${installed.length} table(s)` + (skipped.length ? ` (${skipped.length} skipped)` : "") + ".");
|
|
7089
7045
|
return {
|
|
7090
7046
|
installed,
|
|
7091
7047
|
skipped
|
|
@@ -7175,7 +7131,7 @@ var PgNotifyListener = class {
|
|
|
7175
7131
|
await client.connect();
|
|
7176
7132
|
await client.query(`LISTEN ${channel}`);
|
|
7177
7133
|
this.client = client;
|
|
7178
|
-
logger.
|
|
7134
|
+
logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
7179
7135
|
} catch (err) {
|
|
7180
7136
|
if (initial) throw err;
|
|
7181
7137
|
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
@@ -8007,6 +7963,12 @@ var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
|
8007
7963
|
var RealtimeService = class RealtimeService extends EventEmitter {
|
|
8008
7964
|
db;
|
|
8009
7965
|
registry;
|
|
7966
|
+
/**
|
|
7967
|
+
* Declares to the multi-engine router that channel frames can be handled
|
|
7968
|
+
* here. Read by `createRoutedRealtimeService`, which otherwise would have to
|
|
7969
|
+
* guess — and guessed "the default provider", whichever engine that is.
|
|
7970
|
+
*/
|
|
7971
|
+
supportsChannels = true;
|
|
8010
7972
|
clients = /* @__PURE__ */ new Map();
|
|
8011
7973
|
channels = /* @__PURE__ */ new Map();
|
|
8012
7974
|
presence = /* @__PURE__ */ new Map();
|
|
@@ -8053,6 +8015,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8053
8015
|
* so a hot channel logs the problem once rather than once per message.
|
|
8054
8016
|
*/
|
|
8055
8017
|
oversizedBroadcastWarned = /* @__PURE__ */ new Set();
|
|
8018
|
+
/**
|
|
8019
|
+
* Optional narrowing on top of the membership floor — see
|
|
8020
|
+
* {@link ChannelAuthorizer}. Unset by default, which leaves membership as
|
|
8021
|
+
* the whole of the rule.
|
|
8022
|
+
*/
|
|
8023
|
+
channelAuthorizer;
|
|
8024
|
+
/**
|
|
8025
|
+
* Whether a notification from another instance has ever arrived.
|
|
8026
|
+
*
|
|
8027
|
+
* The entity LISTEN handler sees a foreign `sid` on every cross-instance
|
|
8028
|
+
* change, which is proof that this deployment runs more than one pod — the
|
|
8029
|
+
* one fact needed to tell "the memory bus is fine here" from "broadcast and
|
|
8030
|
+
* presence silently reach a fraction of your users".
|
|
8031
|
+
*/
|
|
8032
|
+
foreignInstanceSeen = false;
|
|
8033
|
+
/** So the multi-pod memory-bus warning is emitted once, not once per join. */
|
|
8034
|
+
memoryBusWarned = false;
|
|
8056
8035
|
presenceInterval;
|
|
8057
8036
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
8058
8037
|
/** How often stale roster rows from other instances are reaped. */
|
|
@@ -8227,26 +8206,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8227
8206
|
await this.handleUnsubscribe(clientId, message.subscriptionId);
|
|
8228
8207
|
break;
|
|
8229
8208
|
case "join_channel":
|
|
8230
|
-
this.joinChannel(clientId, payload?.channel);
|
|
8231
|
-
break;
|
|
8232
8209
|
case "leave_channel":
|
|
8233
|
-
this.leaveChannel(clientId, payload?.channel);
|
|
8234
|
-
break;
|
|
8235
8210
|
case "broadcast":
|
|
8236
|
-
this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
|
|
8237
|
-
break;
|
|
8238
8211
|
case "channel_history":
|
|
8239
|
-
await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
|
|
8240
|
-
break;
|
|
8241
8212
|
case "presence_track":
|
|
8242
|
-
this.joinChannel(clientId, payload?.channel);
|
|
8243
|
-
this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
|
|
8244
|
-
break;
|
|
8245
8213
|
case "presence_untrack":
|
|
8246
|
-
this.removePresence(clientId, payload?.channel);
|
|
8247
|
-
break;
|
|
8248
8214
|
case "presence_state":
|
|
8249
|
-
this.
|
|
8215
|
+
await this.handleChannelMessage(clientId, message.type, payload, authContext);
|
|
8250
8216
|
break;
|
|
8251
8217
|
default: this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
|
|
8252
8218
|
}
|
|
@@ -8261,7 +8227,21 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8261
8227
|
this.sendError(clientId, msg, subscriptionId);
|
|
8262
8228
|
return;
|
|
8263
8229
|
}
|
|
8264
|
-
|
|
8230
|
+
if (request.vectorSearch) {
|
|
8231
|
+
const msg = "Realtime subscriptions do not support vector search: a subscription is re-run on every matching write, and nothing here computes distances. Use `.vectorSearch(...).find()` for the query, and subscribe without it if you need live updates.";
|
|
8232
|
+
logger.warn(`[RealtimeService] ${msg}`);
|
|
8233
|
+
this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
|
|
8234
|
+
return;
|
|
8235
|
+
}
|
|
8236
|
+
let boundedLimit;
|
|
8237
|
+
try {
|
|
8238
|
+
boundedLimit = resolveClientListLimit(request.limit);
|
|
8239
|
+
} catch (e) {
|
|
8240
|
+
if (!(e instanceof ListLimitError)) throw e;
|
|
8241
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
8242
|
+
this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
|
|
8243
|
+
return;
|
|
8244
|
+
}
|
|
8265
8245
|
this._subscriptions.set(subscriptionId, {
|
|
8266
8246
|
clientId,
|
|
8267
8247
|
type: "collection",
|
|
@@ -8365,7 +8345,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8365
8345
|
this.debugLog("🔔 [RealtimeService] notifyUpdate completed for path:", path);
|
|
8366
8346
|
}
|
|
8367
8347
|
/**
|
|
8368
|
-
* Notify subscriptions for a specific path
|
|
8348
|
+
* Notify subscriptions for a specific path.
|
|
8349
|
+
*
|
|
8350
|
+
* **A subscriber only ever receives rows re-read under its own scope.**
|
|
8351
|
+
* `row` is used to decide *that* something changed, never to say *what* —
|
|
8352
|
+
* every delivery below goes through a refetch that binds the subscription's
|
|
8353
|
+
* own auth context.
|
|
8354
|
+
*
|
|
8355
|
+
* It used to be conditional. The CDC path already did the right thing: it
|
|
8356
|
+
* discards the captured tuple and emits `{_rebase_invalidated: true}`, and
|
|
8357
|
+
* that marker selected the refetch branch. But the marker is produced in
|
|
8358
|
+
* exactly two places, and the *other* side of each branch here shipped the
|
|
8359
|
+
* row it was handed straight to the socket. Two of the three entry paths
|
|
8360
|
+
* took that side — every API mutation (`PostgresBackendDriver.save` passes
|
|
8361
|
+
* the row it just wrote, read under the **writer's** scope) and the legacy
|
|
8362
|
+
* cross-instance LISTEN handler (which re-reads on the owner connection,
|
|
8363
|
+
* bypassing RLS altogether). Path matching was the only filter applied: the
|
|
8364
|
+
* subscription's own `filter`/`logical` was never evaluated, and any
|
|
8365
|
+
* `afterRead` redaction was the writer's rather than the reader's.
|
|
8366
|
+
*
|
|
8367
|
+
* A single-row subscription was the sharpest case. `subscribe_one` on a row
|
|
8368
|
+
* RLS denies is accepted and answered `null`; the next update then pushed
|
|
8369
|
+
* the full row with no later correction. The collection variant was merely
|
|
8370
|
+
* papered over ~300 ms later by the debounced refetch — after the bytes had
|
|
8371
|
+
* already reached the browser.
|
|
8372
|
+
*
|
|
8373
|
+
* The same defect was found and fixed on the Mongo driver in `065e2b615`
|
|
8374
|
+
* (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
|
|
8375
|
+
* the Postgres half, stated as one rule rather than three patched branches.
|
|
8376
|
+
*
|
|
8377
|
+
* The cost is the instant row-level patch that used to precede the refetch:
|
|
8378
|
+
* cross-tab feedback now waits for the debounce. That is the price of not
|
|
8379
|
+
* being able to know, without asking the database as this subscriber,
|
|
8380
|
+
* whether this subscriber may see the row at all.
|
|
8369
8381
|
*/
|
|
8370
8382
|
async notifyPathUpdate(notifyPath, originalPath, id, row, _databaseId) {
|
|
8371
8383
|
this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
|
|
@@ -8379,12 +8391,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8379
8391
|
const webSocketSubscriptions = allSubscriptions.filter(([, sub]) => sub.clientId !== "driver" && this.clients.has(sub.clientId));
|
|
8380
8392
|
const driverSubscriptions = allSubscriptions.filter(([subscriptionId, sub]) => sub.clientId === "driver" && this.subscriptionCallbacks.has(subscriptionId));
|
|
8381
8393
|
for (const [subscriptionId, subscription] of webSocketSubscriptions) try {
|
|
8382
|
-
if (subscription.type === "single" && notifyPath === originalPath)
|
|
8383
|
-
else
|
|
8384
|
-
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
8385
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
8386
|
-
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
8387
|
-
}
|
|
8394
|
+
if (subscription.type === "single" && notifyPath === originalPath) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
8395
|
+
else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
8388
8396
|
} catch (error) {
|
|
8389
8397
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
8390
8398
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8392,8 +8400,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8392
8400
|
for (const [subscriptionId, subscription] of driverSubscriptions) try {
|
|
8393
8401
|
const callback = this.subscriptionCallbacks.get(subscriptionId);
|
|
8394
8402
|
if (!callback) continue;
|
|
8395
|
-
if (subscription.type === "single" && notifyPath === originalPath)
|
|
8396
|
-
else callback(row);
|
|
8403
|
+
if (subscription.type === "single" && notifyPath === originalPath) this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
|
|
8397
8404
|
else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
|
|
8398
8405
|
} catch (error) {
|
|
8399
8406
|
logger.error(`❌ [RealtimeService] Error processing DataDriver subscription ${subscriptionId}`, { error });
|
|
@@ -8457,6 +8464,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8457
8464
|
let fetchedEntities;
|
|
8458
8465
|
if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8459
8466
|
filter: collectionRequest.filter,
|
|
8467
|
+
logical: collectionRequest.logical,
|
|
8460
8468
|
orderBy: collectionRequest.orderBy,
|
|
8461
8469
|
order: collectionRequest.order,
|
|
8462
8470
|
limit: collectionRequest.limit,
|
|
@@ -8518,13 +8526,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8518
8526
|
}
|
|
8519
8527
|
if (collectionRequest.searchString) return await this.dataService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8520
8528
|
filter: collectionRequest.filter,
|
|
8529
|
+
logical: collectionRequest.logical,
|
|
8521
8530
|
orderBy: collectionRequest.orderBy,
|
|
8522
8531
|
order: collectionRequest.order,
|
|
8523
8532
|
limit: collectionRequest.limit,
|
|
8524
|
-
databaseId: collectionRequest.databaseId
|
|
8533
|
+
databaseId: collectionRequest.databaseId,
|
|
8534
|
+
searchExplain: collectionRequest.searchExplain
|
|
8525
8535
|
});
|
|
8526
8536
|
return await this.dataService.fetchCollection(notifyPath, {
|
|
8527
8537
|
filter: collectionRequest.filter,
|
|
8538
|
+
logical: collectionRequest.logical,
|
|
8528
8539
|
orderBy: collectionRequest.orderBy,
|
|
8529
8540
|
order: collectionRequest.order,
|
|
8530
8541
|
limit: collectionRequest.limit,
|
|
@@ -8654,16 +8665,6 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8654
8665
|
* columns and no address. The SDK holds no collection config to derive one
|
|
8655
8666
|
* from, so this is the only place the mapping can come from.
|
|
8656
8667
|
*/
|
|
8657
|
-
sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
|
|
8658
|
-
const message = {
|
|
8659
|
-
type: "collection_patch",
|
|
8660
|
-
subscriptionId,
|
|
8661
|
-
id,
|
|
8662
|
-
row,
|
|
8663
|
-
pks: this.primaryKeysForPath(notifyPath)
|
|
8664
|
-
};
|
|
8665
|
-
this.sendMessage(clientId, message);
|
|
8666
|
-
}
|
|
8667
8668
|
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
8668
8669
|
primaryKeysForPath(path) {
|
|
8669
8670
|
try {
|
|
@@ -8708,12 +8709,148 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8708
8709
|
}
|
|
8709
8710
|
return parentPaths;
|
|
8710
8711
|
}
|
|
8712
|
+
/**
|
|
8713
|
+
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
8714
|
+
*
|
|
8715
|
+
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
8716
|
+
* be built on, kept deliberately separate from the membership floor so the
|
|
8717
|
+
* floor holds whether or not anyone uses it.
|
|
8718
|
+
*/
|
|
8719
|
+
setChannelAuthorizer(authorizer) {
|
|
8720
|
+
this.channelAuthorizer = authorizer;
|
|
8721
|
+
}
|
|
8722
|
+
/** Which action each channel frame is asking to perform. */
|
|
8723
|
+
static CHANNEL_ACTIONS = {
|
|
8724
|
+
join_channel: "join",
|
|
8725
|
+
broadcast: "broadcast",
|
|
8726
|
+
channel_history: "history",
|
|
8727
|
+
presence_track: "join",
|
|
8728
|
+
presence_state: "presence"
|
|
8729
|
+
};
|
|
8730
|
+
/**
|
|
8731
|
+
* The one door every channel frame comes through.
|
|
8732
|
+
*
|
|
8733
|
+
* Returns synchronously — and so dispatches synchronously — unless an
|
|
8734
|
+
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
8735
|
+
* `presence_state` and `channel_history` back to back on connect, and the
|
|
8736
|
+
* socket's message handler processes each frame up to its first `await`,
|
|
8737
|
+
* so a gate that always yielded would let the reads overtake the join that
|
|
8738
|
+
* is about to authorize them.
|
|
8739
|
+
*/
|
|
8740
|
+
handleChannelMessage(clientId, type, payload, authContext) {
|
|
8741
|
+
const channel = payload?.channel;
|
|
8742
|
+
if (type === "leave_channel") {
|
|
8743
|
+
this.leaveChannel(clientId, channel);
|
|
8744
|
+
return;
|
|
8745
|
+
}
|
|
8746
|
+
if (type === "presence_untrack") {
|
|
8747
|
+
this.removePresence(clientId, channel);
|
|
8748
|
+
return;
|
|
8749
|
+
}
|
|
8750
|
+
const action = RealtimeService.CHANNEL_ACTIONS[type];
|
|
8751
|
+
const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
|
|
8752
|
+
if (allowed === false) return;
|
|
8753
|
+
if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
8754
|
+
return allowed.then((ok) => {
|
|
8755
|
+
if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
8756
|
+
});
|
|
8757
|
+
}
|
|
8758
|
+
/** Perform an already-authorized channel frame. */
|
|
8759
|
+
dispatchChannelMessage(clientId, type, channel, payload) {
|
|
8760
|
+
switch (type) {
|
|
8761
|
+
case "join_channel":
|
|
8762
|
+
this.joinChannel(clientId, channel);
|
|
8763
|
+
return;
|
|
8764
|
+
case "broadcast":
|
|
8765
|
+
this.broadcastToChannel(clientId, channel, payload?.event, payload?.payload);
|
|
8766
|
+
return;
|
|
8767
|
+
case "channel_history": return this.handleChannelHistoryRequest(clientId, channel, payload?.sinceSeq, payload?.limit);
|
|
8768
|
+
case "presence_track":
|
|
8769
|
+
this.joinChannel(clientId, channel);
|
|
8770
|
+
this.trackPresence(clientId, channel, payload?.state ?? {});
|
|
8771
|
+
return;
|
|
8772
|
+
case "presence_state":
|
|
8773
|
+
this.sendPresenceState(clientId, channel);
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
/**
|
|
8778
|
+
* Decide whether a client may perform an action on a channel.
|
|
8779
|
+
*
|
|
8780
|
+
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
8781
|
+
* its retained history and broadcasting into it all require that this client
|
|
8782
|
+
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
8783
|
+
* the channel — but it is not the bar that was there before, which was none
|
|
8784
|
+
* at all: `channel_history` and `presence_state` answered any socket about
|
|
8785
|
+
* any channel, and a broadcast fanned out to members the sender had never
|
|
8786
|
+
* joined. Two internal tables (`rebase.channel_presence`,
|
|
8787
|
+
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
8788
|
+
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
8789
|
+
*
|
|
8790
|
+
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
8791
|
+
* this method is where it is consulted, and the only place.
|
|
8792
|
+
*/
|
|
8793
|
+
authorizeChannelAction(clientId, channel, action, authContext) {
|
|
8794
|
+
if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
|
|
8795
|
+
this.denyChannelAction(clientId, channel, action, "not a member of the channel");
|
|
8796
|
+
return false;
|
|
8797
|
+
}
|
|
8798
|
+
const authorizer = this.channelAuthorizer;
|
|
8799
|
+
if (!authorizer) return true;
|
|
8800
|
+
let verdict;
|
|
8801
|
+
try {
|
|
8802
|
+
verdict = authorizer({
|
|
8803
|
+
channel,
|
|
8804
|
+
action,
|
|
8805
|
+
clientId,
|
|
8806
|
+
user: authContext
|
|
8807
|
+
});
|
|
8808
|
+
} catch (error) {
|
|
8809
|
+
logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
|
|
8810
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
8811
|
+
return false;
|
|
8812
|
+
}
|
|
8813
|
+
if (typeof verdict === "boolean") {
|
|
8814
|
+
if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
8815
|
+
return verdict;
|
|
8816
|
+
}
|
|
8817
|
+
return verdict.then((ok) => {
|
|
8818
|
+
if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
8819
|
+
return ok;
|
|
8820
|
+
}, (error) => {
|
|
8821
|
+
logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
|
|
8822
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
8823
|
+
return false;
|
|
8824
|
+
});
|
|
8825
|
+
}
|
|
8826
|
+
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
8827
|
+
denyChannelAction(clientId, channel, action, reason) {
|
|
8828
|
+
this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
|
|
8829
|
+
this.sendError(clientId, `Refused ${action} on channel "${channel}": ${reason}`, void 0, "CHANNEL_FORBIDDEN");
|
|
8830
|
+
}
|
|
8711
8831
|
/** Join a broadcast channel */
|
|
8712
8832
|
joinChannel(clientId, channel) {
|
|
8713
8833
|
if (!this.channels.has(channel)) this.channels.set(channel, /* @__PURE__ */ new Set());
|
|
8714
8834
|
this.channels.get(channel).add(clientId);
|
|
8835
|
+
this.warnIfMemoryBusOnMultiplePods();
|
|
8715
8836
|
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
8716
8837
|
}
|
|
8838
|
+
/**
|
|
8839
|
+
* Say something the first time channels are used on a deployment that is
|
|
8840
|
+
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
8841
|
+
*
|
|
8842
|
+
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
8843
|
+
* the case where the operator already knew a bus mattered. The common
|
|
8844
|
+
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
8845
|
+
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
8846
|
+
* fraction of the room. The evidence is already in the process, so use it.
|
|
8847
|
+
*/
|
|
8848
|
+
warnIfMemoryBusOnMultiplePods() {
|
|
8849
|
+
if (this.memoryBusWarned) return;
|
|
8850
|
+
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
8851
|
+
this.memoryBusWarned = true;
|
|
8852
|
+
logger.warn("⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another instance have been seen — this deployment runs more than one process. Broadcast and presence reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) to make channels cross-instance.");
|
|
8853
|
+
}
|
|
8717
8854
|
/** Leave a broadcast channel */
|
|
8718
8855
|
leaveChannel(clientId, channel) {
|
|
8719
8856
|
const members = this.channels.get(channel);
|
|
@@ -9208,7 +9345,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9208
9345
|
throw err;
|
|
9209
9346
|
}
|
|
9210
9347
|
this.cdcActive = true;
|
|
9211
|
-
logger.
|
|
9348
|
+
logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
|
|
9212
9349
|
}
|
|
9213
9350
|
/** Stop the CDC listener and clear its state. */
|
|
9214
9351
|
async stopCdc() {
|
|
@@ -9391,6 +9528,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9391
9528
|
try {
|
|
9392
9529
|
const { sid, p, eid, db } = JSON.parse(msg.payload);
|
|
9393
9530
|
if (sid === this.instanceId) return;
|
|
9531
|
+
this.foreignInstanceSeen = true;
|
|
9394
9532
|
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
|
|
9395
9533
|
let refetchedRow = null;
|
|
9396
9534
|
try {
|
|
@@ -9581,7 +9719,7 @@ function createBackupCron(config) {
|
|
|
9581
9719
|
enabled: config.enabled ?? true,
|
|
9582
9720
|
timeoutSeconds: 3600,
|
|
9583
9721
|
async handler({ log }) {
|
|
9584
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
9722
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BH0Dzo_h.js").then((n) => n.r);
|
|
9585
9723
|
const { destination } = config;
|
|
9586
9724
|
if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
|
|
9587
9725
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -9906,7 +10044,7 @@ function buildCollectionRegistry(schema) {
|
|
|
9906
10044
|
const registry = new PostgresCollectionRegistry();
|
|
9907
10045
|
if (schema.collections) {
|
|
9908
10046
|
registry.registerMultiple(schema.collections);
|
|
9909
|
-
logger.
|
|
10047
|
+
logger.debug(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
9910
10048
|
}
|
|
9911
10049
|
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
9912
10050
|
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
@@ -10081,7 +10219,7 @@ async function probeAuthSchema(db, authSchema) {
|
|
|
10081
10219
|
* When omitted, a default `rebase.users` table is created.
|
|
10082
10220
|
*/
|
|
10083
10221
|
async function ensureAuthTablesExist(db, collection) {
|
|
10084
|
-
logger.
|
|
10222
|
+
logger.debug("🔍 Checking auth tables...");
|
|
10085
10223
|
await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
|
|
10086
10224
|
try {
|
|
10087
10225
|
let usersTableName = "\"rebase\".\"users\"";
|
|
@@ -10112,7 +10250,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10112
10250
|
if (dbType === "UUID") userIdType = "UUID";
|
|
10113
10251
|
else if (dbType === "INTEGER" || dbType === "SMALLINT" || dbType === "BIGINT") userIdType = "INTEGER";
|
|
10114
10252
|
else userIdType = "TEXT";
|
|
10115
|
-
logger.
|
|
10253
|
+
logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
|
|
10116
10254
|
}
|
|
10117
10255
|
} catch (err) {
|
|
10118
10256
|
logger.warn(`⚠️ Failed to introspect ${usersTableName}.id type from database, falling back to config type: ${userIdType}`, { error: err });
|
|
@@ -10399,7 +10537,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10399
10537
|
FROM information_schema.tables
|
|
10400
10538
|
WHERE table_name = 'refresh_tokens'
|
|
10401
10539
|
`)).rows;
|
|
10402
|
-
logger.
|
|
10540
|
+
logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
|
|
10403
10541
|
for (const { table_schema } of found) {
|
|
10404
10542
|
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
10405
10543
|
try {
|
|
@@ -10407,6 +10545,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10407
10545
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
10408
10546
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
10409
10547
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
10548
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
|
|
10410
10549
|
await db.execute(sql`
|
|
10411
10550
|
UPDATE ${sql.raw(qualified)}
|
|
10412
10551
|
SET session_id = gen_random_uuid()::text
|
|
@@ -10426,7 +10565,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10426
10565
|
ON ${sql.raw(qualified)}(session_id)
|
|
10427
10566
|
`);
|
|
10428
10567
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
10429
|
-
logger.
|
|
10568
|
+
logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
|
|
10430
10569
|
} catch (perTableError) {
|
|
10431
10570
|
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
10432
10571
|
}
|
|
@@ -10469,6 +10608,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10469
10608
|
secret_encrypted TEXT NOT NULL,
|
|
10470
10609
|
friendly_name TEXT,
|
|
10471
10610
|
verified BOOLEAN DEFAULT FALSE,
|
|
10611
|
+
last_used_counter BIGINT,
|
|
10472
10612
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
10473
10613
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
10474
10614
|
)
|
|
@@ -10484,6 +10624,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10484
10624
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
10485
10625
|
verified_at TIMESTAMP WITH TIME ZONE,
|
|
10486
10626
|
ip_address TEXT,
|
|
10627
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
10487
10628
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
10488
10629
|
)
|
|
10489
10630
|
`);
|
|
@@ -10491,6 +10632,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10491
10632
|
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
|
|
10492
10633
|
ON ${sql.raw(mfaChallengesTableName)}(factor_id)
|
|
10493
10634
|
`);
|
|
10635
|
+
try {
|
|
10636
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
|
|
10637
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
|
|
10638
|
+
} catch (mfaMigrationError) {
|
|
10639
|
+
logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
|
|
10640
|
+
}
|
|
10494
10641
|
await db.execute(sql`
|
|
10495
10642
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
10496
10643
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
@@ -10538,7 +10685,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10538
10685
|
await revokeInternalTableAccess(async (text) => {
|
|
10539
10686
|
await db.execute(sql.raw(text));
|
|
10540
10687
|
}, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
|
|
10541
|
-
logger.
|
|
10688
|
+
logger.debug("✅ Auth tables ready");
|
|
10542
10689
|
} catch (error) {
|
|
10543
10690
|
if (error instanceof AuthSchemaVersionError) throw error;
|
|
10544
10691
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -10811,7 +10958,7 @@ var UserService = class {
|
|
|
10811
10958
|
const conditions = [];
|
|
10812
10959
|
if (roleId) conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
|
|
10813
10960
|
if (search) {
|
|
10814
|
-
const pattern = `%${search}%`;
|
|
10961
|
+
const pattern = `%${escapeLikePattern(search)}%`;
|
|
10815
10962
|
conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
|
|
10816
10963
|
}
|
|
10817
10964
|
const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
|
|
@@ -10987,7 +11134,8 @@ var RefreshTokenService = class {
|
|
|
10987
11134
|
"sessionId",
|
|
10988
11135
|
"rotatedAt",
|
|
10989
11136
|
"revoked",
|
|
10990
|
-
"sessionStartedAt"
|
|
11137
|
+
"sessionStartedAt",
|
|
11138
|
+
"aal"
|
|
10991
11139
|
]) if (this.has(optional)) selection[optional] = this.col(optional);
|
|
10992
11140
|
return selection;
|
|
10993
11141
|
}
|
|
@@ -11001,6 +11149,7 @@ var RefreshTokenService = class {
|
|
|
11001
11149
|
};
|
|
11002
11150
|
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
11003
11151
|
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
11152
|
+
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
11004
11153
|
await this.db.insert(this.refreshTokensTable).values(values);
|
|
11005
11154
|
}
|
|
11006
11155
|
async findByHash(tokenHash) {
|
|
@@ -11478,6 +11627,9 @@ var PostgresAuthRepository = class {
|
|
|
11478
11627
|
async verifyMfaFactor(factorId) {
|
|
11479
11628
|
return this.getMfaService().verifyMfaFactor(factorId);
|
|
11480
11629
|
}
|
|
11630
|
+
async updateMfaFactorSecret(factorId, secretEncrypted) {
|
|
11631
|
+
return this.getMfaService().updateMfaFactorSecret(factorId, secretEncrypted);
|
|
11632
|
+
}
|
|
11481
11633
|
async deleteMfaFactor(factorId, uid) {
|
|
11482
11634
|
return this.getMfaService().deleteMfaFactor(factorId, uid);
|
|
11483
11635
|
}
|
|
@@ -11505,6 +11657,12 @@ var PostgresAuthRepository = class {
|
|
|
11505
11657
|
async hasVerifiedMfaFactors(uid) {
|
|
11506
11658
|
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
11507
11659
|
}
|
|
11660
|
+
async claimMfaFactorCounter(factorId, counter) {
|
|
11661
|
+
return this.getMfaService().claimMfaFactorCounter(factorId, counter);
|
|
11662
|
+
}
|
|
11663
|
+
async recordMfaChallengeAttempt(challengeId) {
|
|
11664
|
+
return this.getMfaService().recordMfaChallengeAttempt(challengeId);
|
|
11665
|
+
}
|
|
11508
11666
|
};
|
|
11509
11667
|
/**
|
|
11510
11668
|
* PostgreSQL implementation of MfaRepository.
|
|
@@ -11557,7 +11715,7 @@ var MfaService = class {
|
|
|
11557
11715
|
async getMfaFactorById(factorId) {
|
|
11558
11716
|
const tableName = this.qualify("mfa_factors");
|
|
11559
11717
|
const result = await this.db.execute(sql`
|
|
11560
|
-
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
11718
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
|
|
11561
11719
|
FROM ${sql.raw(tableName)}
|
|
11562
11720
|
WHERE id = ${factorId}
|
|
11563
11721
|
`);
|
|
@@ -11570,10 +11728,29 @@ var MfaService = class {
|
|
|
11570
11728
|
secretEncrypted: row.secret_encrypted,
|
|
11571
11729
|
friendlyName: row.friendly_name ?? void 0,
|
|
11572
11730
|
verified: row.verified,
|
|
11731
|
+
lastUsedCounter: row.last_used_counter === null || row.last_used_counter === void 0 ? null : Number(row.last_used_counter),
|
|
11573
11732
|
createdAt: new Date(row.created_at),
|
|
11574
11733
|
updatedAt: new Date(row.updated_at)
|
|
11575
11734
|
};
|
|
11576
11735
|
}
|
|
11736
|
+
/**
|
|
11737
|
+
* Spend a TOTP time step, once and only once.
|
|
11738
|
+
*
|
|
11739
|
+
* One statement: the `WHERE` is the check, the `UPDATE` is the act, and
|
|
11740
|
+
* `RETURNING` reports which of two concurrent requests carrying the same
|
|
11741
|
+
* six digits won. Reading the counter and then writing it would let both
|
|
11742
|
+
* pass — the exact replay this closes.
|
|
11743
|
+
*/
|
|
11744
|
+
async claimMfaFactorCounter(factorId, counter) {
|
|
11745
|
+
const tableName = this.qualify("mfa_factors");
|
|
11746
|
+
return (await this.db.execute(sql`
|
|
11747
|
+
UPDATE ${sql.raw(tableName)}
|
|
11748
|
+
SET last_used_counter = ${counter}, updated_at = NOW()
|
|
11749
|
+
WHERE id = ${factorId}
|
|
11750
|
+
AND (last_used_counter IS NULL OR last_used_counter < ${counter})
|
|
11751
|
+
RETURNING id
|
|
11752
|
+
`)).rows.length > 0;
|
|
11753
|
+
}
|
|
11577
11754
|
async verifyMfaFactor(factorId) {
|
|
11578
11755
|
const tableName = this.qualify("mfa_factors");
|
|
11579
11756
|
await this.db.execute(sql`
|
|
@@ -11582,6 +11759,14 @@ var MfaService = class {
|
|
|
11582
11759
|
WHERE id = ${factorId}
|
|
11583
11760
|
`);
|
|
11584
11761
|
}
|
|
11762
|
+
async updateMfaFactorSecret(factorId, secretEncrypted) {
|
|
11763
|
+
const tableName = this.qualify("mfa_factors");
|
|
11764
|
+
await this.db.execute(sql`
|
|
11765
|
+
UPDATE ${sql.raw(tableName)}
|
|
11766
|
+
SET secret_encrypted = ${secretEncrypted}, updated_at = NOW()
|
|
11767
|
+
WHERE id = ${factorId}
|
|
11768
|
+
`);
|
|
11769
|
+
}
|
|
11585
11770
|
async deleteMfaFactor(factorId, uid) {
|
|
11586
11771
|
const tableName = this.qualify("mfa_factors");
|
|
11587
11772
|
await this.db.execute(sql`
|
|
@@ -11608,7 +11793,7 @@ var MfaService = class {
|
|
|
11608
11793
|
async getMfaChallengeById(challengeId) {
|
|
11609
11794
|
const tableName = this.qualify("mfa_challenges");
|
|
11610
11795
|
const result = await this.db.execute(sql`
|
|
11611
|
-
SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
|
|
11796
|
+
SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
|
|
11612
11797
|
FROM ${sql.raw(tableName)}
|
|
11613
11798
|
WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
|
|
11614
11799
|
`);
|
|
@@ -11619,9 +11804,28 @@ var MfaService = class {
|
|
|
11619
11804
|
factorId: row.factor_id,
|
|
11620
11805
|
createdAt: new Date(row.created_at),
|
|
11621
11806
|
verifiedAt: row.verified_at ? new Date(row.verified_at) : void 0,
|
|
11622
|
-
ipAddress: row.ip_address ?? void 0
|
|
11807
|
+
ipAddress: row.ip_address ?? void 0,
|
|
11808
|
+
attempts: Number(row.attempts ?? 0)
|
|
11623
11809
|
};
|
|
11624
11810
|
}
|
|
11811
|
+
/**
|
|
11812
|
+
* Count one failed guess against a challenge and report the new total.
|
|
11813
|
+
*
|
|
11814
|
+
* Incremented in the database rather than in the route so that guesses
|
|
11815
|
+
* arriving in parallel — the shape any real brute-force takes — cannot
|
|
11816
|
+
* share a single increment.
|
|
11817
|
+
*/
|
|
11818
|
+
async recordMfaChallengeAttempt(challengeId) {
|
|
11819
|
+
const tableName = this.qualify("mfa_challenges");
|
|
11820
|
+
const result = await this.db.execute(sql`
|
|
11821
|
+
UPDATE ${sql.raw(tableName)}
|
|
11822
|
+
SET attempts = attempts + 1
|
|
11823
|
+
WHERE id = ${challengeId}
|
|
11824
|
+
RETURNING attempts
|
|
11825
|
+
`);
|
|
11826
|
+
if (result.rows.length === 0) return 0;
|
|
11827
|
+
return Number(result.rows[0].attempts);
|
|
11828
|
+
}
|
|
11625
11829
|
async verifyMfaChallenge(challengeId) {
|
|
11626
11830
|
const tableName = this.qualify("mfa_challenges");
|
|
11627
11831
|
await this.db.execute(sql`
|
|
@@ -11842,7 +12046,7 @@ function findChangedFields(oldValues, newValues) {
|
|
|
11842
12046
|
* pattern as `ensureAuthTablesExist`.
|
|
11843
12047
|
*/
|
|
11844
12048
|
async function ensureHistoryTableExists(db) {
|
|
11845
|
-
logger.
|
|
12049
|
+
logger.debug("🔍 Checking row history table...");
|
|
11846
12050
|
try {
|
|
11847
12051
|
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
11848
12052
|
await db.execute(sql`
|
|
@@ -11867,7 +12071,7 @@ async function ensureHistoryTableExists(db) {
|
|
|
11867
12071
|
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
11868
12072
|
`);
|
|
11869
12073
|
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
11870
|
-
logger.
|
|
12074
|
+
logger.debug("✅ Entity history table ready");
|
|
11871
12075
|
} catch (error) {
|
|
11872
12076
|
logger.error("❌ Failed to create row history table", { error });
|
|
11873
12077
|
logger.warn("⚠️ Continuing without creating history table.");
|
|
@@ -12181,6 +12385,7 @@ function idKindFor(col, propType) {
|
|
|
12181
12385
|
}
|
|
12182
12386
|
function buildProperties(meta, enumMap) {
|
|
12183
12387
|
const properties = {};
|
|
12388
|
+
const takenKeys = /* @__PURE__ */ new Set();
|
|
12184
12389
|
for (const col of meta.columns) {
|
|
12185
12390
|
const isPk = meta.pks.includes(col.column_name);
|
|
12186
12391
|
if (meta.fks.some((fk) => fk.column_name === col.column_name) && !isPk) continue;
|
|
@@ -12193,7 +12398,8 @@ function buildProperties(meta, enumMap) {
|
|
|
12193
12398
|
columnName: col.column_name,
|
|
12194
12399
|
type: propType
|
|
12195
12400
|
};
|
|
12196
|
-
const key = col.column_name;
|
|
12401
|
+
const key = firstFreeKey([toWireKey(col.column_name), col.column_name], takenKeys);
|
|
12402
|
+
takenKeys.add(key);
|
|
12197
12403
|
if (isPk) property.isId = idKindFor(col, propType);
|
|
12198
12404
|
else if (col.is_nullable === "NO" && col.column_default === null) property.validation = { required: true };
|
|
12199
12405
|
if (isEnum && enumValues) property.enum = enumValues.map((value) => ({
|
|
@@ -12230,7 +12436,7 @@ function buildRelations(meta, slugByTable, collectionBySlug) {
|
|
|
12230
12436
|
for (const fk of meta.fks) {
|
|
12231
12437
|
const targetSlug = slugByTable.get(fk.foreign_table_name);
|
|
12232
12438
|
if (!targetSlug) continue;
|
|
12233
|
-
let key = fk.column_name.replace(/_id$/, "");
|
|
12439
|
+
let key = toWireKey(fk.column_name.replace(/_id$/, ""));
|
|
12234
12440
|
if (meta.pks.includes(fk.column_name) && key === fk.column_name) key = fk.foreign_table_name;
|
|
12235
12441
|
relations[key] = {
|
|
12236
12442
|
name: humanize(key),
|
|
@@ -12487,6 +12693,43 @@ function resolveDriftCheckName(col, registeredTableNames) {
|
|
|
12487
12693
|
return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
|
|
12488
12694
|
}
|
|
12489
12695
|
/**
|
|
12696
|
+
* Is this the local database `rebase init` scaffolds — i.e. the one case where
|
|
12697
|
+
* "you are connected as a superuser" is not news?
|
|
12698
|
+
*
|
|
12699
|
+
* The scaffold's own `docker-compose.yml` sets `POSTGRES_USER: rebase_app`,
|
|
12700
|
+
* which makes that role the cluster superuser, so the superuser advisory below
|
|
12701
|
+
* was the only WARN a brand-new project ever saw and it was about a decision
|
|
12702
|
+
* the tool had made for the developer.
|
|
12703
|
+
*
|
|
12704
|
+
* Of the two available fixes — provision a non-superuser table-owner role in
|
|
12705
|
+
* the scaffold, or recognise the local shape and stay quiet — this is the
|
|
12706
|
+
* second, because the first breaks the scaffold it is meant to improve: a
|
|
12707
|
+
* non-superuser owner cannot `CREATE EXTENSION` (search collections need
|
|
12708
|
+
* `pg_trgm`/`unaccent`, applied by `rebase db push` and again by the boot
|
|
12709
|
+
* schema-ensure), so the very first `pnpm run db:push` on a scaffolded project
|
|
12710
|
+
* with a search block would fail. Trading a working first run for a quieter log
|
|
12711
|
+
* line is the wrong trade.
|
|
12712
|
+
*
|
|
12713
|
+
* The condition is deliberately narrow — a *non-production* process talking to
|
|
12714
|
+
* a database on the loopback interface. A genuine production superuser
|
|
12715
|
+
* connection still warns, and so does a non-production process pointed at a
|
|
12716
|
+
* remote database (the usual "my dev machine writes to staging" mistake, where
|
|
12717
|
+
* the advisory is exactly right). NODE_ENV alone would not do: the scaffold
|
|
12718
|
+
* ships `NODE_ENV=development` and some deployments inherit it.
|
|
12719
|
+
*/
|
|
12720
|
+
function isScaffoldedLocalDatabase(connectionString) {
|
|
12721
|
+
if (process.env.NODE_ENV === "production") return false;
|
|
12722
|
+
if (!connectionString) return false;
|
|
12723
|
+
let host;
|
|
12724
|
+
try {
|
|
12725
|
+
host = new URL(connectionString).hostname;
|
|
12726
|
+
} catch {
|
|
12727
|
+
return false;
|
|
12728
|
+
}
|
|
12729
|
+
const bare = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
12730
|
+
return bare === "localhost" || bare === "::1" || bare === "0.0.0.0" || bare === "" || /^127\./.test(bare) || bare.endsWith(".localhost");
|
|
12731
|
+
}
|
|
12732
|
+
/**
|
|
12490
12733
|
* Default PostgreSQL bootstrapper.
|
|
12491
12734
|
*
|
|
12492
12735
|
* Use it to register Postgres with `initializeRebaseBackend()`:
|
|
@@ -12599,7 +12842,11 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12599
12842
|
driver.rlsUserRole = REBASE_USER_ROLE;
|
|
12600
12843
|
realtimeService.rlsUserRole = REBASE_USER_ROLE;
|
|
12601
12844
|
logger.info(`🔐 RLS enforcement active: authenticated requests run as "${REBASE_USER_ROLE}" (connection "${posture.role}" bypasses RLS: ${posture.superuser ? "superuser" : posture.bypassRLS ? "BYPASSRLS" : "table owner"})`);
|
|
12602
|
-
if (posture.superuser || posture.bypassRLS)
|
|
12845
|
+
if (posture.superuser || posture.bypassRLS) {
|
|
12846
|
+
const message = `The database connection runs as ${posture.superuser ? "a superuser" : "a BYPASSRLS role"} ("${posture.role}"). User requests are isolated via SET LOCAL ROLE, but connect as a non-superuser table-owner role in production so the server/owner context is least-privilege.`;
|
|
12847
|
+
if (isScaffoldedLocalDatabase(pgConfig.connectionString)) logger.debug(`🔐 ${message}`);
|
|
12848
|
+
else logger.warn(`⚠️ ${message}`);
|
|
12849
|
+
}
|
|
12603
12850
|
} else logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
|
|
12604
12851
|
await validatePolicyPgRoles(runSql, registry.getCollections(), driver.rlsUserRole ?? posture.role);
|
|
12605
12852
|
warnOnAnonymousGrants(registry.getCollections());
|
|
@@ -12819,12 +13066,12 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12819
13066
|
*/
|
|
12820
13067
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
12821
13068
|
const internals = driverResult.internals;
|
|
12822
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
13069
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-CbvaGuVn.js");
|
|
12823
13070
|
const plan = await ensureCollectionTables({ async query(text) {
|
|
12824
13071
|
const result = await internals.db.execute(sql.raw(text));
|
|
12825
13072
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
12826
13073
|
} }, collections, log);
|
|
12827
|
-
for (const failure of plan.failures) logger.warn(`🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
|
|
13074
|
+
for (const failure of plan.failures) logger.warn(failure.kind === "comment-column" ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, but a later change to the \`search\` block will not be detected: ${failure.error}` : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
|
|
12828
13075
|
return { applied: plan.actions.length - plan.failures.length };
|
|
12829
13076
|
},
|
|
12830
13077
|
/**
|
|
@@ -12844,15 +13091,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12844
13091
|
*/
|
|
12845
13092
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
12846
13093
|
const internals = driverResult.internals;
|
|
12847
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
13094
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-8vuu-n4r.js");
|
|
12848
13095
|
const outcome = await ensureCollectionPolicies({ async query(text) {
|
|
12849
13096
|
const result = await internals.db.execute(sql.raw(text));
|
|
12850
13097
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
12851
13098
|
} }, collections, log);
|
|
12852
13099
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
12853
|
-
for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" —
|
|
13100
|
+
for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`);
|
|
13101
|
+
const unrevoked = outcome.unsecured.filter((u) => !u.grantWithdrawn);
|
|
13102
|
+
for (const u of outcome.unsecured.filter((u) => u.grantWithdrawn)) logger.error(`🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is unreachable rather than unprotected. Reads and writes to that collection will fail until RLS can be enabled.`);
|
|
13103
|
+
if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
|
|
12854
13104
|
try {
|
|
12855
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
13105
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-69hYT8nr.js").then((n) => n.n);
|
|
12856
13106
|
await dropLegacyAuthSchema(async (text) => {
|
|
12857
13107
|
return (await internals.db.execute(sql.raw(text))).rows ?? [];
|
|
12858
13108
|
}, {
|
|
@@ -12869,7 +13119,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12869
13119
|
},
|
|
12870
13120
|
mountRoutes(app, basePath, driverResult) {},
|
|
12871
13121
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
12872
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
13122
|
+
const { createPostgresWebSocket } = await import("./websocket-C8ZqVBiV.js").then((n) => n.n);
|
|
12873
13123
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
12874
13124
|
}
|
|
12875
13125
|
};
|
|
@@ -12909,6 +13159,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
12909
13159
|
};
|
|
12910
13160
|
}
|
|
12911
13161
|
//#endregion
|
|
12912
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
13162
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
12913
13163
|
|
|
12914
13164
|
//# sourceMappingURL=index.es.js.map
|