@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- 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-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +23 -25
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
package/dist/index.es.js
CHANGED
|
@@ -2,16 +2,17 @@ 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 { 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-
|
|
5
|
+
import { n as resolveClientListLimit, r as ANONYMOUS_USER_ID, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
|
|
6
|
+
import { $ as isManyToMany, A as getTableName$1, B as normalizeToEntityRelation, C as getEffectiveSecurityRules, D as findRelation, E as findAnonymousGrants, F as isAddressableId, G as getPolicyNamesForRule, H as generateForeignKeyName, I as parseIdValues, J as camelCase, K as isPrototypePollutingKey, L as sortCollectionsBySlug, M as resolveCollectionRelations, N as buildCompositeId, O as getColumnName, P as getDeclaredPrimaryKeys, Q as hasForeignKeyOnTarget, R as createRelationRef, S as resolveJunctionSpecs, T as securityRuleToConditions, U as legacyForeignKeyName, V as updateDateAutoValues, Y as toSnakeCase, _ as CollectionRegistry, b as getJunctionCollectionConfig, et as Vector, g as buildSdkData, h as visibleColumnProjection, j as getTableVarName, k as getEnumVarName, l as buildSearchColumnSpec, q as mergeDeep, r as authUsersColumnSql, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, u as hiddenColumnsOption, v as relationalCollections, w as policyToPostgres, x as getJunctionSecurityRules, y as resolveStringColumnLength, z as createRelationRefWithData } from "./auth-users-columns-CBEOeYqa.js";
|
|
7
|
+
import { c as isPostgresCollectionConfig, l as isRelationalCollectionConfig, n as REBASE_SCHEMA, o as usesLegacyRlsFunctions } from "./src-C_wvdMnl.js";
|
|
8
|
+
import { t as createPostgresWebSocket } from "./websocket-D1qbmLZ2.js";
|
|
9
|
+
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-Bww-Lg0s.js";
|
|
10
|
+
import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-Bpv3nUZo.js";
|
|
10
11
|
import { Client, Pool } from "pg";
|
|
11
12
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
12
13
|
import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
13
14
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
14
|
-
import { PgArray,
|
|
15
|
+
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";
|
|
15
16
|
import fs, { promises } from "fs";
|
|
16
17
|
import path from "path";
|
|
17
18
|
import chokidar from "chokidar";
|
|
@@ -30,25 +31,6 @@ import { randomUUID } from "crypto";
|
|
|
30
31
|
function isChannelBusInstance(setting) {
|
|
31
32
|
return typeof setting?.publish === "function";
|
|
32
33
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
35
|
-
*
|
|
36
|
-
* - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
|
|
37
|
-
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
38
|
-
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
39
|
-
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
40
|
-
*
|
|
41
|
-
* The return is never `undefined` — no ingress that routes its client limit
|
|
42
|
-
* through this can produce an unbounded read.
|
|
43
|
-
*/
|
|
44
|
-
function resolveClientListLimit(rawLimit, opts = {}) {
|
|
45
|
-
const maxLimit = opts.maxLimit ?? 1e3;
|
|
46
|
-
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
47
|
-
const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
|
|
48
|
-
if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
|
|
49
|
-
}
|
|
50
|
-
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
51
|
-
}
|
|
52
34
|
//#endregion
|
|
53
35
|
//#region ../common/src/util/email.ts
|
|
54
36
|
/**
|
|
@@ -152,6 +134,163 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
152
134
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
153
135
|
};
|
|
154
136
|
//#endregion
|
|
137
|
+
//#region ../common/src/util/internal-tables.ts
|
|
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_$]*$/;
|
|
226
|
+
/**
|
|
227
|
+
* A single statement that takes every privilege on `schema.table` away from the
|
|
228
|
+
* end-user role.
|
|
229
|
+
*
|
|
230
|
+
* Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
|
|
231
|
+
* happen in practice:
|
|
232
|
+
*
|
|
233
|
+
* - the role does not exist when the connection is unprivileged (Rebase then
|
|
234
|
+
* relies on native RLS rather than a role switch), and a bare `REVOKE` on a
|
|
235
|
+
* missing role is an error, not a no-op;
|
|
236
|
+
* - the table may not exist yet — `cron_logs` never appears in a project with
|
|
237
|
+
* no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
|
|
238
|
+
*
|
|
239
|
+
* One command, so it is safe on handles that speak the extended query protocol
|
|
240
|
+
* and reject multi-statement strings.
|
|
241
|
+
*/
|
|
242
|
+
function revokeInternalTableSql(schema, table) {
|
|
243
|
+
if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
|
|
244
|
+
if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
|
|
245
|
+
const qualified = `"${schema}"."${table}"`;
|
|
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
|
+
}
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region ../common/src/data/filter-conditions.ts
|
|
274
|
+
/**
|
|
275
|
+
* Read one field's filter as the list of conditions it stands for.
|
|
276
|
+
*
|
|
277
|
+
* Accepts both declared shapes and normalises them to a list:
|
|
278
|
+
*
|
|
279
|
+
* ```ts
|
|
280
|
+
* toFilterTuples(["==", "active"]) // [["==", "active"]]
|
|
281
|
+
* toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* A falsy, non-array or empty param has no conditions in it — the empty list,
|
|
285
|
+
* so a caller iterating adds nothing rather than compiling a tuple of
|
|
286
|
+
* `undefined`s and logging about an operator nobody sent.
|
|
287
|
+
*/
|
|
288
|
+
function toFilterTuples(filterParam) {
|
|
289
|
+
if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
|
|
290
|
+
if (Array.isArray(filterParam[0])) return filterParam;
|
|
291
|
+
return [filterParam];
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
155
294
|
//#region ../common/src/table-classification.ts
|
|
156
295
|
/** Schemas that are always considered Rebase-internal. */
|
|
157
296
|
var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
|
|
@@ -257,6 +396,38 @@ function getCollectionByPath(collectionPath, registry) {
|
|
|
257
396
|
}
|
|
258
397
|
return collection;
|
|
259
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Reject a write naming something that is not a column of the table.
|
|
401
|
+
*
|
|
402
|
+
* Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
|
|
403
|
+
* from `Object.keys(tableColumns)`, so a key the table does not carry is not
|
|
404
|
+
* rejected by anything — it is *left out of the statement*. The insert answers
|
|
405
|
+
* 201 having stored nothing under that name; the update, if the key was the
|
|
406
|
+
* only one, builds `update "posts" set where …` and Postgres raises a syntax
|
|
407
|
+
* error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
|
|
408
|
+
* 500 for what is a caller's typo.
|
|
409
|
+
*
|
|
410
|
+
* That makes this the last honest place to check, and the only one every write
|
|
411
|
+
* passes through. `assertKnownWriteFields` in the REST layer checks the same
|
|
412
|
+
* thing against the *config* and is skipped on four paths — `strictWrites:
|
|
413
|
+
* false`, a collection declaring no properties, an auth adapter that owns the
|
|
414
|
+
* body's shape, and a nested route whose target cannot be walked — and it never
|
|
415
|
+
* sees an in-process `rebase.data` write at all.
|
|
416
|
+
*
|
|
417
|
+
* It also gives `strictWrites: false` a truthful implementation. The flag is
|
|
418
|
+
* documented for "a column that really does exist which the config never
|
|
419
|
+
* declared", and skipping the config check alone could not deliver that: the
|
|
420
|
+
* value was dropped a layer later regardless. Skipping the config check and
|
|
421
|
+
* keeping this one does exactly what the flag says — the column must exist,
|
|
422
|
+
* the property need not.
|
|
423
|
+
*/
|
|
424
|
+
function assertWritableColumns(values, table, collectionPath) {
|
|
425
|
+
const columns = getTableColumns(table);
|
|
426
|
+
if (!columns || Object.keys(columns).length === 0) return;
|
|
427
|
+
const unknown = Object.keys(values).filter((key) => !(key in columns));
|
|
428
|
+
if (unknown.length === 0) return;
|
|
429
|
+
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");
|
|
430
|
+
}
|
|
260
431
|
function getTableForCollection(collection, registry) {
|
|
261
432
|
const tableName = getTableName$1(collection);
|
|
262
433
|
const table = registry.getTable(tableName);
|
|
@@ -436,6 +607,71 @@ function deriveRowAddress(row, collection, registry) {
|
|
|
436
607
|
//#endregion
|
|
437
608
|
//#region src/utils/drizzle-conditions.ts
|
|
438
609
|
/**
|
|
610
|
+
* Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
|
|
611
|
+
* because the fuzzy predicate has to know when the index-backed operator agrees
|
|
612
|
+
* with the collection's declared threshold and when it would narrow too far.
|
|
613
|
+
*/
|
|
614
|
+
var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
|
|
615
|
+
/**
|
|
616
|
+
* A user's search term, made safe to drop inside a `%…%` LIKE pattern.
|
|
617
|
+
*
|
|
618
|
+
* The term is already a bind parameter, so this is not about injection. It is
|
|
619
|
+
* about the two things a LIKE metacharacter does when it arrives from a search
|
|
620
|
+
* box:
|
|
621
|
+
*
|
|
622
|
+
* 1. **It changes the query.** `%` and `_` are wildcards, so searching for
|
|
623
|
+
* `50%` returned every row and `a_c` matched `abc`. Nothing the caller
|
|
624
|
+
* could type would find a literal `%`.
|
|
625
|
+
* 2. **It is a cost the caller chooses.** Postgres matches LIKE by
|
|
626
|
+
* backtracking: each `%` re-tries every remaining offset, so
|
|
627
|
+
* `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
|
|
628
|
+
* exponent — evaluated per row, OR-ed across every string property of the
|
|
629
|
+
* collection, on a sequential scan (a leading `%` cannot use an index), and
|
|
630
|
+
* the page limit does not bound it because the scan happens first.
|
|
631
|
+
*
|
|
632
|
+
* This is the server-side half of the pattern `like-pattern-redos.test.ts`
|
|
633
|
+
* hardened the offline evaluator against; that test's own note ("the same
|
|
634
|
+
* translation in the Mongo driver hands the expression to the database, where
|
|
635
|
+
* it occupies a server thread instead") describes this call site.
|
|
636
|
+
*
|
|
637
|
+
* Backslash is the default `ESCAPE` character for LIKE, and the pattern is
|
|
638
|
+
* bound rather than interpolated, so a single backslash here reaches the
|
|
639
|
+
* matcher as one. Escaping the escape character first is what keeps a term
|
|
640
|
+
* ending in `\` from swallowing the closing `%`.
|
|
641
|
+
*
|
|
642
|
+
* Note this is a *substring search*, not the `like` filter operator: a caller
|
|
643
|
+
* who wants wildcards has `?title=like.foo%` for that, where the pattern is the
|
|
644
|
+
* documented input.
|
|
645
|
+
*/
|
|
646
|
+
var escapeLikePattern = (value) => value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
647
|
+
/** Column types `ILIKE '%…%'` is defined on. */
|
|
648
|
+
var ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
|
|
649
|
+
/**
|
|
650
|
+
* Can this column be matched with `ILIKE`?
|
|
651
|
+
*
|
|
652
|
+
* Asked of the column's *declared SQL type*, never with `instanceof`. The
|
|
653
|
+
* previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
|
|
654
|
+
* and `instanceof` compares class identity: it is only true when the column was
|
|
655
|
+
* constructed by the very same copy of `drizzle-orm` that this module imported.
|
|
656
|
+
*
|
|
657
|
+
* An application's generated schema builds its tables with the app's own
|
|
658
|
+
* `drizzle-orm`, and this driver declares its own dependency on one. When the
|
|
659
|
+
* two ranges do not overlap — an app scaffolded against `^0.44` with a driver
|
|
660
|
+
* asking for `^0.45` — a strict installer gives the driver a second copy, every
|
|
661
|
+
* check returns false, no condition is produced, and the caller compiles that
|
|
662
|
+
* into an impossible `WHERE`. The result is a 200 with an empty page for every
|
|
663
|
+
* search on every collection without a `search` block: the failure looks
|
|
664
|
+
* exactly like "nothing matched". Observed in production, not theorised.
|
|
665
|
+
*
|
|
666
|
+
* `getSQLType()` is a value the column reports about itself, so it crosses
|
|
667
|
+
* module instances the way a class identity cannot. It also happens to fix
|
|
668
|
+
* `citext`, which the `instanceof` list never covered.
|
|
669
|
+
*/
|
|
670
|
+
var supportsILike = (column) => {
|
|
671
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
672
|
+
return ILIKE_SQL_TYPES.test(sqlType);
|
|
673
|
+
};
|
|
674
|
+
/**
|
|
439
675
|
* Process-wide default, set once when the driver is constructed.
|
|
440
676
|
*
|
|
441
677
|
* The condition builder is a set of *static* methods reached from a dozen
|
|
@@ -493,7 +729,7 @@ function toMembershipList(value) {
|
|
|
493
729
|
* @example
|
|
494
730
|
* const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
|
|
495
731
|
*/
|
|
496
|
-
var DrizzleConditionBuilder = class {
|
|
732
|
+
var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
497
733
|
/**
|
|
498
734
|
* Express "reachable from this parent through this relation" as a plain
|
|
499
735
|
* `WHERE` condition on the target table.
|
|
@@ -669,8 +905,7 @@ var DrizzleConditionBuilder = class {
|
|
|
669
905
|
if (!filterParam) continue;
|
|
670
906
|
const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
|
|
671
907
|
if (!target) continue;
|
|
672
|
-
|
|
673
|
-
for (const [op, value] of paramsList) {
|
|
908
|
+
for (const [op, value] of toFilterTuples(filterParam)) {
|
|
674
909
|
const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
|
|
675
910
|
if (condition) conditions.push(condition);
|
|
676
911
|
}
|
|
@@ -1119,22 +1354,153 @@ var DrizzleConditionBuilder = class {
|
|
|
1119
1354
|
return or(...conditions);
|
|
1120
1355
|
}
|
|
1121
1356
|
/**
|
|
1122
|
-
* Build search conditions for text fields
|
|
1357
|
+
* Build search conditions for text fields.
|
|
1358
|
+
*
|
|
1359
|
+
* Two shapes, chosen by whether the collection declared a `search` block:
|
|
1360
|
+
*
|
|
1361
|
+
* - **Declared** — one `@@ websearch_to_tsquery` against the generated
|
|
1362
|
+
* `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
|
|
1363
|
+
* inside JSONB and arrays, and uses the GIN index.
|
|
1364
|
+
* - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
|
|
1365
|
+
* string properties, with the term escaped (see {@link escapeLikePattern})
|
|
1366
|
+
* so it is matched as the literal text the user typed.
|
|
1367
|
+
*
|
|
1368
|
+
* The second is the default and stays the default. A collection that has
|
|
1369
|
+
* not opted in compiles to exactly the SQL it compiled to before this
|
|
1370
|
+
* branch existed, which is the only reason it is safe to have added it.
|
|
1371
|
+
*
|
|
1372
|
+
* `collection` is optional so that the callers which genuinely have no
|
|
1373
|
+
* collection in hand — nested paths, derived views — keep working; without
|
|
1374
|
+
* one there is no `search` block to read and the ILIKE path is correct.
|
|
1123
1375
|
*/
|
|
1124
|
-
static buildSearchConditions(searchString, properties, table) {
|
|
1376
|
+
static buildSearchConditions(searchString, properties, table, collection) {
|
|
1125
1377
|
const searchConditions = [];
|
|
1378
|
+
const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
|
|
1379
|
+
if (ftsCondition) return [ftsCondition];
|
|
1380
|
+
let declaredStringProperties = 0;
|
|
1126
1381
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1127
1382
|
const p = prop;
|
|
1128
1383
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
1384
|
+
declaredStringProperties++;
|
|
1129
1385
|
const fieldColumn = table[key];
|
|
1130
|
-
if (fieldColumn) {
|
|
1131
|
-
if (fieldColumn instanceof PgVarchar || fieldColumn instanceof PgText || fieldColumn instanceof PgChar || fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
|
|
1132
|
-
}
|
|
1386
|
+
if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
|
|
1133
1387
|
}
|
|
1134
1388
|
}
|
|
1389
|
+
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.`);
|
|
1135
1390
|
return searchConditions;
|
|
1136
1391
|
}
|
|
1137
1392
|
/**
|
|
1393
|
+
* The `@@` predicate for a collection that declared a `search` block, or
|
|
1394
|
+
* undefined for one that did not.
|
|
1395
|
+
*
|
|
1396
|
+
* The query is normalized exactly as the indexed content was — same text
|
|
1397
|
+
* search configuration, same accent folding. Skipping that on the query
|
|
1398
|
+
* side is the subtle way to get a search that matches nothing: the column
|
|
1399
|
+
* would hold `gestion` while the query asked for `gestión`.
|
|
1400
|
+
*
|
|
1401
|
+
* `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
|
|
1402
|
+
* one that behaves the way a search box looks like it should — quoted
|
|
1403
|
+
* phrases, `or`, and a leading `-` to exclude — and because it never throws
|
|
1404
|
+
* on user input, which `to_tsquery` does on so much as a stray parenthesis.
|
|
1405
|
+
*/
|
|
1406
|
+
static buildFullTextCondition(searchString, table, collection) {
|
|
1407
|
+
let spec;
|
|
1408
|
+
try {
|
|
1409
|
+
spec = buildSearchColumnSpec(collection);
|
|
1410
|
+
} catch {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
if (!spec) return void 0;
|
|
1414
|
+
const column = table[spec.column];
|
|
1415
|
+
if (!column) return;
|
|
1416
|
+
const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
|
|
1417
|
+
if (!spec.fuzzy) return exact;
|
|
1418
|
+
const fuzzyColumn = table[spec.fuzzy.column];
|
|
1419
|
+
if (!fuzzyColumn) return exact;
|
|
1420
|
+
const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
|
|
1421
|
+
const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
|
|
1422
|
+
return sql`(${exact} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* `websearch_to_tsquery(<config>, <normalized search string>)`.
|
|
1426
|
+
*
|
|
1427
|
+
* Split out because the ranking expression needs the identical query — a
|
|
1428
|
+
* row ranked against a different tsquery than it was matched against is a
|
|
1429
|
+
* ranking of something else.
|
|
1430
|
+
*/
|
|
1431
|
+
static normalizedTsQuery(searchString, spec) {
|
|
1432
|
+
const normalized = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
|
|
1433
|
+
return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* A JSONB array of `{ field, snippet }` naming which declared fields matched
|
|
1437
|
+
* and showing the text around each hit — what backs `_matches`.
|
|
1438
|
+
*
|
|
1439
|
+
* A ranked list answers "which rows", never "why this row". For a talent
|
|
1440
|
+
* pool that difference is the product: a candidate surfacing for
|
|
1441
|
+
* "iso 14001" on a *certification* is a different candidate from one whose
|
|
1442
|
+
* bio happens to mention the standard, and the score cannot tell them apart.
|
|
1443
|
+
*
|
|
1444
|
+
* Built as a correlated subquery over a `VALUES` list of the declared
|
|
1445
|
+
* fields, rather than one `CASE` per field, so the shape does not change
|
|
1446
|
+
* with the number of fields and the empty result is a plain `[]`.
|
|
1447
|
+
*
|
|
1448
|
+
* `ts_headline` runs over the same normalized text that was indexed. Over
|
|
1449
|
+
* the *original* text it would find nothing to mark whenever `unaccent` is
|
|
1450
|
+
* on — the query's lexemes are folded and the document's are not — and
|
|
1451
|
+
* would return the text silently unhighlighted. Folded-but-marked beats
|
|
1452
|
+
* pretty-but-inert.
|
|
1453
|
+
*
|
|
1454
|
+
* Undefined when the collection has not opted in, when the column is not on
|
|
1455
|
+
* the table yet, or when the caller did not ask: this costs a `ts_headline`
|
|
1456
|
+
* per field per row and `ts_headline` re-parses the document.
|
|
1457
|
+
*/
|
|
1458
|
+
static buildSearchMatchesExpression(searchString, table, collection) {
|
|
1459
|
+
let spec;
|
|
1460
|
+
try {
|
|
1461
|
+
spec = buildSearchColumnSpec(collection);
|
|
1462
|
+
} catch {
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (!spec || spec.fields.length === 0) return void 0;
|
|
1466
|
+
if (!table[spec.column]) return void 0;
|
|
1467
|
+
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1468
|
+
const config = sql`${spec.language}`;
|
|
1469
|
+
const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
|
|
1470
|
+
return sql`(
|
|
1471
|
+
SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
|
|
1472
|
+
FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
|
|
1473
|
+
CROSS JOIN LATERAL (
|
|
1474
|
+
SELECT jsonb_build_object(
|
|
1475
|
+
'field', f.path,
|
|
1476
|
+
'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
|
|
1477
|
+
'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
|
|
1478
|
+
) AS m
|
|
1479
|
+
WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
|
|
1480
|
+
) s
|
|
1481
|
+
)`;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* `ts_rank(<column>, <query>)` for the collection, or undefined when it has
|
|
1485
|
+
* not opted in. This is what backs `orderBy: ["_score", "desc"]`.
|
|
1486
|
+
*/
|
|
1487
|
+
static buildSearchRankExpression(searchString, table, collection) {
|
|
1488
|
+
let spec;
|
|
1489
|
+
try {
|
|
1490
|
+
spec = buildSearchColumnSpec(collection);
|
|
1491
|
+
} catch {
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (!spec) return void 0;
|
|
1495
|
+
const column = table[spec.column];
|
|
1496
|
+
if (!column) return void 0;
|
|
1497
|
+
const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
|
|
1498
|
+
if (!spec.fuzzy) return rank;
|
|
1499
|
+
const fuzzyColumn = table[spec.fuzzy.column];
|
|
1500
|
+
if (!fuzzyColumn) return rank;
|
|
1501
|
+
return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1138
1504
|
* Build a unique field check condition
|
|
1139
1505
|
*/
|
|
1140
1506
|
static buildUniqueFieldCondition(fieldColumn, value, idColumn, excludeId) {
|
|
@@ -1232,10 +1598,19 @@ var DrizzleConditionBuilder = class {
|
|
|
1232
1598
|
* - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
|
|
1233
1599
|
* - `filter`: optional WHERE clause for distance threshold
|
|
1234
1600
|
* - `distanceSelect`: SQL expression for selecting the distance as `_distance`
|
|
1601
|
+
*
|
|
1602
|
+
* `property` is `?vector_search=` off the querystring, so it is an untrusted
|
|
1603
|
+
* *name*, and it used to be looked up straight in the drizzle table object.
|
|
1604
|
+
* Two ways that went wrong, both answering 500 to a malformed request:
|
|
1605
|
+
* `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
|
|
1606
|
+
* database rejects with "operator does not exist"; and a table object also
|
|
1607
|
+
* carries non-column keys (`_`, methods), which passed the `if (!column)`
|
|
1608
|
+
* guard and compiled to nonsense. The name is resolved against the table's
|
|
1609
|
+
* actual columns and required to be a `vector` — anything else is the
|
|
1610
|
+
* caller's mistake and gets a 400 that says so.
|
|
1235
1611
|
*/
|
|
1236
1612
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
1237
|
-
const column = table
|
|
1238
|
-
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1613
|
+
const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
|
|
1239
1614
|
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");
|
|
1240
1615
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1241
1616
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
@@ -1257,7 +1632,33 @@ var DrizzleConditionBuilder = class {
|
|
|
1257
1632
|
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
|
|
1258
1633
|
};
|
|
1259
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* The `vector` column a request named, or a 400 explaining what it named.
|
|
1637
|
+
*
|
|
1638
|
+
* `getTableColumns` rather than a key lookup: it returns only the columns,
|
|
1639
|
+
* so `_`, `getSQL` and every other property of a drizzle table stop looking
|
|
1640
|
+
* like candidates. The type check is on the *physical* column
|
|
1641
|
+
* (`vector(1536)`) rather than on the declared property, so it holds for an
|
|
1642
|
+
* introspected collection too, where the property carries no Rebase type.
|
|
1643
|
+
*/
|
|
1644
|
+
static resolveVectorColumn(table, property) {
|
|
1645
|
+
const columns = getTableColumns(table);
|
|
1646
|
+
const column = columns?.[property];
|
|
1647
|
+
if (!column) {
|
|
1648
|
+
const known = Object.entries(columns ?? {}).filter(([, c]) => isVectorColumn(c)).map(([name]) => name);
|
|
1649
|
+
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");
|
|
1650
|
+
}
|
|
1651
|
+
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");
|
|
1652
|
+
return column;
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
/** The column's SQL type, for a value that may not be a drizzle column at all. */
|
|
1656
|
+
var columnSqlType = (column) => {
|
|
1657
|
+
const getSQLType = column?.getSQLType;
|
|
1658
|
+
return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
|
|
1260
1659
|
};
|
|
1660
|
+
/** True for `vector(1536)` and its pgvector siblings, whatever the width. */
|
|
1661
|
+
var isVectorColumn = (column) => /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
|
|
1261
1662
|
/**
|
|
1262
1663
|
* Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
|
|
1263
1664
|
* This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
|
|
@@ -1276,7 +1677,11 @@ function sanitizeAndConvertDates(obj) {
|
|
|
1276
1677
|
if (obj instanceof Date) return obj.toISOString();
|
|
1277
1678
|
if (typeof obj === "object") {
|
|
1278
1679
|
const newObj = {};
|
|
1279
|
-
for (const key in obj)
|
|
1680
|
+
for (const key in obj) {
|
|
1681
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
1682
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
1683
|
+
newObj[key] = sanitizeAndConvertDates(obj[key]);
|
|
1684
|
+
}
|
|
1280
1685
|
return newObj;
|
|
1281
1686
|
}
|
|
1282
1687
|
if (typeof obj === "string") {
|
|
@@ -1305,6 +1710,8 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1305
1710
|
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
1306
1711
|
});
|
|
1307
1712
|
for (const [key, value] of Object.entries(row)) {
|
|
1713
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
1714
|
+
if (value === void 0) continue;
|
|
1308
1715
|
const property = properties[key];
|
|
1309
1716
|
const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
|
|
1310
1717
|
if (!property) {
|
|
@@ -1315,11 +1722,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1315
1722
|
const relation = findRelation(resolvedRelations, key);
|
|
1316
1723
|
if (relation) {
|
|
1317
1724
|
if (relation.kind === "belongsTo") {
|
|
1318
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1725
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1319
1726
|
if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
|
|
1320
1727
|
continue;
|
|
1321
1728
|
} else if (hasForeignKeyOnTarget(relation)) {
|
|
1322
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1729
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1323
1730
|
inverseRelationUpdates.push({
|
|
1324
1731
|
relationKey: key,
|
|
1325
1732
|
relation,
|
|
@@ -1327,7 +1734,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1327
1734
|
});
|
|
1328
1735
|
continue;
|
|
1329
1736
|
} else if (relation.kind === "via") {
|
|
1330
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1737
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1331
1738
|
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
1332
1739
|
relationKey: key,
|
|
1333
1740
|
relation,
|
|
@@ -1342,7 +1749,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1342
1749
|
}
|
|
1343
1750
|
}
|
|
1344
1751
|
}
|
|
1345
|
-
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
1752
|
+
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
1346
1753
|
}
|
|
1347
1754
|
return {
|
|
1348
1755
|
scalarData: result,
|
|
@@ -1351,19 +1758,39 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1351
1758
|
};
|
|
1352
1759
|
}
|
|
1353
1760
|
/**
|
|
1354
|
-
*
|
|
1761
|
+
* How to name a rejected value in an error, without quoting it back.
|
|
1762
|
+
*
|
|
1763
|
+
* The value may be anything the caller sent, including a secret in the wrong
|
|
1764
|
+
* field, so the message describes its shape rather than echoing it — an echoed
|
|
1765
|
+
* value ends up in logs and in error-reporting services.
|
|
1766
|
+
*/
|
|
1767
|
+
function describeValue(value) {
|
|
1768
|
+
if (value === null) return "null";
|
|
1769
|
+
if (Array.isArray(value)) return "an array";
|
|
1770
|
+
if (value instanceof Date) return "a date";
|
|
1771
|
+
const type = typeof value;
|
|
1772
|
+
return type === "object" ? "an object" : `a ${type}`;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Serialize a single property value for database storage.
|
|
1776
|
+
*
|
|
1777
|
+
* `propertyKey` is only ever used to phrase errors and warnings. Without it the
|
|
1778
|
+
* one trace a bad value left was `Expected array value for array property, got
|
|
1779
|
+
* string` — no collection, no property, no value, which in the log of a
|
|
1780
|
+
* thousand-row import names nothing at all.
|
|
1355
1781
|
*/
|
|
1356
|
-
function serializePropertyToServer(value, property) {
|
|
1782
|
+
function serializePropertyToServer(value, property, propertyKey) {
|
|
1357
1783
|
if (value === null || value === void 0) return value;
|
|
1784
|
+
const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
|
|
1358
1785
|
switch (property.type) {
|
|
1359
1786
|
case "relation":
|
|
1360
|
-
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property));
|
|
1787
|
+
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
|
|
1361
1788
|
else if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
1362
1789
|
if (value === "") return null;
|
|
1363
1790
|
return value;
|
|
1364
1791
|
case "array":
|
|
1365
1792
|
if (Array.isArray(value)) {
|
|
1366
|
-
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of));
|
|
1793
|
+
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
|
|
1367
1794
|
else if (property.oneOf) {
|
|
1368
1795
|
const typeField = property.oneOf.typeField ?? "type";
|
|
1369
1796
|
const valueField = property.oneOf.valueField ?? "value";
|
|
@@ -1376,20 +1803,28 @@ function serializePropertyToServer(value, property) {
|
|
|
1376
1803
|
if (!type || !childProperty) return e;
|
|
1377
1804
|
return {
|
|
1378
1805
|
[typeField]: type,
|
|
1379
|
-
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
1806
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
1380
1807
|
};
|
|
1381
1808
|
});
|
|
1382
1809
|
}
|
|
1383
1810
|
return value;
|
|
1384
1811
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1812
|
+
throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
1813
|
+
case "geopoint": {
|
|
1814
|
+
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");
|
|
1815
|
+
const point = value;
|
|
1816
|
+
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");
|
|
1817
|
+
return {
|
|
1818
|
+
latitude: point.latitude,
|
|
1819
|
+
longitude: point.longitude
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1387
1822
|
case "map":
|
|
1388
1823
|
if (typeof value === "object" && property.properties) {
|
|
1389
1824
|
const result = {};
|
|
1390
1825
|
for (const [subKey, subValue] of Object.entries(value)) {
|
|
1391
1826
|
const subProperty = property.properties[subKey];
|
|
1392
|
-
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty);
|
|
1827
|
+
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
1393
1828
|
else result[subKey] = subValue;
|
|
1394
1829
|
}
|
|
1395
1830
|
return result;
|
|
@@ -1627,6 +2062,7 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1627
2062
|
return isNaN(parsed) ? null : parsed;
|
|
1628
2063
|
}
|
|
1629
2064
|
return value;
|
|
2065
|
+
case "geopoint": return value;
|
|
1630
2066
|
case "vector": {
|
|
1631
2067
|
let nums = [];
|
|
1632
2068
|
if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
|
|
@@ -1670,6 +2106,18 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1670
2106
|
* from the result (used by `normalizeDbValues` where
|
|
1671
2107
|
* Drizzle's relational API already hydrates them).
|
|
1672
2108
|
*/
|
|
2109
|
+
/**
|
|
2110
|
+
* Keys a query computes and attaches to a row, rather than reads from a column.
|
|
2111
|
+
*
|
|
2112
|
+
* An explicit set rather than a `_` prefix rule: a user's column may perfectly
|
|
2113
|
+
* well be called `_internal`, and passing it through here would put a value on
|
|
2114
|
+
* the row that no property describes and nothing downstream knows how to type.
|
|
2115
|
+
*/
|
|
2116
|
+
var QUERY_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
2117
|
+
"_score",
|
|
2118
|
+
"_distance",
|
|
2119
|
+
"_matches"
|
|
2120
|
+
]);
|
|
1673
2121
|
function normalizeScalarValues(data, properties, collection, resolvedRelations, options) {
|
|
1674
2122
|
const result = {};
|
|
1675
2123
|
const internalFKColumns = /* @__PURE__ */ new Set();
|
|
@@ -1681,6 +2129,10 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
|
|
|
1681
2129
|
result[key] = value === null ? null : typeof value === "number" ? value : String(value);
|
|
1682
2130
|
continue;
|
|
1683
2131
|
}
|
|
2132
|
+
if (QUERY_METADATA_KEYS.has(key)) {
|
|
2133
|
+
result[key] = value;
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
1684
2136
|
const property = properties[key];
|
|
1685
2137
|
if (!property) continue;
|
|
1686
2138
|
if (options.skipRelations && property.type === "relation") continue;
|
|
@@ -1933,7 +2385,7 @@ var RelationService = class {
|
|
|
1933
2385
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1934
2386
|
const additionalFilters = [];
|
|
1935
2387
|
if (options.searchString) {
|
|
1936
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable);
|
|
2388
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
|
|
1937
2389
|
if (searchConditions.length === 0) return [];
|
|
1938
2390
|
const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
|
|
1939
2391
|
if (searchCombined) additionalFilters.push(searchCombined);
|
|
@@ -2233,6 +2685,55 @@ var RelationService = class {
|
|
|
2233
2685
|
return resultMap;
|
|
2234
2686
|
}
|
|
2235
2687
|
/**
|
|
2688
|
+
* Bring one row's junction links in line with the ids a save carried,
|
|
2689
|
+
* by diffing against what is stored rather than replacing the lot.
|
|
2690
|
+
*
|
|
2691
|
+
* The old shape was `DELETE every link for this parent` followed by
|
|
2692
|
+
* `INSERT what the client sent`, which makes a save of the parent a full
|
|
2693
|
+
* replacement of the membership set from a list the browser assembled out
|
|
2694
|
+
* of a read it did earlier. Three things follow from that, and the diff
|
|
2695
|
+
* closes all three:
|
|
2696
|
+
*
|
|
2697
|
+
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves;
|
|
2698
|
+
* B saves any field and the whole set is rewritten from B's older list,
|
|
2699
|
+
* dropping X with nothing to show for it. A diff only names the ids that
|
|
2700
|
+
* actually changed, so edits to disjoint tags no longer collide.
|
|
2701
|
+
* - **Partial read, partial delete.** The read that fills the form runs
|
|
2702
|
+
* under RLS, so a user who may edit the parent but cannot *see* some of
|
|
2703
|
+
* the linked rows gets a shorter list — and writing it back deleted the
|
|
2704
|
+
* links they were never shown. The DELETE now names ids instead of
|
|
2705
|
+
* "everything for this parent", and the select that produces them runs
|
|
2706
|
+
* in this same transaction under the same policies, so a link the caller
|
|
2707
|
+
* cannot read is in neither list and survives the save.
|
|
2708
|
+
* - **Junction payload columns.** A junction carrying its own columns
|
|
2709
|
+
* (`position`, `role`, `created_at`) lost them on every save, because
|
|
2710
|
+
* every row was re-inserted with only the two keys. Untouched links are
|
|
2711
|
+
* now left alone.
|
|
2712
|
+
*
|
|
2713
|
+
* The insert is `ON CONFLICT DO NOTHING` so that two sessions adding the
|
|
2714
|
+
* same link concurrently is a no-op rather than a unique violation.
|
|
2715
|
+
*/
|
|
2716
|
+
async syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedParentId, parsedTargetIds) {
|
|
2717
|
+
const existingRows = await tx.select({ targetId: targetJunctionColumn }).from(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
2718
|
+
const existingById = /* @__PURE__ */ new Map();
|
|
2719
|
+
for (const row of existingRows) {
|
|
2720
|
+
if (row.targetId === null || row.targetId === void 0) continue;
|
|
2721
|
+
existingById.set(String(row.targetId), row.targetId);
|
|
2722
|
+
}
|
|
2723
|
+
const wantedById = /* @__PURE__ */ new Map();
|
|
2724
|
+
for (const targetId of parsedTargetIds) {
|
|
2725
|
+
if (targetId === null || targetId === void 0) continue;
|
|
2726
|
+
wantedById.set(String(targetId), targetId);
|
|
2727
|
+
}
|
|
2728
|
+
const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
|
|
2729
|
+
const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, value]) => value);
|
|
2730
|
+
if (removed.length > 0) await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), inArray(targetJunctionColumn, removed)));
|
|
2731
|
+
if (added.length > 0) await tx.insert(junctionTable).values(added.map((targetId) => ({
|
|
2732
|
+
[sourceJunctionColumn.name]: parsedParentId,
|
|
2733
|
+
[targetJunctionColumn.name]: targetId
|
|
2734
|
+
}))).onConflictDoNothing();
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2236
2737
|
* Update many-to-many and junction relations
|
|
2237
2738
|
*/
|
|
2238
2739
|
async updateRelationsUsingJoins(tx, collection, id, relationValues) {
|
|
@@ -2277,16 +2778,13 @@ var RelationService = class {
|
|
|
2277
2778
|
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
2278
2779
|
const parentIdInfo = parentPks[0];
|
|
2279
2780
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2280
|
-
|
|
2781
|
+
let parsedTargetIds = [];
|
|
2281
2782
|
if (targetEntityIds.length > 0) {
|
|
2282
2783
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2283
2784
|
const targetIdInfo = targetPks[0];
|
|
2284
|
-
|
|
2285
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
2286
|
-
[targetJunctionColumn.name]: targetId
|
|
2287
|
-
}));
|
|
2288
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2785
|
+
parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2289
2786
|
}
|
|
2787
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedParentId, parsedTargetIds);
|
|
2290
2788
|
} else if (relation.kind === "manyToMany") {
|
|
2291
2789
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
2292
2790
|
if (!junctionTable) {
|
|
@@ -2302,16 +2800,13 @@ var RelationService = class {
|
|
|
2302
2800
|
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
2303
2801
|
const parentIdInfo = parentPks[0];
|
|
2304
2802
|
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2305
|
-
|
|
2803
|
+
let parsedTargetIds = [];
|
|
2306
2804
|
if (targetEntityIds.length > 0) {
|
|
2307
2805
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2308
2806
|
const targetIdInfo = targetPks[0];
|
|
2309
|
-
|
|
2310
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
2311
|
-
[targetJunctionColumn.name]: targetId
|
|
2312
|
-
}));
|
|
2313
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2807
|
+
parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2314
2808
|
}
|
|
2809
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedParentId, parsedTargetIds);
|
|
2315
2810
|
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
2316
2811
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2317
2812
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -2415,25 +2910,17 @@ var RelationService = class {
|
|
|
2415
2910
|
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2416
2911
|
const sourceIdInfo = sourcePks[0];
|
|
2417
2912
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
2418
|
-
|
|
2913
|
+
let parsedTargetIds = [];
|
|
2419
2914
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
2420
2915
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2421
2916
|
const targetIdInfo = targetPks[0];
|
|
2422
|
-
|
|
2423
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2424
|
-
[targetJunctionColumn.name]: targetId
|
|
2425
|
-
}));
|
|
2426
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2917
|
+
parsedTargetIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2427
2918
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
2428
2919
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2429
2920
|
const targetIdInfo = targetPks[0];
|
|
2430
|
-
|
|
2431
|
-
const newLink = {
|
|
2432
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2433
|
-
[targetJunctionColumn.name]: parsedTargetId
|
|
2434
|
-
};
|
|
2435
|
-
await tx.insert(junctionTable).values(newLink);
|
|
2921
|
+
parsedTargetIds = [parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName]];
|
|
2436
2922
|
}
|
|
2923
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedSourceId, parsedTargetIds);
|
|
2437
2924
|
}
|
|
2438
2925
|
} catch (error) {
|
|
2439
2926
|
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
@@ -2459,16 +2946,14 @@ var RelationService = class {
|
|
|
2459
2946
|
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2460
2947
|
const sourceIdInfo = sourcePks[0];
|
|
2461
2948
|
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
2462
|
-
|
|
2463
|
-
|
|
2949
|
+
const targetEntityIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : [];
|
|
2950
|
+
let parsedTargetIds = [];
|
|
2951
|
+
if (targetEntityIds.length > 0) {
|
|
2464
2952
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2465
2953
|
const targetIdInfo = targetPks[0];
|
|
2466
|
-
|
|
2467
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2468
|
-
[targetJunctionColumn.name]: targetId
|
|
2469
|
-
}));
|
|
2470
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2954
|
+
parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2471
2955
|
}
|
|
2956
|
+
await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedSourceId, parsedTargetIds);
|
|
2472
2957
|
} catch (error) {
|
|
2473
2958
|
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
2474
2959
|
throw error;
|
|
@@ -3019,8 +3504,7 @@ function sanitizeErrorForClient(error, context) {
|
|
|
3019
3504
|
column: pgError.column,
|
|
3020
3505
|
table: pgError.table,
|
|
3021
3506
|
constraint: pgError.constraint,
|
|
3022
|
-
dataType: pgError.dataType
|
|
3023
|
-
drizzleMessage: error instanceof Error ? error.message : String(error)
|
|
3507
|
+
dataType: pgError.dataType
|
|
3024
3508
|
});
|
|
3025
3509
|
return pgErrorToFriendlyMessage(pgError, context);
|
|
3026
3510
|
}
|
|
@@ -3039,7 +3523,7 @@ function sanitizeErrorForClient(error, context) {
|
|
|
3039
3523
|
* Service for handling all row read operations.
|
|
3040
3524
|
* Handles fetching, searching, counting, and filtering rows.
|
|
3041
3525
|
*/
|
|
3042
|
-
var FetchService = class {
|
|
3526
|
+
var FetchService = class FetchService {
|
|
3043
3527
|
db;
|
|
3044
3528
|
registry;
|
|
3045
3529
|
relationService;
|
|
@@ -3112,6 +3596,24 @@ var FetchService = class {
|
|
|
3112
3596
|
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
3113
3597
|
* caller that hands over no collection to resolve against.
|
|
3114
3598
|
*/
|
|
3599
|
+
/**
|
|
3600
|
+
* The ORDER BY target, which may be relevance rather than a column.
|
|
3601
|
+
*
|
|
3602
|
+
* `_score` is only meaningful for a collection that declared a `search`
|
|
3603
|
+
* block *and* for a request that carried a search string — ranking rows
|
|
3604
|
+
* against no query ranks them all at zero. Outside those two conditions it
|
|
3605
|
+
* is an unknown field and gets the same 400 as any other typo, which is the
|
|
3606
|
+
* behaviour that matters: a sort that is silently dropped returns 200 with
|
|
3607
|
+
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
3608
|
+
*/
|
|
3609
|
+
static SCORE_FIELD = "_score";
|
|
3610
|
+
resolveOrderTarget(table, orderBy, collection, searchString) {
|
|
3611
|
+
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
3612
|
+
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
3613
|
+
if (rank) return rank;
|
|
3614
|
+
}
|
|
3615
|
+
return this.resolveOrderByField(table, orderBy, collection);
|
|
3616
|
+
}
|
|
3115
3617
|
resolveOrderByField(table, orderBy, collection) {
|
|
3116
3618
|
const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
|
|
3117
3619
|
const direct = columnAt(orderBy);
|
|
@@ -3239,12 +3741,14 @@ var FetchService = class {
|
|
|
3239
3741
|
*/
|
|
3240
3742
|
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
|
|
3241
3743
|
const queryOpts = {};
|
|
3744
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), this.registry.getCollectionByPath(collectionPath) ?? void 0);
|
|
3745
|
+
if (hidden) queryOpts.columns = hidden;
|
|
3242
3746
|
if (withConfig) queryOpts.with = withConfig;
|
|
3243
3747
|
const allConditions = [];
|
|
3244
3748
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3245
3749
|
if (options.searchString) {
|
|
3246
3750
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3247
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3751
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3248
3752
|
if (searchConditions.length === 0) {
|
|
3249
3753
|
queryOpts.where = and(eq(idField, -99999999));
|
|
3250
3754
|
return queryOpts;
|
|
@@ -3267,7 +3771,7 @@ var FetchService = class {
|
|
|
3267
3771
|
const orderExpressions = [];
|
|
3268
3772
|
if (options.orderBy) {
|
|
3269
3773
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3270
|
-
const orderByField = this.
|
|
3774
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
3271
3775
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3272
3776
|
}
|
|
3273
3777
|
orderExpressions.push(desc(idField));
|
|
@@ -3284,6 +3788,7 @@ var FetchService = class {
|
|
|
3284
3788
|
if (!options.startAfter) return [];
|
|
3285
3789
|
const cursor = options.startAfter;
|
|
3286
3790
|
if (options.orderBy) {
|
|
3791
|
+
if (options.orderBy === FetchService.SCORE_FIELD) throw ApiError.badRequest("Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. Relevance is computed per query rather than stored, so it cannot key a cursor. Use `limit`/`offset` for relevance-ordered pages, or order by a column.", "SCORE_CURSOR_UNSUPPORTED", { field: FetchService.SCORE_FIELD });
|
|
3287
3792
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
|
|
3288
3793
|
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3289
3794
|
if (orderByField) {
|
|
@@ -3354,9 +3859,11 @@ var FetchService = class {
|
|
|
3354
3859
|
const qb = this.getQueryBuilder(tableName);
|
|
3355
3860
|
if (qb) try {
|
|
3356
3861
|
const withConfig = this.buildWithConfig(collection);
|
|
3862
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
3357
3863
|
const row = await qb.findFirst({
|
|
3358
3864
|
where: eq(idField, parsedId),
|
|
3359
|
-
with: withConfig
|
|
3865
|
+
with: withConfig,
|
|
3866
|
+
...hidden ? { columns: hidden } : {}
|
|
3360
3867
|
});
|
|
3361
3868
|
if (!row) return void 0;
|
|
3362
3869
|
const flatRow = toFlatRow(row, collection, this.registry);
|
|
@@ -3370,7 +3877,8 @@ var FetchService = class {
|
|
|
3370
3877
|
if (reachedDatabase(e)) throw e;
|
|
3371
3878
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
3372
3879
|
}
|
|
3373
|
-
const
|
|
3880
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
3881
|
+
const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
|
|
3374
3882
|
if (result.length === 0) return void 0;
|
|
3375
3883
|
const raw = result[0];
|
|
3376
3884
|
const values = await parseDataFromServer(raw, collection, this.db, this.registry);
|
|
@@ -3426,14 +3934,21 @@ var FetchService = class {
|
|
|
3426
3934
|
}
|
|
3427
3935
|
let vectorMeta;
|
|
3428
3936
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3937
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
3938
|
+
const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
|
|
3939
|
+
const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
|
|
3429
3940
|
let query = vectorMeta ? this.db.select({
|
|
3430
|
-
table_row: table,
|
|
3941
|
+
table_row: visible ?? table,
|
|
3431
3942
|
_distance: vectorMeta.distanceSelect
|
|
3432
|
-
}).from(table).$dynamic() : this.db.select(
|
|
3943
|
+
}).from(table).$dynamic() : rankSelect ? this.db.select({
|
|
3944
|
+
table_row: visible ?? table,
|
|
3945
|
+
_score: rankSelect,
|
|
3946
|
+
...matchesSelect ? { _matches: matchesSelect } : {}
|
|
3947
|
+
}).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3433
3948
|
const allConditions = [];
|
|
3434
3949
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3435
3950
|
if (options.searchString) {
|
|
3436
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3951
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3437
3952
|
if (searchConditions.length === 0) return [];
|
|
3438
3953
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3439
3954
|
}
|
|
@@ -3453,7 +3968,7 @@ var FetchService = class {
|
|
|
3453
3968
|
const orderExpressions = [];
|
|
3454
3969
|
if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
|
|
3455
3970
|
else if (options.orderBy) {
|
|
3456
|
-
const orderByField = this.
|
|
3971
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
3457
3972
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3458
3973
|
}
|
|
3459
3974
|
orderExpressions.push(desc(idField));
|
|
@@ -3473,6 +3988,10 @@ var FetchService = class {
|
|
|
3473
3988
|
const results = vectorMeta ? rawResults.map((r) => ({
|
|
3474
3989
|
...r.table_row,
|
|
3475
3990
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3991
|
+
})) : rankSelect ? rawResults.map((r) => ({
|
|
3992
|
+
...r.table_row,
|
|
3993
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
3994
|
+
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
3476
3995
|
})) : rawResults;
|
|
3477
3996
|
return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
3478
3997
|
}
|
|
@@ -3564,7 +4083,7 @@ var FetchService = class {
|
|
|
3564
4083
|
const allConditions = [];
|
|
3565
4084
|
if (hop) allConditions.push(this.buildRelationScope(hop));
|
|
3566
4085
|
if (options.searchString) {
|
|
3567
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
4086
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3568
4087
|
if (searchConditions.length === 0) return 0;
|
|
3569
4088
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3570
4089
|
}
|
|
@@ -3709,7 +4228,8 @@ var FetchService = class {
|
|
|
3709
4228
|
if (reachedDatabase(e)) throw e;
|
|
3710
4229
|
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
3711
4230
|
}
|
|
3712
|
-
const
|
|
4231
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
4232
|
+
const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
|
|
3713
4233
|
if (result.length === 0) return null;
|
|
3714
4234
|
const flatEntity = { ...result[0] };
|
|
3715
4235
|
if (!include || include.length === 0) return flatEntity;
|
|
@@ -3747,14 +4267,21 @@ var FetchService = class {
|
|
|
3747
4267
|
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
3748
4268
|
let vectorMeta;
|
|
3749
4269
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
4270
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
4271
|
+
const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
|
|
4272
|
+
const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
|
|
3750
4273
|
let query = vectorMeta ? this.db.select({
|
|
3751
|
-
table_row: table,
|
|
4274
|
+
table_row: visible ?? table,
|
|
3752
4275
|
_distance: vectorMeta.distanceSelect
|
|
3753
|
-
}).from(table).$dynamic() : this.db.select(
|
|
4276
|
+
}).from(table).$dynamic() : rankSelect ? this.db.select({
|
|
4277
|
+
table_row: visible ?? table,
|
|
4278
|
+
_score: rankSelect,
|
|
4279
|
+
...matchesSelect ? { _matches: matchesSelect } : {}
|
|
4280
|
+
}).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3754
4281
|
const allConditions = [];
|
|
3755
4282
|
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
3756
4283
|
if (options.searchString) {
|
|
3757
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
4284
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3758
4285
|
if (searchConditions.length === 0) return [];
|
|
3759
4286
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3760
4287
|
}
|
|
@@ -3762,6 +4289,10 @@ var FetchService = class {
|
|
|
3762
4289
|
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3763
4290
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3764
4291
|
}
|
|
4292
|
+
if (options.logical) {
|
|
4293
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
4294
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
4295
|
+
}
|
|
3765
4296
|
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
3766
4297
|
if (allConditions.length > 0) {
|
|
3767
4298
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -3770,7 +4301,7 @@ var FetchService = class {
|
|
|
3770
4301
|
const orderExpressions = [];
|
|
3771
4302
|
if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
|
|
3772
4303
|
else if (options.orderBy) {
|
|
3773
|
-
const orderByField = this.
|
|
4304
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
3774
4305
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3775
4306
|
}
|
|
3776
4307
|
orderExpressions.push(desc(idField));
|
|
@@ -3783,6 +4314,11 @@ var FetchService = class {
|
|
|
3783
4314
|
...r.table_row,
|
|
3784
4315
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3785
4316
|
}));
|
|
4317
|
+
if (rankSelect) return rawResults.map((r) => ({
|
|
4318
|
+
...r.table_row,
|
|
4319
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
4320
|
+
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
4321
|
+
}));
|
|
3786
4322
|
return rawResults;
|
|
3787
4323
|
}
|
|
3788
4324
|
/**
|
|
@@ -3796,51 +4332,6 @@ var FetchService = class {
|
|
|
3796
4332
|
return !!this.getQueryBuilder(tableName);
|
|
3797
4333
|
}
|
|
3798
4334
|
/**
|
|
3799
|
-
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
3800
|
-
* for efficient JOIN-based relation loading.
|
|
3801
|
-
* Returns null if the API is not available or the query fails.
|
|
3802
|
-
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
3803
|
-
*/
|
|
3804
|
-
async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
|
|
3805
|
-
try {
|
|
3806
|
-
const table = getTableForCollection(collection, this.registry);
|
|
3807
|
-
const tableName = getTableName(table);
|
|
3808
|
-
const queryTarget = this.getQueryBuilder(tableName);
|
|
3809
|
-
if (!queryTarget?.findMany) return null;
|
|
3810
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3811
|
-
const withConfig = {};
|
|
3812
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
|
|
3813
|
-
const drizzleRelName = relation.relationName || key;
|
|
3814
|
-
withConfig[drizzleRelName] = true;
|
|
3815
|
-
}
|
|
3816
|
-
const queryOpts = { with: withConfig };
|
|
3817
|
-
if (options.limit) queryOpts.limit = options.limit;
|
|
3818
|
-
if (options.filter) {
|
|
3819
|
-
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3820
|
-
if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
|
|
3821
|
-
}
|
|
3822
|
-
if (options.orderBy) {
|
|
3823
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3824
|
-
if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
3825
|
-
}
|
|
3826
|
-
return (await queryTarget.findMany(queryOpts)).map((row) => {
|
|
3827
|
-
const flat = {};
|
|
3828
|
-
for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
|
|
3829
|
-
const keys = Object.keys(item);
|
|
3830
|
-
const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
3831
|
-
if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
|
|
3832
|
-
return { ...item };
|
|
3833
|
-
});
|
|
3834
|
-
else if (typeof v === "object" && v !== null) flat[k] = { ...v };
|
|
3835
|
-
else flat[k] = v;
|
|
3836
|
-
return flat;
|
|
3837
|
-
});
|
|
3838
|
-
} catch (e) {
|
|
3839
|
-
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
3840
|
-
return null;
|
|
3841
|
-
}
|
|
3842
|
-
}
|
|
3843
|
-
/**
|
|
3844
4335
|
* Fallback path used when db.query is unavailable.
|
|
3845
4336
|
* The primary path uses db.query.findMany with `with` config, which
|
|
3846
4337
|
* loads all relations in a single query.
|
|
@@ -4023,6 +4514,7 @@ var PersistService = class {
|
|
|
4023
4514
|
const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
|
|
4024
4515
|
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
4025
4516
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
4517
|
+
assertWritableColumns(entityData, table, effectiveCollectionPath);
|
|
4026
4518
|
savedId = await this.db.transaction(async (tx) => {
|
|
4027
4519
|
let currentId;
|
|
4028
4520
|
if (id && !options?.upsert) {
|
|
@@ -4049,6 +4541,7 @@ var PersistService = class {
|
|
|
4049
4541
|
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
4050
4542
|
const set = { ...dataForInsert };
|
|
4051
4543
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
4544
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
|
|
4052
4545
|
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
4053
4546
|
target,
|
|
4054
4547
|
set
|
|
@@ -4091,7 +4584,8 @@ var PersistService = class {
|
|
|
4091
4584
|
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
4092
4585
|
const pgError = extractPgError(error);
|
|
4093
4586
|
if (pgError) {
|
|
4094
|
-
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
4587
|
+
const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
4588
|
+
if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
|
|
4095
4589
|
return new Error(message);
|
|
4096
4590
|
}
|
|
4097
4591
|
const causeMessage = extractCauseMessage(error);
|
|
@@ -4316,6 +4810,7 @@ var BranchService = class {
|
|
|
4316
4810
|
metadata JSONB DEFAULT '{}'
|
|
4317
4811
|
);
|
|
4318
4812
|
`));
|
|
4813
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
|
|
4319
4814
|
}
|
|
4320
4815
|
/**
|
|
4321
4816
|
* Create a new branch database by templating the source database.
|
|
@@ -4423,43 +4918,49 @@ var BranchService = class {
|
|
|
4423
4918
|
};
|
|
4424
4919
|
//#endregion
|
|
4425
4920
|
//#region src/security/rls-enforcement.ts
|
|
4921
|
+
var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
|
|
4922
|
+
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
4923
|
+
var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
4426
4924
|
/**
|
|
4427
|
-
*
|
|
4428
|
-
*
|
|
4429
|
-
* Every operation runs in one of two contexts:
|
|
4925
|
+
* Warn when the connection role shares its name with an existing schema.
|
|
4430
4926
|
*
|
|
4431
|
-
*
|
|
4432
|
-
*
|
|
4433
|
-
*
|
|
4434
|
-
*
|
|
4435
|
-
* `securityRules` are the whole authorization model; app-layer callbacks
|
|
4436
|
-
* are validation/side-effects, not a security boundary.
|
|
4927
|
+
* Postgres resolves unqualified names through `search_path`, which defaults to
|
|
4928
|
+
* `"$user", public` — and `$user` is the connection ROLE. When a schema of that
|
|
4929
|
+
* name exists it sits ahead of `public`, so every unqualified statement
|
|
4930
|
+
* silently operates on it instead:
|
|
4437
4931
|
*
|
|
4438
|
-
*
|
|
4439
|
-
* background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
|
|
4440
|
-
* owner it bypasses RLS. This is the trusted plane, equivalent to
|
|
4441
|
-
* Supabase's `service_role`.
|
|
4932
|
+
* CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
|
|
4442
4933
|
*
|
|
4443
|
-
*
|
|
4934
|
+
* Nothing errors. You get a second table of the same name in the wrong schema,
|
|
4935
|
+
* and reads that pin `public` cannot see it — which reads as "missing table" and
|
|
4936
|
+
* sends people to re-run a push that creates a *third* copy. The bootstrapper
|
|
4937
|
+
* has a whole branch dedicated to recognising the symptom after the fact.
|
|
4444
4938
|
*
|
|
4445
|
-
*
|
|
4446
|
-
*
|
|
4447
|
-
*
|
|
4448
|
-
*
|
|
4449
|
-
*
|
|
4450
|
-
*
|
|
4451
|
-
*
|
|
4452
|
-
* RLS binds. Transaction-scoped, so it composes with poolers.
|
|
4939
|
+
* Rebase shipped straight into this: it creates a schema named `rebase` while
|
|
4940
|
+
* every template named the database role `rebase` too. The scaffold uses
|
|
4941
|
+
* `rebase_app` now, and every pool Rebase opens pins `search_path=public`
|
|
4942
|
+
* (`pinSearchPath`), which covers the paths the framework controls. This covers
|
|
4943
|
+
* the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
|
|
4944
|
+
* a hand-written migration — because the hazard is a property of the two NAMES,
|
|
4945
|
+
* not of any one connection.
|
|
4453
4946
|
*
|
|
4454
|
-
*
|
|
4455
|
-
*
|
|
4456
|
-
*
|
|
4947
|
+
* A warning rather than a boot failure: the database works, the framework's own
|
|
4948
|
+
* traffic is pinned, and refusing to start over a naming choice a user may have
|
|
4949
|
+
* inherited would be worse than the risk.
|
|
4457
4950
|
*/
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4951
|
+
async function warnOnRoleSchemaCollision(run) {
|
|
4952
|
+
try {
|
|
4953
|
+
const rows = await run(`
|
|
4954
|
+
SELECT current_user AS role,
|
|
4955
|
+
EXISTS (
|
|
4956
|
+
SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
|
|
4957
|
+
) AS collides
|
|
4958
|
+
`);
|
|
4959
|
+
if (rows[0]?.collides !== true) return;
|
|
4960
|
+
const role = String(rows[0]?.role ?? "the connection role");
|
|
4961
|
+
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".`);
|
|
4962
|
+
} catch {}
|
|
4963
|
+
}
|
|
4463
4964
|
async function detectConnectionPosture(run) {
|
|
4464
4965
|
const row = (await run(`
|
|
4465
4966
|
SELECT current_user AS role,
|
|
@@ -4535,6 +5036,9 @@ async function ensureAppRole(run, schemas) {
|
|
|
4535
5036
|
await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4536
5037
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
4537
5038
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
5039
|
+
await revokeInternalTableAccess(async (text) => {
|
|
5040
|
+
await run(text);
|
|
5041
|
+
}, 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))) });
|
|
4538
5042
|
}
|
|
4539
5043
|
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
4540
5044
|
}
|
|
@@ -4620,6 +5124,40 @@ function warnOnAnonymousGrants(collections) {
|
|
|
4620
5124
|
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");
|
|
4621
5125
|
}
|
|
4622
5126
|
/**
|
|
5127
|
+
* Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
|
|
5128
|
+
*
|
|
5129
|
+
* The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
|
|
5130
|
+
* database, so nothing is broken and no policy is wrong — which is exactly why
|
|
5131
|
+
* this has to be said out loud. A silent rewrite that works forever is not a
|
|
5132
|
+
* migration, it is a second supported spelling nobody wrote down, and the next
|
|
5133
|
+
* person to read those rules will copy the old one.
|
|
5134
|
+
*
|
|
5135
|
+
* Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
|
|
5136
|
+
* `policy.rolesOverlap(...)`) compile from the model and were never affected.
|
|
5137
|
+
*/
|
|
5138
|
+
function warnOnLegacyRlsFunctions(collections) {
|
|
5139
|
+
const sites = [];
|
|
5140
|
+
for (const collection of collections) for (const rule of collection.securityRules ?? []) {
|
|
5141
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
5142
|
+
if (![usingExpr, withCheckExpr].filter((e) => e !== null).some(containsLegacyRlsCall)) continue;
|
|
5143
|
+
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
5144
|
+
if (!sites.includes(site)) sites.push(site);
|
|
5145
|
+
}
|
|
5146
|
+
if (sites.length === 0) return;
|
|
5147
|
+
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");
|
|
5148
|
+
}
|
|
5149
|
+
/** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
|
|
5150
|
+
function containsLegacyRlsCall(expr) {
|
|
5151
|
+
switch (expr.kind) {
|
|
5152
|
+
case "raw": return usesLegacyRlsFunctions(expr.sql);
|
|
5153
|
+
case "and":
|
|
5154
|
+
case "or": return expr.operands.some(containsLegacyRlsCall);
|
|
5155
|
+
case "not": return containsLegacyRlsCall(expr.operand);
|
|
5156
|
+
case "existsIn": return containsLegacyRlsCall(expr.where);
|
|
5157
|
+
default: return false;
|
|
5158
|
+
}
|
|
5159
|
+
}
|
|
5160
|
+
/**
|
|
4623
5161
|
* Reject `pgRoles` that this server can never satisfy.
|
|
4624
5162
|
*
|
|
4625
5163
|
* `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
|
|
@@ -4749,6 +5287,25 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
4749
5287
|
}
|
|
4750
5288
|
};
|
|
4751
5289
|
}
|
|
5290
|
+
/**
|
|
5291
|
+
* Build the context handed to every collection callback.
|
|
5292
|
+
*
|
|
5293
|
+
* Note `data: this.data` — `this` is whichever driver is running the
|
|
5294
|
+
* operation, so the callback's data plane inherits that driver's privilege.
|
|
5295
|
+
* On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
|
|
5296
|
+
* constructs a fresh base driver bound to the RLS-scoped transaction and
|
|
5297
|
+
* runs the operation on it, so `this.data` speaks through that connection
|
|
5298
|
+
* and policies apply. On server-context work `this` is the base driver on
|
|
5299
|
+
* the owner connection, and they do not. Pinned by the
|
|
5300
|
+
* `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
|
|
5301
|
+
* suite, because it is the kind of property that is easy to break from a
|
|
5302
|
+
* distance and impossible to notice.
|
|
5303
|
+
*
|
|
5304
|
+
* Previously returned through `as unknown as RebaseCallContext`, which
|
|
5305
|
+
* disabled checking for the whole object and let `driver` — documented in
|
|
5306
|
+
* the callbacks guide — sit on the runtime context while absent from the
|
|
5307
|
+
* contract. Both are declared now, so this is a plain typed return.
|
|
5308
|
+
*/
|
|
4752
5309
|
buildCallContext() {
|
|
4753
5310
|
return {
|
|
4754
5311
|
user: this.user,
|
|
@@ -5204,6 +5761,106 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5204
5761
|
return saved;
|
|
5205
5762
|
});
|
|
5206
5763
|
}
|
|
5764
|
+
/**
|
|
5765
|
+
* Update many rows through the same pipeline as {@link save}, in one
|
|
5766
|
+
* transaction.
|
|
5767
|
+
*
|
|
5768
|
+
* Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
|
|
5769
|
+
* same deferred notifications, same per-row error labelling — but it calls
|
|
5770
|
+
* `save` with an explicit `id` and `status: "existing"`, which is precisely
|
|
5771
|
+
* what `saveMany` cannot do: that one passes `status: "new"` and keeps the
|
|
5772
|
+
* key inside `values`, so it inserts or upserts and can never target a
|
|
5773
|
+
* particular row.
|
|
5774
|
+
*
|
|
5775
|
+
* All-or-nothing, so an id matching no row aborts the batch. A partial
|
|
5776
|
+
* update is the outcome with no good recovery: the caller cannot tell which
|
|
5777
|
+
* half landed without re-reading everything.
|
|
5778
|
+
*/
|
|
5779
|
+
async updateMany({ path, updates, collection }) {
|
|
5780
|
+
return this.db.transaction(async (tx) => {
|
|
5781
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
5782
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
5783
|
+
txDriver.client = this.client;
|
|
5784
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
5785
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
5786
|
+
const saved = [];
|
|
5787
|
+
for (let i = 0; i < updates.length; i++) {
|
|
5788
|
+
const { id, values } = updates[i];
|
|
5789
|
+
try {
|
|
5790
|
+
if (!await txDriver.fetchOne({
|
|
5791
|
+
path,
|
|
5792
|
+
id: String(id),
|
|
5793
|
+
collection
|
|
5794
|
+
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
5795
|
+
statusCode: 404,
|
|
5796
|
+
code: "NOT_FOUND"
|
|
5797
|
+
});
|
|
5798
|
+
saved.push(await txDriver.save({
|
|
5799
|
+
path,
|
|
5800
|
+
id: String(id),
|
|
5801
|
+
values,
|
|
5802
|
+
collection,
|
|
5803
|
+
status: "existing"
|
|
5804
|
+
}));
|
|
5805
|
+
} catch (error) {
|
|
5806
|
+
throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
5807
|
+
statusCode: error?.statusCode,
|
|
5808
|
+
code: error?.code,
|
|
5809
|
+
name: error?.name
|
|
5810
|
+
});
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5813
|
+
return saved;
|
|
5814
|
+
});
|
|
5815
|
+
}
|
|
5816
|
+
/**
|
|
5817
|
+
* Delete many rows in one transaction, running the full delete pipeline —
|
|
5818
|
+
* `beforeDelete`, the delete, `afterDelete` — for each.
|
|
5819
|
+
*
|
|
5820
|
+
* Looping the single-row {@link delete} rather than emitting one
|
|
5821
|
+
* `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
|
|
5822
|
+
* statement would be faster and would skip every callback, so a collection
|
|
5823
|
+
* relying on `beforeDelete` to veto or on `afterDelete` to clean up
|
|
5824
|
+
* dependents would behave differently depending on how many rows the caller
|
|
5825
|
+
* happened to delete at once. Same pipeline, one transaction.
|
|
5826
|
+
*/
|
|
5827
|
+
async deleteMany({ path, ids, collection }) {
|
|
5828
|
+
await this.db.transaction(async (tx) => {
|
|
5829
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
5830
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
5831
|
+
txDriver.client = this.client;
|
|
5832
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
5833
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
5834
|
+
for (let i = 0; i < ids.length; i++) {
|
|
5835
|
+
const id = ids[i];
|
|
5836
|
+
try {
|
|
5837
|
+
const existing = await txDriver.fetchOne({
|
|
5838
|
+
path,
|
|
5839
|
+
id: String(id),
|
|
5840
|
+
collection
|
|
5841
|
+
});
|
|
5842
|
+
if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
5843
|
+
statusCode: 404,
|
|
5844
|
+
code: "NOT_FOUND"
|
|
5845
|
+
});
|
|
5846
|
+
await txDriver.delete({
|
|
5847
|
+
row: {
|
|
5848
|
+
id: String(id),
|
|
5849
|
+
path,
|
|
5850
|
+
values: existing
|
|
5851
|
+
},
|
|
5852
|
+
collection
|
|
5853
|
+
});
|
|
5854
|
+
} catch (error) {
|
|
5855
|
+
throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
5856
|
+
statusCode: error?.statusCode,
|
|
5857
|
+
code: error?.code,
|
|
5858
|
+
name: error?.name
|
|
5859
|
+
});
|
|
5860
|
+
}
|
|
5861
|
+
}
|
|
5862
|
+
});
|
|
5863
|
+
}
|
|
5207
5864
|
async delete({ row, collection }) {
|
|
5208
5865
|
const targetPath = row.path;
|
|
5209
5866
|
const targetRow = { ...row.values ?? {} };
|
|
@@ -5781,6 +6438,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
5781
6438
|
* that rotates immediately after it.
|
|
5782
6439
|
*/
|
|
5783
6440
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
6441
|
+
/**
|
|
6442
|
+
* The assurance level the sign-in was established at — `aal2` only
|
|
6443
|
+
* where a second factor was actually presented. Carried across
|
|
6444
|
+
* rotations, because refresh is not a new authentication and has
|
|
6445
|
+
* nothing else to read the level from.
|
|
6446
|
+
*/
|
|
6447
|
+
aal: text("aal"),
|
|
5784
6448
|
userAgent: text("user_agent"),
|
|
5785
6449
|
ipAddress: text("ip_address"),
|
|
5786
6450
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -5826,6 +6490,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
5826
6490
|
secretEncrypted: text("secret_encrypted").notNull(),
|
|
5827
6491
|
friendlyName: text("friendly_name"),
|
|
5828
6492
|
verified: boolean("verified").default(false).notNull(),
|
|
6493
|
+
/**
|
|
6494
|
+
* The highest TOTP time step ever accepted for this factor. RFC 6238
|
|
6495
|
+
* §5.2 forbids accepting an OTP twice, and the ±1 step window that
|
|
6496
|
+
* exists for clock drift is also a 90-second replay window: without
|
|
6497
|
+
* this, one observed code buys a fresh session for a minute and a half.
|
|
6498
|
+
*/
|
|
6499
|
+
lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
|
|
5829
6500
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
5830
6501
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
5831
6502
|
});
|
|
@@ -5843,6 +6514,8 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
5843
6514
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
5844
6515
|
verifiedAt: timestamp("verified_at"),
|
|
5845
6516
|
ipAddress: text("ip_address"),
|
|
6517
|
+
/** Failed guesses recorded against this challenge; bounded by the route. */
|
|
6518
|
+
attempts: integer("attempts").default(0).notNull(),
|
|
5846
6519
|
expiresAt: timestamp("expires_at").notNull()
|
|
5847
6520
|
}),
|
|
5848
6521
|
recoveryCodes: tableCreator("recovery_codes", {
|
|
@@ -5919,6 +6592,26 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
5919
6592
|
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
5920
6593
|
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
5921
6594
|
*/
|
|
6595
|
+
var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
6596
|
+
/**
|
|
6597
|
+
* A string literal for the generated schema file.
|
|
6598
|
+
*
|
|
6599
|
+
* Column names, table names and enum values are all written into this file as
|
|
6600
|
+
* literals, and none of them is constrained to be quote-free: a Postgres
|
|
6601
|
+
* identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
|
|
6602
|
+
* Interpolating them raw ended the literal early — for enum values, inside
|
|
6603
|
+
* single quotes, where an apostrophe is not an edge case.
|
|
6604
|
+
*/
|
|
6605
|
+
var quote$1 = (value) => JSON.stringify(value);
|
|
6606
|
+
/** An object key: verbatim when it is an identifier, quoted otherwise. */
|
|
6607
|
+
var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
|
|
6608
|
+
/**
|
|
6609
|
+
* A property access on a generated table variable.
|
|
6610
|
+
*
|
|
6611
|
+
* `users.full name` is not an expression; `users["full name"]` is, and Drizzle
|
|
6612
|
+
* treats the two identically.
|
|
6613
|
+
*/
|
|
6614
|
+
var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
|
|
5922
6615
|
var resolveColumnName = (propName, prop) => {
|
|
5923
6616
|
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
5924
6617
|
return toSnakeCase(propName);
|
|
@@ -5979,18 +6672,25 @@ var isIdProperty = (propName, prop, collection) => {
|
|
|
5979
6672
|
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
5980
6673
|
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
5981
6674
|
};
|
|
6675
|
+
/**
|
|
6676
|
+
* The Drizzle column declaration a property compiles to, or `null` when the
|
|
6677
|
+
* property puts no column on *this* table (an inverse relation, whose column
|
|
6678
|
+
* lives on the target). Exported so it can be checked against its DDL twin
|
|
6679
|
+
* `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
|
|
6680
|
+
* with a database column and no Drizzle key.
|
|
6681
|
+
*/
|
|
5982
6682
|
var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
5983
6683
|
const colName = resolveColumnName(propName, prop);
|
|
5984
6684
|
let columnDefinition;
|
|
5985
6685
|
switch (prop.type) {
|
|
5986
6686
|
case "string": {
|
|
5987
6687
|
const stringProp = prop;
|
|
5988
|
-
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(
|
|
5989
|
-
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(
|
|
5990
|
-
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(
|
|
5991
|
-
else if (stringProp.columnType === "char") columnDefinition = `char(
|
|
5992
|
-
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(
|
|
5993
|
-
else columnDefinition = `text(
|
|
6688
|
+
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
|
|
6689
|
+
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
6690
|
+
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
6691
|
+
else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
6692
|
+
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
6693
|
+
else columnDefinition = `text(${quote$1(colName)})`;
|
|
5994
6694
|
if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
|
|
5995
6695
|
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
5996
6696
|
if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
|
|
@@ -6006,10 +6706,10 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6006
6706
|
case "number": {
|
|
6007
6707
|
const numProp = prop;
|
|
6008
6708
|
const isId = isIdProperty(propName, prop, collection);
|
|
6009
|
-
let baseType = numProp.validation?.integer || isId ? `integer(
|
|
6010
|
-
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(
|
|
6011
|
-
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(
|
|
6012
|
-
else baseType = `${numProp.columnType}(
|
|
6709
|
+
let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
|
|
6710
|
+
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
|
|
6711
|
+
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
|
|
6712
|
+
else baseType = `${numProp.columnType}(${quote$1(colName)})`;
|
|
6013
6713
|
if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
6014
6714
|
else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
6015
6715
|
columnDefinition = baseType;
|
|
@@ -6021,19 +6721,22 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6021
6721
|
break;
|
|
6022
6722
|
}
|
|
6023
6723
|
case "boolean":
|
|
6024
|
-
columnDefinition = `boolean(
|
|
6724
|
+
columnDefinition = `boolean(${quote$1(colName)})`;
|
|
6025
6725
|
break;
|
|
6026
6726
|
case "date": {
|
|
6027
6727
|
const dateProp = prop;
|
|
6028
|
-
if (dateProp.columnType === "date") columnDefinition = `date(
|
|
6029
|
-
else if (dateProp.columnType === "time") columnDefinition = `time(
|
|
6030
|
-
else columnDefinition = `timestamp(
|
|
6728
|
+
if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
|
|
6729
|
+
else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
|
|
6730
|
+
else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
|
|
6031
6731
|
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
|
|
6032
6732
|
break;
|
|
6033
6733
|
}
|
|
6034
6734
|
case "map":
|
|
6035
|
-
if (prop.columnType === "json") columnDefinition = `json(
|
|
6036
|
-
else columnDefinition = `jsonb(
|
|
6735
|
+
if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
6736
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6737
|
+
break;
|
|
6738
|
+
case "geopoint":
|
|
6739
|
+
columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6037
6740
|
break;
|
|
6038
6741
|
case "array": {
|
|
6039
6742
|
const arrayProp = prop;
|
|
@@ -6044,19 +6747,21 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6044
6747
|
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
6045
6748
|
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
6046
6749
|
}
|
|
6047
|
-
if (colType === "json") columnDefinition = `json(
|
|
6048
|
-
else if (colType === "text[]") columnDefinition = `text(
|
|
6049
|
-
else if (colType === "integer[]") columnDefinition = `integer(
|
|
6050
|
-
else if (colType === "boolean[]") columnDefinition = `boolean(
|
|
6051
|
-
else if (colType === "numeric[]") columnDefinition = `numeric(
|
|
6052
|
-
else columnDefinition = `jsonb(
|
|
6750
|
+
if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
6751
|
+
else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
|
|
6752
|
+
else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
|
|
6753
|
+
else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
|
|
6754
|
+
else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
|
|
6755
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6053
6756
|
break;
|
|
6054
6757
|
}
|
|
6055
|
-
case "vector":
|
|
6056
|
-
|
|
6758
|
+
case "vector": {
|
|
6759
|
+
const vp = prop;
|
|
6760
|
+
columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
|
|
6057
6761
|
break;
|
|
6762
|
+
}
|
|
6058
6763
|
case "binary":
|
|
6059
|
-
columnDefinition = `customType({ dataType() { return 'bytea'; } })(
|
|
6764
|
+
columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
|
|
6060
6765
|
break;
|
|
6061
6766
|
case "relation": {
|
|
6062
6767
|
const refProp = prop;
|
|
@@ -6078,30 +6783,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6078
6783
|
const required = prop.validation?.required;
|
|
6079
6784
|
const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
|
|
6080
6785
|
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
6081
|
-
let columnDef = `${baseColumn}.references(() => ${targetTableVar
|
|
6786
|
+
let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
|
|
6082
6787
|
if (required) columnDef += ".notNull()";
|
|
6083
|
-
return ` ${relation.localKey}: ${columnDef}`;
|
|
6788
|
+
return ` ${propKey(relation.localKey)}: ${columnDef}`;
|
|
6084
6789
|
}
|
|
6085
6790
|
case "reference": {
|
|
6086
6791
|
const refProp = prop;
|
|
6087
6792
|
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
|
|
6088
6793
|
if (!targetCollection) {
|
|
6089
|
-
columnDefinition = `text(
|
|
6794
|
+
columnDefinition = `text(${quote$1(colName)})`;
|
|
6090
6795
|
break;
|
|
6091
6796
|
}
|
|
6092
6797
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
6093
6798
|
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
6094
6799
|
const targetIdField = pkProp.name;
|
|
6095
|
-
const baseColumn = pkProp.type === "number" ? `integer(
|
|
6800
|
+
const baseColumn = pkProp.type === "number" ? `integer(${quote$1(colName)})` : pkProp.isUuid ? `uuid(${quote$1(colName)})` : `text(${quote$1(colName)})`;
|
|
6096
6801
|
const required = prop.validation?.required;
|
|
6097
|
-
|
|
6802
|
+
const refOptions = `{ onDelete: "${required ? "cascade" : "set null"}" }`;
|
|
6803
|
+
columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
|
|
6098
6804
|
if (required) columnDefinition += ".notNull()";
|
|
6099
|
-
return ` ${propName}: ${columnDefinition}`;
|
|
6805
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
6100
6806
|
}
|
|
6101
|
-
default:
|
|
6807
|
+
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).`);
|
|
6102
6808
|
}
|
|
6103
6809
|
if (prop.validation?.required) columnDefinition += ".notNull()";
|
|
6104
|
-
return ` ${propName}: ${columnDefinition}`;
|
|
6810
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
6105
6811
|
};
|
|
6106
6812
|
/**
|
|
6107
6813
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
@@ -6134,7 +6840,7 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
|
|
|
6134
6840
|
parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
|
|
6135
6841
|
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
6136
6842
|
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
6137
|
-
return ` pgPolicy(
|
|
6843
|
+
return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
|
|
6138
6844
|
};
|
|
6139
6845
|
/**
|
|
6140
6846
|
* Computes a deterministic shared relation name for Drizzle.
|
|
@@ -6168,11 +6874,13 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
|
6168
6874
|
}
|
|
6169
6875
|
return fallback;
|
|
6170
6876
|
};
|
|
6171
|
-
var generateSchema = async (
|
|
6877
|
+
var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
6878
|
+
const collections = sortCollectionsBySlug(relationalCollections(allCollections));
|
|
6172
6879
|
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
6173
6880
|
const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
|
|
6174
6881
|
const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
|
|
6175
6882
|
const hasBinary = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "binary"));
|
|
6883
|
+
const hasSearch = collections.some((c) => buildSearchColumnSpec(c) !== void 0);
|
|
6176
6884
|
const pgCoreImports = [
|
|
6177
6885
|
"primaryKey",
|
|
6178
6886
|
"pgTable",
|
|
@@ -6197,7 +6905,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6197
6905
|
];
|
|
6198
6906
|
if (hasUuid) pgCoreImports.push("uuid");
|
|
6199
6907
|
if (hasVector) pgCoreImports.push("vector");
|
|
6200
|
-
if (hasBinary) pgCoreImports.push("customType");
|
|
6908
|
+
if (hasBinary || hasSearch) pgCoreImports.push("customType");
|
|
6201
6909
|
const uniqueSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
|
|
6202
6910
|
if (uniqueSchemas.length > 0) pgCoreImports.push("pgSchema");
|
|
6203
6911
|
schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
|
|
@@ -6218,7 +6926,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6218
6926
|
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
6219
6927
|
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);
|
|
6220
6928
|
if (values.length > 0) {
|
|
6221
|
-
schemaContent += `export const ${enumVarName} = pgEnum(
|
|
6929
|
+
schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
|
|
6222
6930
|
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
6223
6931
|
}
|
|
6224
6932
|
}
|
|
@@ -6279,6 +6987,11 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6279
6987
|
const columnString = getDrizzleColumn(propName, prop, collection, collections);
|
|
6280
6988
|
if (columnString) columns.add(columnString);
|
|
6281
6989
|
});
|
|
6990
|
+
const searchSpec = buildSearchColumnSpec(collection);
|
|
6991
|
+
if (searchSpec) {
|
|
6992
|
+
columns.add(` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}").generatedAlwaysAs(sql\`${searchSpec.expression}\`)`);
|
|
6993
|
+
if (searchSpec.fuzzy) columns.add(` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}").generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`);
|
|
6994
|
+
}
|
|
6282
6995
|
if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
|
|
6283
6996
|
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
6284
6997
|
const securityRules = getEffectiveSecurityRules(collection);
|
|
@@ -6315,9 +7028,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6315
7028
|
break;
|
|
6316
7029
|
}
|
|
6317
7030
|
} catch {}
|
|
6318
|
-
tableRelations.push(`
|
|
7031
|
+
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 })`);
|
|
6319
7032
|
const targetRelationName = inverseRelationName ? inverseRelationName : `${tableName}_${relation.through.targetColumn}`;
|
|
6320
|
-
tableRelations.push(`
|
|
7033
|
+
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 })`);
|
|
6321
7034
|
}
|
|
6322
7035
|
} else {
|
|
6323
7036
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
@@ -6332,7 +7045,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6332
7045
|
switch (rel.kind) {
|
|
6333
7046
|
case "belongsTo": {
|
|
6334
7047
|
const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
|
|
6335
|
-
tableRelations.push(`
|
|
7048
|
+
tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6336
7049
|
break;
|
|
6337
7050
|
}
|
|
6338
7051
|
case "hasOne":
|
|
@@ -6363,7 +7076,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6363
7076
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
6364
7077
|
const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
|
|
6365
7078
|
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
6366
|
-
tableRelations.push(`
|
|
7079
|
+
tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6367
7080
|
emittedRelationNames.add(deduplicationKey);
|
|
6368
7081
|
}
|
|
6369
7082
|
}
|
|
@@ -6982,6 +7695,8 @@ var ChannelHistoryStore = class {
|
|
|
6982
7695
|
last_seq BIGINT NOT NULL
|
|
6983
7696
|
)
|
|
6984
7697
|
`);
|
|
7698
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
|
|
7699
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
|
|
6985
7700
|
this.tablesReady = true;
|
|
6986
7701
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
6987
7702
|
}
|
|
@@ -7165,6 +7880,7 @@ var ChannelPresenceStore = class {
|
|
|
7165
7880
|
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
7166
7881
|
ON rebase.channel_presence (last_seen)
|
|
7167
7882
|
`);
|
|
7883
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
|
|
7168
7884
|
this.tablesReady = true;
|
|
7169
7885
|
}
|
|
7170
7886
|
/** Record (or refresh) a client's presence. */
|
|
@@ -7556,6 +8272,12 @@ var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
|
7556
8272
|
var RealtimeService = class RealtimeService extends EventEmitter {
|
|
7557
8273
|
db;
|
|
7558
8274
|
registry;
|
|
8275
|
+
/**
|
|
8276
|
+
* Declares to the multi-engine router that channel frames can be handled
|
|
8277
|
+
* here. Read by `createRoutedRealtimeService`, which otherwise would have to
|
|
8278
|
+
* guess — and guessed "the default provider", whichever engine that is.
|
|
8279
|
+
*/
|
|
8280
|
+
supportsChannels = true;
|
|
7559
8281
|
clients = /* @__PURE__ */ new Map();
|
|
7560
8282
|
channels = /* @__PURE__ */ new Map();
|
|
7561
8283
|
presence = /* @__PURE__ */ new Map();
|
|
@@ -7602,6 +8324,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7602
8324
|
* so a hot channel logs the problem once rather than once per message.
|
|
7603
8325
|
*/
|
|
7604
8326
|
oversizedBroadcastWarned = /* @__PURE__ */ new Set();
|
|
8327
|
+
/**
|
|
8328
|
+
* Optional narrowing on top of the membership floor — see
|
|
8329
|
+
* {@link ChannelAuthorizer}. Unset by default, which leaves membership as
|
|
8330
|
+
* the whole of the rule.
|
|
8331
|
+
*/
|
|
8332
|
+
channelAuthorizer;
|
|
8333
|
+
/**
|
|
8334
|
+
* Whether a notification from another instance has ever arrived.
|
|
8335
|
+
*
|
|
8336
|
+
* The entity LISTEN handler sees a foreign `sid` on every cross-instance
|
|
8337
|
+
* change, which is proof that this deployment runs more than one pod — the
|
|
8338
|
+
* one fact needed to tell "the memory bus is fine here" from "broadcast and
|
|
8339
|
+
* presence silently reach a fraction of your users".
|
|
8340
|
+
*/
|
|
8341
|
+
foreignInstanceSeen = false;
|
|
8342
|
+
/** So the multi-pod memory-bus warning is emitted once, not once per join. */
|
|
8343
|
+
memoryBusWarned = false;
|
|
7605
8344
|
presenceInterval;
|
|
7606
8345
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
7607
8346
|
/** How often stale roster rows from other instances are reaped. */
|
|
@@ -7695,7 +8434,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7695
8434
|
limit: config.limit,
|
|
7696
8435
|
startAfter: config.startAfter,
|
|
7697
8436
|
databaseId: config.databaseId,
|
|
7698
|
-
searchString: config.searchString
|
|
8437
|
+
searchString: config.searchString,
|
|
8438
|
+
searchExplain: config.searchExplain
|
|
7699
8439
|
}
|
|
7700
8440
|
});
|
|
7701
8441
|
if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
|
|
@@ -7775,26 +8515,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7775
8515
|
await this.handleUnsubscribe(clientId, message.subscriptionId);
|
|
7776
8516
|
break;
|
|
7777
8517
|
case "join_channel":
|
|
7778
|
-
this.joinChannel(clientId, payload?.channel);
|
|
7779
|
-
break;
|
|
7780
8518
|
case "leave_channel":
|
|
7781
|
-
this.leaveChannel(clientId, payload?.channel);
|
|
7782
|
-
break;
|
|
7783
8519
|
case "broadcast":
|
|
7784
|
-
this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
|
|
7785
|
-
break;
|
|
7786
8520
|
case "channel_history":
|
|
7787
|
-
await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
|
|
7788
|
-
break;
|
|
7789
8521
|
case "presence_track":
|
|
7790
|
-
this.joinChannel(clientId, payload?.channel);
|
|
7791
|
-
this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
|
|
7792
|
-
break;
|
|
7793
8522
|
case "presence_untrack":
|
|
7794
|
-
this.removePresence(clientId, payload?.channel);
|
|
7795
|
-
break;
|
|
7796
8523
|
case "presence_state":
|
|
7797
|
-
this.
|
|
8524
|
+
await this.handleChannelMessage(clientId, message.type, payload, authContext);
|
|
7798
8525
|
break;
|
|
7799
8526
|
default: this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
|
|
7800
8527
|
}
|
|
@@ -7809,30 +8536,40 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7809
8536
|
this.sendError(clientId, msg, subscriptionId);
|
|
7810
8537
|
return;
|
|
7811
8538
|
}
|
|
7812
|
-
|
|
8539
|
+
if (request.vectorSearch) {
|
|
8540
|
+
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.";
|
|
8541
|
+
logger.warn(`[RealtimeService] ${msg}`);
|
|
8542
|
+
this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
|
|
8543
|
+
return;
|
|
8544
|
+
}
|
|
8545
|
+
let boundedLimit;
|
|
8546
|
+
try {
|
|
8547
|
+
boundedLimit = resolveClientListLimit(request.limit);
|
|
8548
|
+
} catch (e) {
|
|
8549
|
+
if (!(e instanceof ListLimitError)) throw e;
|
|
8550
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
8551
|
+
this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
|
|
8552
|
+
return;
|
|
8553
|
+
}
|
|
7813
8554
|
this._subscriptions.set(subscriptionId, {
|
|
7814
8555
|
clientId,
|
|
7815
8556
|
type: "collection",
|
|
7816
8557
|
path: request.path,
|
|
7817
8558
|
collectionRequest: {
|
|
7818
8559
|
filter: request.filter,
|
|
8560
|
+
logical: request.logical,
|
|
7819
8561
|
orderBy: request.orderBy,
|
|
7820
8562
|
order: request.order,
|
|
7821
8563
|
limit: boundedLimit,
|
|
8564
|
+
offset: request.offset,
|
|
7822
8565
|
startAfter: request.startAfter,
|
|
7823
8566
|
databaseId: request.collection?.databaseId,
|
|
7824
|
-
searchString: request.searchString
|
|
8567
|
+
searchString: request.searchString,
|
|
8568
|
+
searchExplain: request.searchExplain
|
|
7825
8569
|
},
|
|
7826
8570
|
authContext
|
|
7827
8571
|
});
|
|
7828
|
-
const rows = await this.fetchCollectionWithAuth(request.path,
|
|
7829
|
-
filter: request.filter,
|
|
7830
|
-
orderBy: request.orderBy,
|
|
7831
|
-
order: request.order,
|
|
7832
|
-
limit: boundedLimit,
|
|
7833
|
-
startAfter: request.startAfter,
|
|
7834
|
-
searchString: request.searchString
|
|
7835
|
-
}, authContext);
|
|
8572
|
+
const rows = await this.fetchCollectionWithAuth(request.path, this._subscriptions.get(subscriptionId).collectionRequest, authContext);
|
|
7836
8573
|
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
7837
8574
|
} catch (error) {
|
|
7838
8575
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -8009,13 +8746,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8009
8746
|
let fetchedEntities;
|
|
8010
8747
|
if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8011
8748
|
filter: collectionRequest.filter,
|
|
8749
|
+
logical: collectionRequest.logical,
|
|
8012
8750
|
orderBy: collectionRequest.orderBy,
|
|
8013
8751
|
order: collectionRequest.order,
|
|
8014
8752
|
limit: collectionRequest.limit,
|
|
8015
|
-
databaseId: collectionRequest.databaseId
|
|
8753
|
+
databaseId: collectionRequest.databaseId,
|
|
8754
|
+
searchExplain: collectionRequest.searchExplain
|
|
8016
8755
|
});
|
|
8017
8756
|
else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
8018
8757
|
filter: collectionRequest.filter,
|
|
8758
|
+
logical: collectionRequest.logical,
|
|
8019
8759
|
orderBy: collectionRequest.orderBy,
|
|
8020
8760
|
order: collectionRequest.order,
|
|
8021
8761
|
limit: collectionRequest.limit,
|
|
@@ -8068,13 +8808,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8068
8808
|
}
|
|
8069
8809
|
if (collectionRequest.searchString) return await this.dataService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8070
8810
|
filter: collectionRequest.filter,
|
|
8811
|
+
logical: collectionRequest.logical,
|
|
8071
8812
|
orderBy: collectionRequest.orderBy,
|
|
8072
8813
|
order: collectionRequest.order,
|
|
8073
8814
|
limit: collectionRequest.limit,
|
|
8074
|
-
databaseId: collectionRequest.databaseId
|
|
8815
|
+
databaseId: collectionRequest.databaseId,
|
|
8816
|
+
searchExplain: collectionRequest.searchExplain
|
|
8075
8817
|
});
|
|
8076
8818
|
return await this.dataService.fetchCollection(notifyPath, {
|
|
8077
8819
|
filter: collectionRequest.filter,
|
|
8820
|
+
logical: collectionRequest.logical,
|
|
8078
8821
|
orderBy: collectionRequest.orderBy,
|
|
8079
8822
|
order: collectionRequest.order,
|
|
8080
8823
|
limit: collectionRequest.limit,
|
|
@@ -8258,12 +9001,148 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8258
9001
|
}
|
|
8259
9002
|
return parentPaths;
|
|
8260
9003
|
}
|
|
9004
|
+
/**
|
|
9005
|
+
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
9006
|
+
*
|
|
9007
|
+
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
9008
|
+
* be built on, kept deliberately separate from the membership floor so the
|
|
9009
|
+
* floor holds whether or not anyone uses it.
|
|
9010
|
+
*/
|
|
9011
|
+
setChannelAuthorizer(authorizer) {
|
|
9012
|
+
this.channelAuthorizer = authorizer;
|
|
9013
|
+
}
|
|
9014
|
+
/** Which action each channel frame is asking to perform. */
|
|
9015
|
+
static CHANNEL_ACTIONS = {
|
|
9016
|
+
join_channel: "join",
|
|
9017
|
+
broadcast: "broadcast",
|
|
9018
|
+
channel_history: "history",
|
|
9019
|
+
presence_track: "join",
|
|
9020
|
+
presence_state: "presence"
|
|
9021
|
+
};
|
|
9022
|
+
/**
|
|
9023
|
+
* The one door every channel frame comes through.
|
|
9024
|
+
*
|
|
9025
|
+
* Returns synchronously — and so dispatches synchronously — unless an
|
|
9026
|
+
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
9027
|
+
* `presence_state` and `channel_history` back to back on connect, and the
|
|
9028
|
+
* socket's message handler processes each frame up to its first `await`,
|
|
9029
|
+
* so a gate that always yielded would let the reads overtake the join that
|
|
9030
|
+
* is about to authorize them.
|
|
9031
|
+
*/
|
|
9032
|
+
handleChannelMessage(clientId, type, payload, authContext) {
|
|
9033
|
+
const channel = payload?.channel;
|
|
9034
|
+
if (type === "leave_channel") {
|
|
9035
|
+
this.leaveChannel(clientId, channel);
|
|
9036
|
+
return;
|
|
9037
|
+
}
|
|
9038
|
+
if (type === "presence_untrack") {
|
|
9039
|
+
this.removePresence(clientId, channel);
|
|
9040
|
+
return;
|
|
9041
|
+
}
|
|
9042
|
+
const action = RealtimeService.CHANNEL_ACTIONS[type];
|
|
9043
|
+
const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
|
|
9044
|
+
if (allowed === false) return;
|
|
9045
|
+
if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
9046
|
+
return allowed.then((ok) => {
|
|
9047
|
+
if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
9048
|
+
});
|
|
9049
|
+
}
|
|
9050
|
+
/** Perform an already-authorized channel frame. */
|
|
9051
|
+
dispatchChannelMessage(clientId, type, channel, payload) {
|
|
9052
|
+
switch (type) {
|
|
9053
|
+
case "join_channel":
|
|
9054
|
+
this.joinChannel(clientId, channel);
|
|
9055
|
+
return;
|
|
9056
|
+
case "broadcast":
|
|
9057
|
+
this.broadcastToChannel(clientId, channel, payload?.event, payload?.payload);
|
|
9058
|
+
return;
|
|
9059
|
+
case "channel_history": return this.handleChannelHistoryRequest(clientId, channel, payload?.sinceSeq, payload?.limit);
|
|
9060
|
+
case "presence_track":
|
|
9061
|
+
this.joinChannel(clientId, channel);
|
|
9062
|
+
this.trackPresence(clientId, channel, payload?.state ?? {});
|
|
9063
|
+
return;
|
|
9064
|
+
case "presence_state":
|
|
9065
|
+
this.sendPresenceState(clientId, channel);
|
|
9066
|
+
return;
|
|
9067
|
+
}
|
|
9068
|
+
}
|
|
9069
|
+
/**
|
|
9070
|
+
* Decide whether a client may perform an action on a channel.
|
|
9071
|
+
*
|
|
9072
|
+
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
9073
|
+
* its retained history and broadcasting into it all require that this client
|
|
9074
|
+
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
9075
|
+
* the channel — but it is not the bar that was there before, which was none
|
|
9076
|
+
* at all: `channel_history` and `presence_state` answered any socket about
|
|
9077
|
+
* any channel, and a broadcast fanned out to members the sender had never
|
|
9078
|
+
* joined. Two internal tables (`rebase.channel_presence`,
|
|
9079
|
+
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
9080
|
+
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
9081
|
+
*
|
|
9082
|
+
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
9083
|
+
* this method is where it is consulted, and the only place.
|
|
9084
|
+
*/
|
|
9085
|
+
authorizeChannelAction(clientId, channel, action, authContext) {
|
|
9086
|
+
if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
|
|
9087
|
+
this.denyChannelAction(clientId, channel, action, "not a member of the channel");
|
|
9088
|
+
return false;
|
|
9089
|
+
}
|
|
9090
|
+
const authorizer = this.channelAuthorizer;
|
|
9091
|
+
if (!authorizer) return true;
|
|
9092
|
+
let verdict;
|
|
9093
|
+
try {
|
|
9094
|
+
verdict = authorizer({
|
|
9095
|
+
channel,
|
|
9096
|
+
action,
|
|
9097
|
+
clientId,
|
|
9098
|
+
user: authContext
|
|
9099
|
+
});
|
|
9100
|
+
} catch (error) {
|
|
9101
|
+
logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
|
|
9102
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
9103
|
+
return false;
|
|
9104
|
+
}
|
|
9105
|
+
if (typeof verdict === "boolean") {
|
|
9106
|
+
if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
9107
|
+
return verdict;
|
|
9108
|
+
}
|
|
9109
|
+
return verdict.then((ok) => {
|
|
9110
|
+
if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
9111
|
+
return ok;
|
|
9112
|
+
}, (error) => {
|
|
9113
|
+
logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
|
|
9114
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
9115
|
+
return false;
|
|
9116
|
+
});
|
|
9117
|
+
}
|
|
9118
|
+
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
9119
|
+
denyChannelAction(clientId, channel, action, reason) {
|
|
9120
|
+
this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
|
|
9121
|
+
this.sendError(clientId, `Refused ${action} on channel "${channel}": ${reason}`, void 0, "CHANNEL_FORBIDDEN");
|
|
9122
|
+
}
|
|
8261
9123
|
/** Join a broadcast channel */
|
|
8262
9124
|
joinChannel(clientId, channel) {
|
|
8263
9125
|
if (!this.channels.has(channel)) this.channels.set(channel, /* @__PURE__ */ new Set());
|
|
8264
9126
|
this.channels.get(channel).add(clientId);
|
|
9127
|
+
this.warnIfMemoryBusOnMultiplePods();
|
|
8265
9128
|
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
8266
9129
|
}
|
|
9130
|
+
/**
|
|
9131
|
+
* Say something the first time channels are used on a deployment that is
|
|
9132
|
+
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
9133
|
+
*
|
|
9134
|
+
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
9135
|
+
* the case where the operator already knew a bus mattered. The common
|
|
9136
|
+
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
9137
|
+
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
9138
|
+
* fraction of the room. The evidence is already in the process, so use it.
|
|
9139
|
+
*/
|
|
9140
|
+
warnIfMemoryBusOnMultiplePods() {
|
|
9141
|
+
if (this.memoryBusWarned) return;
|
|
9142
|
+
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
9143
|
+
this.memoryBusWarned = true;
|
|
9144
|
+
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.");
|
|
9145
|
+
}
|
|
8267
9146
|
/** Leave a broadcast channel */
|
|
8268
9147
|
leaveChannel(clientId, channel) {
|
|
8269
9148
|
const members = this.channels.get(channel);
|
|
@@ -8941,6 +9820,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8941
9820
|
try {
|
|
8942
9821
|
const { sid, p, eid, db } = JSON.parse(msg.payload);
|
|
8943
9822
|
if (sid === this.instanceId) return;
|
|
9823
|
+
this.foreignInstanceSeen = true;
|
|
8944
9824
|
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
|
|
8945
9825
|
let refetchedRow = null;
|
|
8946
9826
|
try {
|
|
@@ -9131,7 +10011,7 @@ function createBackupCron(config) {
|
|
|
9131
10011
|
enabled: config.enabled ?? true,
|
|
9132
10012
|
timeoutSeconds: 3600,
|
|
9133
10013
|
async handler({ log }) {
|
|
9134
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
10014
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-Bww-Lg0s.js").then((n) => n.r);
|
|
9135
10015
|
const { destination } = config;
|
|
9136
10016
|
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 }).`);
|
|
9137
10017
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -9203,6 +10083,54 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
|
|
|
9203
10083
|
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
9204
10084
|
var asColumns = (value) => Array.isArray(value) ? value : [value];
|
|
9205
10085
|
/**
|
|
10086
|
+
* Distinguish "this column name is wrong" from "the generated schema is old".
|
|
10087
|
+
*
|
|
10088
|
+
* They present identically here — a relation asks for a column the registered
|
|
10089
|
+
* table does not have — but they are opposite problems with opposite fixes, and
|
|
10090
|
+
* getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
|
|
10091
|
+
* projects.
|
|
10092
|
+
*
|
|
10093
|
+
* The registered table is not the database. It comes from the project's
|
|
10094
|
+
* checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
|
|
10095
|
+
* derives foreign-key names: `categories` yields `category_id` where it used to
|
|
10096
|
+
* yield `categorie_id`. Boot-ensure renames the database column to match, so by
|
|
10097
|
+
* the time this runs the *database* is correct and the *generated module* is the
|
|
10098
|
+
* stale one. Reporting "not a column" then points at the wrong artifact, and the
|
|
10099
|
+
* generic fix — "set `through.targetColumn` to one of: …", listing the legacy
|
|
10100
|
+
* name because that is what the stale module still has — talks the reader into
|
|
10101
|
+
* pinning a column that no longer exists.
|
|
10102
|
+
*
|
|
10103
|
+
* So when the wanted name is what the current rule derives, and the table
|
|
10104
|
+
* carries what the *previous* rule would have derived from the same source, say
|
|
10105
|
+
* that instead.
|
|
10106
|
+
*
|
|
10107
|
+
* @param wanted the column the relation asks for
|
|
10108
|
+
* @param available every column the registered table has
|
|
10109
|
+
* @param sources names the default could have been derived from (a slug, a
|
|
10110
|
+
* relation name) — checking against these rather than guessing
|
|
10111
|
+
* backwards from `wanted` keeps the match exact
|
|
10112
|
+
*/
|
|
10113
|
+
function staleCodegenRename(wanted, available, sources) {
|
|
10114
|
+
for (const source of sources) {
|
|
10115
|
+
if (!source) continue;
|
|
10116
|
+
const current = generateForeignKeyName(source);
|
|
10117
|
+
const legacy = legacyForeignKeyName(source);
|
|
10118
|
+
if (current !== wanted || legacy === current) continue;
|
|
10119
|
+
if (available.has(legacy) && !available.has(current)) return {
|
|
10120
|
+
legacy,
|
|
10121
|
+
current
|
|
10122
|
+
};
|
|
10123
|
+
}
|
|
10124
|
+
return null;
|
|
10125
|
+
}
|
|
10126
|
+
/** The shared explanation, so every relation kind reports it identically. */
|
|
10127
|
+
function staleCodegenDefect(table, { legacy, current }) {
|
|
10128
|
+
return {
|
|
10129
|
+
problem: `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this release derives \`${current}\` — the generated schema predates the foreign-key naming fix and no longer describes the database`,
|
|
10130
|
+
fix: `regenerate it with \`rebase schema generate\` (or \`pnpm run schema:generate\`). The database column has already been renamed for you at boot, so nothing else is needed. To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
|
|
10131
|
+
};
|
|
10132
|
+
}
|
|
10133
|
+
/**
|
|
9206
10134
|
* Relations whose names do not resolve against the registered schema.
|
|
9207
10135
|
*
|
|
9208
10136
|
* Fails open wherever it cannot see enough to be sure — an unregistered source
|
|
@@ -9249,19 +10177,31 @@ function findRelationDefects(collections, registry) {
|
|
|
9249
10177
|
const targetColumns = columnNames(targetTable);
|
|
9250
10178
|
switch (relation.kind) {
|
|
9251
10179
|
case "belongsTo":
|
|
9252
|
-
if (!sourceColumns.has(relation.localKey))
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
10180
|
+
if (!sourceColumns.has(relation.localKey)) {
|
|
10181
|
+
const stale = staleCodegenRename(relation.localKey, sourceColumns, [relation.relationName, targetCollection.slug]);
|
|
10182
|
+
defects.push(stale ? {
|
|
10183
|
+
...at,
|
|
10184
|
+
...staleCodegenDefect(sourceTableName, stale)
|
|
10185
|
+
} : {
|
|
10186
|
+
...at,
|
|
10187
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
10188
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
10189
|
+
});
|
|
10190
|
+
}
|
|
9257
10191
|
break;
|
|
9258
10192
|
case "hasOne":
|
|
9259
10193
|
case "hasMany":
|
|
9260
|
-
if (!targetColumns.has(relation.foreignKeyOnTarget))
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
10194
|
+
if (!targetColumns.has(relation.foreignKeyOnTarget)) {
|
|
10195
|
+
const stale = staleCodegenRename(relation.foreignKeyOnTarget, targetColumns, [collection.slug]);
|
|
10196
|
+
defects.push(stale ? {
|
|
10197
|
+
...at,
|
|
10198
|
+
...staleCodegenDefect(targetTableName, stale)
|
|
10199
|
+
} : {
|
|
10200
|
+
...at,
|
|
10201
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
10202
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
10203
|
+
});
|
|
10204
|
+
}
|
|
9265
10205
|
if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
|
|
9266
10206
|
...at,
|
|
9267
10207
|
problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
|
|
@@ -9280,11 +10220,21 @@ function findRelationDefects(collections, registry) {
|
|
|
9280
10220
|
break;
|
|
9281
10221
|
}
|
|
9282
10222
|
const junctionColumns = columnNames(junction);
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
10223
|
+
const derivedFrom = {
|
|
10224
|
+
sourceColumn: [collection.slug],
|
|
10225
|
+
targetColumn: [targetCollection.slug]
|
|
10226
|
+
};
|
|
10227
|
+
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) {
|
|
10228
|
+
const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
|
|
10229
|
+
defects.push(stale ? {
|
|
10230
|
+
...at,
|
|
10231
|
+
...staleCodegenDefect(table, stale)
|
|
10232
|
+
} : {
|
|
10233
|
+
...at,
|
|
10234
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
10235
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
10236
|
+
});
|
|
10237
|
+
}
|
|
9288
10238
|
break;
|
|
9289
10239
|
}
|
|
9290
10240
|
case "via": {
|
|
@@ -9356,7 +10306,16 @@ function assertRelationsResolve(collections, registry) {
|
|
|
9356
10306
|
const defects = findRelationDefects(collections, registry);
|
|
9357
10307
|
if (defects.length === 0) return;
|
|
9358
10308
|
const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
|
|
9359
|
-
throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against
|
|
10309
|
+
throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against \`backend/src/schema.generated.ts\`.
|
|
10310
|
+
|
|
10311
|
+
Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
|
|
10312
|
+
|
|
10313
|
+
If the database was migrated recently — an upgrade, a \`db push\`, a restore — this file is
|
|
10314
|
+
probably older than the schema it describes. Regenerate it before changing anything else:
|
|
10315
|
+
|
|
10316
|
+
rebase schema generate
|
|
10317
|
+
|
|
10318
|
+
If it is already current, then the collection is what disagrees with it:
|
|
9360
10319
|
|
|
9361
10320
|
` + lines.join("\n\n") + "\n");
|
|
9362
10321
|
}
|
|
@@ -9567,7 +10526,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9567
10526
|
if (idProp) {
|
|
9568
10527
|
const isId = "isId" in idProp ? idProp.isId : void 0;
|
|
9569
10528
|
if (isId === "uuid") userIdType = "UUID";
|
|
9570
|
-
else if (isId === "
|
|
10529
|
+
else if (isId === "increment") userIdType = "INTEGER";
|
|
9571
10530
|
}
|
|
9572
10531
|
}
|
|
9573
10532
|
try {
|
|
@@ -9600,43 +10559,42 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9600
10559
|
const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
|
|
9601
10560
|
const emailLowerUniqueIndex = authIdentifier("email_lower_key");
|
|
9602
10561
|
const verificationTokenIndex = authIdentifier("email_verification_token_idx");
|
|
10562
|
+
const usersColumnDdl = AUTH_USERS_COLUMNS.map((spec) => spec.column === "email" ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)` : `${spec.column} ${authUsersColumnSql(spec)}`).join(",\n ");
|
|
9603
10563
|
await db.execute(sql`
|
|
9604
10564
|
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
9605
10565
|
id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
|
|
9606
|
-
|
|
9607
|
-
display_name TEXT,
|
|
9608
|
-
photo_url TEXT,
|
|
9609
|
-
roles TEXT[] DEFAULT '{}' NOT NULL,
|
|
9610
|
-
password_hash TEXT,
|
|
9611
|
-
email_verified BOOLEAN DEFAULT FALSE NOT NULL,
|
|
9612
|
-
email_verification_token TEXT,
|
|
9613
|
-
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
9614
|
-
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
9615
|
-
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
9616
|
-
tokens_valid_after TIMESTAMP WITH TIME ZONE,
|
|
9617
|
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
9618
|
-
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
10566
|
+
${sql.raw(usersColumnDdl)}
|
|
9619
10567
|
)
|
|
9620
10568
|
`);
|
|
9621
|
-
|
|
9622
|
-
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
9623
|
-
BEGIN
|
|
9624
|
-
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
9625
|
-
NEW.uid := NEW.user_id;
|
|
9626
|
-
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
9627
|
-
NEW.user_id := NEW.uid;
|
|
9628
|
-
END IF;
|
|
9629
|
-
RETURN NEW;
|
|
9630
|
-
END $$ LANGUAGE plpgsql
|
|
9631
|
-
`);
|
|
9632
|
-
for (const authTable of [
|
|
10569
|
+
const legacyFkTables = [
|
|
9633
10570
|
"user_identities",
|
|
9634
10571
|
"refresh_tokens",
|
|
9635
10572
|
"password_reset_tokens",
|
|
9636
10573
|
"magic_link_tokens",
|
|
9637
10574
|
"mfa_factors",
|
|
9638
10575
|
"recovery_codes"
|
|
9639
|
-
]
|
|
10576
|
+
];
|
|
10577
|
+
const legacyFkTableList = legacyFkTables.map((t) => `'${t}'`).join(", ");
|
|
10578
|
+
const legacyUserIdPresent = await db.execute(sql`
|
|
10579
|
+
SELECT 1
|
|
10580
|
+
FROM information_schema.columns
|
|
10581
|
+
WHERE table_schema = ${authSchema}
|
|
10582
|
+
AND table_name IN (${sql.raw(legacyFkTableList)})
|
|
10583
|
+
AND column_name = 'user_id'
|
|
10584
|
+
LIMIT 1
|
|
10585
|
+
`);
|
|
10586
|
+
if (legacyUserIdPresent.rows.length > 0) await db.execute(sql`
|
|
10587
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
10588
|
+
BEGIN
|
|
10589
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
10590
|
+
NEW.uid := NEW.user_id;
|
|
10591
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
10592
|
+
NEW.user_id := NEW.uid;
|
|
10593
|
+
END IF;
|
|
10594
|
+
RETURN NEW;
|
|
10595
|
+
END $$ LANGUAGE plpgsql
|
|
10596
|
+
`);
|
|
10597
|
+
for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
|
|
9640
10598
|
const qualified = `"${authSchema}"."${authTable}"`;
|
|
9641
10599
|
await db.execute(sql`
|
|
9642
10600
|
DO $$
|
|
@@ -9757,54 +10715,50 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9757
10715
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
9758
10716
|
)
|
|
9759
10717
|
`);
|
|
9760
|
-
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
|
|
9761
10718
|
await db.transaction(async (tx) => {
|
|
9762
10719
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
9763
|
-
await tx.execute(sql
|
|
9764
|
-
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
|
|
9765
|
-
SELECT COALESCE(
|
|
9766
|
-
NULLIF(current_setting('app.uid', true), ''),
|
|
9767
|
-
NULLIF(current_setting('app.user_id', true), '')
|
|
9768
|
-
);
|
|
9769
|
-
$$ LANGUAGE sql STABLE
|
|
9770
|
-
`);
|
|
9771
|
-
await tx.execute(sql`
|
|
9772
|
-
CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
|
|
9773
|
-
SELECT COALESCE(
|
|
9774
|
-
NULLIF(current_setting('app.jwt', true), ''),
|
|
9775
|
-
'{}'
|
|
9776
|
-
)::jsonb;
|
|
9777
|
-
$$ LANGUAGE sql STABLE
|
|
9778
|
-
`);
|
|
9779
|
-
await tx.execute(sql`
|
|
9780
|
-
CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
|
|
9781
|
-
SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
|
|
9782
|
-
$$ LANGUAGE sql STABLE
|
|
9783
|
-
`);
|
|
10720
|
+
for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
|
|
9784
10721
|
});
|
|
9785
|
-
for (const
|
|
9786
|
-
"
|
|
9787
|
-
|
|
9788
|
-
"roles TEXT[] DEFAULT '{}' NOT NULL",
|
|
9789
|
-
"password_hash TEXT",
|
|
9790
|
-
"email_verified BOOLEAN DEFAULT FALSE NOT NULL",
|
|
9791
|
-
"email_verification_token TEXT",
|
|
9792
|
-
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
9793
|
-
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
9794
|
-
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
9795
|
-
"tokens_valid_after TIMESTAMP WITH TIME ZONE",
|
|
9796
|
-
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
9797
|
-
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
9798
|
-
]) await db.execute(sql`
|
|
10722
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
10723
|
+
if (spec.column === "email") continue;
|
|
10724
|
+
await db.execute(sql`
|
|
9799
10725
|
ALTER TABLE ${sql.raw(usersTableName)}
|
|
9800
|
-
ADD COLUMN IF NOT EXISTS ${sql.raw(
|
|
10726
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
|
|
9801
10727
|
`);
|
|
9802
|
-
|
|
9803
|
-
|
|
10728
|
+
}
|
|
10729
|
+
const usersColumnRows = (await db.execute(sql`
|
|
10730
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
9804
10731
|
FROM information_schema.columns
|
|
9805
10732
|
WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
|
|
9806
|
-
`);
|
|
9807
|
-
const usersColumnTypes = new Map(
|
|
10733
|
+
`)).rows;
|
|
10734
|
+
const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
|
|
10735
|
+
const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
|
|
10736
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
10737
|
+
const state = usersColumnState.get(spec.column);
|
|
10738
|
+
if (!state) continue;
|
|
10739
|
+
if (spec.default !== void 0 && state.column_default === null) {
|
|
10740
|
+
await db.execute(sql`
|
|
10741
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
10742
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
|
|
10743
|
+
`);
|
|
10744
|
+
logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
|
|
10745
|
+
}
|
|
10746
|
+
if (!spec.notNull || state.is_nullable !== "YES") continue;
|
|
10747
|
+
if (spec.default !== void 0) await db.execute(sql`
|
|
10748
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
10749
|
+
SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
|
|
10750
|
+
WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
|
|
10751
|
+
`);
|
|
10752
|
+
try {
|
|
10753
|
+
await db.execute(sql`
|
|
10754
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
10755
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
|
|
10756
|
+
`);
|
|
10757
|
+
logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
|
|
10758
|
+
} catch (err) {
|
|
10759
|
+
logger.warn(`⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the constraint was not applied. Fill or remove those rows and restart: ` + (err instanceof Error ? err.message : String(err)));
|
|
10760
|
+
}
|
|
10761
|
+
}
|
|
9808
10762
|
for (const column of [
|
|
9809
10763
|
"email",
|
|
9810
10764
|
"display_name",
|
|
@@ -9883,6 +10837,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9883
10837
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
9884
10838
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
9885
10839
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
10840
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
|
|
9886
10841
|
await db.execute(sql`
|
|
9887
10842
|
UPDATE ${sql.raw(qualified)}
|
|
9888
10843
|
SET session_id = gen_random_uuid()::text
|
|
@@ -9945,6 +10900,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9945
10900
|
secret_encrypted TEXT NOT NULL,
|
|
9946
10901
|
friendly_name TEXT,
|
|
9947
10902
|
verified BOOLEAN DEFAULT FALSE,
|
|
10903
|
+
last_used_counter BIGINT,
|
|
9948
10904
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
9949
10905
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
9950
10906
|
)
|
|
@@ -9960,6 +10916,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9960
10916
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
9961
10917
|
verified_at TIMESTAMP WITH TIME ZONE,
|
|
9962
10918
|
ip_address TEXT,
|
|
10919
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
9963
10920
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
9964
10921
|
)
|
|
9965
10922
|
`);
|
|
@@ -9967,6 +10924,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9967
10924
|
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
|
|
9968
10925
|
ON ${sql.raw(mfaChallengesTableName)}(factor_id)
|
|
9969
10926
|
`);
|
|
10927
|
+
try {
|
|
10928
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
|
|
10929
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
|
|
10930
|
+
} catch (mfaMigrationError) {
|
|
10931
|
+
logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
|
|
10932
|
+
}
|
|
9970
10933
|
await db.execute(sql`
|
|
9971
10934
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
9972
10935
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
@@ -9986,10 +10949,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9986
10949
|
[authSchema, "user_identities"],
|
|
9987
10950
|
[authSchema, "refresh_tokens"],
|
|
9988
10951
|
[authSchema, "password_reset_tokens"],
|
|
10952
|
+
[authSchema, "magic_link_tokens"],
|
|
9989
10953
|
[authSchema, "app_config"],
|
|
9990
10954
|
[authSchema, "mfa_factors"],
|
|
9991
10955
|
[authSchema, "mfa_challenges"],
|
|
9992
|
-
[authSchema, "recovery_codes"]
|
|
10956
|
+
[authSchema, "recovery_codes"],
|
|
10957
|
+
[authSchema, "schema_meta"]
|
|
9993
10958
|
];
|
|
9994
10959
|
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
9995
10960
|
SELECT 1
|
|
@@ -10009,6 +10974,9 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10009
10974
|
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
10010
10975
|
}
|
|
10011
10976
|
await stampAuthSchemaVersion(db, authSchema);
|
|
10977
|
+
await revokeInternalTableAccess(async (text) => {
|
|
10978
|
+
await db.execute(sql.raw(text));
|
|
10979
|
+
}, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
|
|
10012
10980
|
logger.info("✅ Auth tables ready");
|
|
10013
10981
|
} catch (error) {
|
|
10014
10982
|
if (error instanceof AuthSchemaVersionError) throw error;
|
|
@@ -10282,7 +11250,7 @@ var UserService = class {
|
|
|
10282
11250
|
const conditions = [];
|
|
10283
11251
|
if (roleId) conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
|
|
10284
11252
|
if (search) {
|
|
10285
|
-
const pattern = `%${search}%`;
|
|
11253
|
+
const pattern = `%${escapeLikePattern(search)}%`;
|
|
10286
11254
|
conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
|
|
10287
11255
|
}
|
|
10288
11256
|
const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
|
|
@@ -10458,7 +11426,8 @@ var RefreshTokenService = class {
|
|
|
10458
11426
|
"sessionId",
|
|
10459
11427
|
"rotatedAt",
|
|
10460
11428
|
"revoked",
|
|
10461
|
-
"sessionStartedAt"
|
|
11429
|
+
"sessionStartedAt",
|
|
11430
|
+
"aal"
|
|
10462
11431
|
]) if (this.has(optional)) selection[optional] = this.col(optional);
|
|
10463
11432
|
return selection;
|
|
10464
11433
|
}
|
|
@@ -10472,6 +11441,7 @@ var RefreshTokenService = class {
|
|
|
10472
11441
|
};
|
|
10473
11442
|
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
10474
11443
|
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
11444
|
+
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
10475
11445
|
await this.db.insert(this.refreshTokensTable).values(values);
|
|
10476
11446
|
}
|
|
10477
11447
|
async findByHash(tokenHash) {
|
|
@@ -10976,6 +11946,12 @@ var PostgresAuthRepository = class {
|
|
|
10976
11946
|
async hasVerifiedMfaFactors(uid) {
|
|
10977
11947
|
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
10978
11948
|
}
|
|
11949
|
+
async claimMfaFactorCounter(factorId, counter) {
|
|
11950
|
+
return this.getMfaService().claimMfaFactorCounter(factorId, counter);
|
|
11951
|
+
}
|
|
11952
|
+
async recordMfaChallengeAttempt(challengeId) {
|
|
11953
|
+
return this.getMfaService().recordMfaChallengeAttempt(challengeId);
|
|
11954
|
+
}
|
|
10979
11955
|
};
|
|
10980
11956
|
/**
|
|
10981
11957
|
* PostgreSQL implementation of MfaRepository.
|
|
@@ -11028,7 +12004,7 @@ var MfaService = class {
|
|
|
11028
12004
|
async getMfaFactorById(factorId) {
|
|
11029
12005
|
const tableName = this.qualify("mfa_factors");
|
|
11030
12006
|
const result = await this.db.execute(sql`
|
|
11031
|
-
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
12007
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
|
|
11032
12008
|
FROM ${sql.raw(tableName)}
|
|
11033
12009
|
WHERE id = ${factorId}
|
|
11034
12010
|
`);
|
|
@@ -11041,10 +12017,29 @@ var MfaService = class {
|
|
|
11041
12017
|
secretEncrypted: row.secret_encrypted,
|
|
11042
12018
|
friendlyName: row.friendly_name ?? void 0,
|
|
11043
12019
|
verified: row.verified,
|
|
12020
|
+
lastUsedCounter: row.last_used_counter === null || row.last_used_counter === void 0 ? null : Number(row.last_used_counter),
|
|
11044
12021
|
createdAt: new Date(row.created_at),
|
|
11045
12022
|
updatedAt: new Date(row.updated_at)
|
|
11046
12023
|
};
|
|
11047
12024
|
}
|
|
12025
|
+
/**
|
|
12026
|
+
* Spend a TOTP time step, once and only once.
|
|
12027
|
+
*
|
|
12028
|
+
* One statement: the `WHERE` is the check, the `UPDATE` is the act, and
|
|
12029
|
+
* `RETURNING` reports which of two concurrent requests carrying the same
|
|
12030
|
+
* six digits won. Reading the counter and then writing it would let both
|
|
12031
|
+
* pass — the exact replay this closes.
|
|
12032
|
+
*/
|
|
12033
|
+
async claimMfaFactorCounter(factorId, counter) {
|
|
12034
|
+
const tableName = this.qualify("mfa_factors");
|
|
12035
|
+
return (await this.db.execute(sql`
|
|
12036
|
+
UPDATE ${sql.raw(tableName)}
|
|
12037
|
+
SET last_used_counter = ${counter}, updated_at = NOW()
|
|
12038
|
+
WHERE id = ${factorId}
|
|
12039
|
+
AND (last_used_counter IS NULL OR last_used_counter < ${counter})
|
|
12040
|
+
RETURNING id
|
|
12041
|
+
`)).rows.length > 0;
|
|
12042
|
+
}
|
|
11048
12043
|
async verifyMfaFactor(factorId) {
|
|
11049
12044
|
const tableName = this.qualify("mfa_factors");
|
|
11050
12045
|
await this.db.execute(sql`
|
|
@@ -11079,7 +12074,7 @@ var MfaService = class {
|
|
|
11079
12074
|
async getMfaChallengeById(challengeId) {
|
|
11080
12075
|
const tableName = this.qualify("mfa_challenges");
|
|
11081
12076
|
const result = await this.db.execute(sql`
|
|
11082
|
-
SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
|
|
12077
|
+
SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
|
|
11083
12078
|
FROM ${sql.raw(tableName)}
|
|
11084
12079
|
WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
|
|
11085
12080
|
`);
|
|
@@ -11090,9 +12085,28 @@ var MfaService = class {
|
|
|
11090
12085
|
factorId: row.factor_id,
|
|
11091
12086
|
createdAt: new Date(row.created_at),
|
|
11092
12087
|
verifiedAt: row.verified_at ? new Date(row.verified_at) : void 0,
|
|
11093
|
-
ipAddress: row.ip_address ?? void 0
|
|
12088
|
+
ipAddress: row.ip_address ?? void 0,
|
|
12089
|
+
attempts: Number(row.attempts ?? 0)
|
|
11094
12090
|
};
|
|
11095
12091
|
}
|
|
12092
|
+
/**
|
|
12093
|
+
* Count one failed guess against a challenge and report the new total.
|
|
12094
|
+
*
|
|
12095
|
+
* Incremented in the database rather than in the route so that guesses
|
|
12096
|
+
* arriving in parallel — the shape any real brute-force takes — cannot
|
|
12097
|
+
* share a single increment.
|
|
12098
|
+
*/
|
|
12099
|
+
async recordMfaChallengeAttempt(challengeId) {
|
|
12100
|
+
const tableName = this.qualify("mfa_challenges");
|
|
12101
|
+
const result = await this.db.execute(sql`
|
|
12102
|
+
UPDATE ${sql.raw(tableName)}
|
|
12103
|
+
SET attempts = attempts + 1
|
|
12104
|
+
WHERE id = ${challengeId}
|
|
12105
|
+
RETURNING attempts
|
|
12106
|
+
`);
|
|
12107
|
+
if (result.rows.length === 0) return 0;
|
|
12108
|
+
return Number(result.rows[0].attempts);
|
|
12109
|
+
}
|
|
11096
12110
|
async verifyMfaChallenge(challengeId) {
|
|
11097
12111
|
const tableName = this.qualify("mfa_challenges");
|
|
11098
12112
|
await this.db.execute(sql`
|
|
@@ -11337,6 +12351,7 @@ async function ensureHistoryTableExists(db) {
|
|
|
11337
12351
|
CREATE INDEX IF NOT EXISTS idx_history_time
|
|
11338
12352
|
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
11339
12353
|
`);
|
|
12354
|
+
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
11340
12355
|
logger.info("✅ Entity history table ready");
|
|
11341
12356
|
} catch (error) {
|
|
11342
12357
|
logger.error("❌ Failed to create row history table", { error });
|
|
@@ -12058,12 +13073,12 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12058
13073
|
const runSql = async (text) => {
|
|
12059
13074
|
return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
|
|
12060
13075
|
};
|
|
13076
|
+
await warnOnRoleSchemaCollision(runSql);
|
|
12061
13077
|
const posture = await detectConnectionPosture(runSql);
|
|
12062
13078
|
if (posture.privileged) {
|
|
12063
13079
|
await ensureAppRole(runSql, [
|
|
12064
13080
|
"public",
|
|
12065
13081
|
"rebase",
|
|
12066
|
-
"auth",
|
|
12067
13082
|
...registry.getCollections().map((c) => c.schema).filter((s) => typeof s === "string")
|
|
12068
13083
|
]);
|
|
12069
13084
|
driver.rlsUserRole = REBASE_USER_ROLE;
|
|
@@ -12073,6 +13088,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12073
13088
|
} else logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
|
|
12074
13089
|
await validatePolicyPgRoles(runSql, registry.getCollections(), driver.rlsUserRole ?? posture.role);
|
|
12075
13090
|
warnOnAnonymousGrants(registry.getCollections());
|
|
13091
|
+
warnOnLegacyRlsFunctions(registry.getCollections());
|
|
12076
13092
|
}
|
|
12077
13093
|
if (driver.branchService) try {
|
|
12078
13094
|
await driver.branchService.ensureBranchMetadataTable();
|
|
@@ -12288,12 +13304,12 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12288
13304
|
*/
|
|
12289
13305
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
12290
13306
|
const internals = driverResult.internals;
|
|
12291
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
13307
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-DzeTEvMv.js");
|
|
12292
13308
|
const plan = await ensureCollectionTables({ async query(text) {
|
|
12293
13309
|
const result = await internals.db.execute(sql.raw(text));
|
|
12294
13310
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
12295
13311
|
} }, collections, log);
|
|
12296
|
-
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}`);
|
|
13312
|
+
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}`);
|
|
12297
13313
|
return { applied: plan.actions.length - plan.failures.length };
|
|
12298
13314
|
},
|
|
12299
13315
|
/**
|
|
@@ -12313,13 +13329,24 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12313
13329
|
*/
|
|
12314
13330
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
12315
13331
|
const internals = driverResult.internals;
|
|
12316
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
13332
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-B_JMGa5K.js");
|
|
12317
13333
|
const outcome = await ensureCollectionPolicies({ async query(text) {
|
|
12318
13334
|
const result = await internals.db.execute(sql.raw(text));
|
|
12319
13335
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
12320
13336
|
} }, collections, log);
|
|
12321
13337
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
12322
13338
|
for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`);
|
|
13339
|
+
try {
|
|
13340
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-Bpv3nUZo.js").then((n) => n.n);
|
|
13341
|
+
await dropLegacyAuthSchema(async (text) => {
|
|
13342
|
+
return (await internals.db.execute(sql.raw(text))).rows ?? [];
|
|
13343
|
+
}, {
|
|
13344
|
+
info: (m) => logger.info(m),
|
|
13345
|
+
warn: (m) => logger.warn(m)
|
|
13346
|
+
});
|
|
13347
|
+
} catch (err) {
|
|
13348
|
+
logger.info("Left the legacy `auth` schema in place: " + (err instanceof Error ? err.message : String(err)));
|
|
13349
|
+
}
|
|
12323
13350
|
return { applied: outcome.policiesApplied };
|
|
12324
13351
|
},
|
|
12325
13352
|
getAdmin(driverResult) {
|
|
@@ -12327,7 +13354,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12327
13354
|
},
|
|
12328
13355
|
mountRoutes(app, basePath, driverResult) {},
|
|
12329
13356
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
12330
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
13357
|
+
const { createPostgresWebSocket } = await import("./websocket-D1qbmLZ2.js").then((n) => n.n);
|
|
12331
13358
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
12332
13359
|
}
|
|
12333
13360
|
};
|
|
@@ -12367,6 +13394,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
12367
13394
|
};
|
|
12368
13395
|
}
|
|
12369
13396
|
//#endregion
|
|
12370
|
-
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 };
|
|
13397
|
+
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, 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 };
|
|
12371
13398
|
|
|
12372
13399
|
//# sourceMappingURL=index.es.js.map
|