@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +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/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/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/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +253 -115
- package/src/services/PersistService.ts +82 -43
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- 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,18 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { t as createPostgresWebSocket } from "./websocket-
|
|
9
|
-
import {
|
|
5
|
+
import { n as resolveClientListLimit, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
|
|
6
|
+
import { A as getEnumVarName, B as createRelationRefWithData, C as getEffectiveSecurityRules, D as fieldKeyForColumn, F as getDeclaredPrimaryKeys, G as legacyForeignKeyName, H as updateDateAutoValues, I as isAddressableId, J as getPolicyNamesForRule, L as parseIdValues, M as getTableVarName, N as resolveCollectionRelations, O as findRelation, P as buildCompositeId, Q as toSnakeCase, R as sortCollectionsBySlug, S as resolveJunctionSpecs, T as securityRuleToConditions, U as firstFreeKey, V as normalizeToEntityRelation, W as generateForeignKeyName, X as mergeDeep, Y as isPrototypePollutingKey, Z as camelCase, _ as CollectionRegistry, b as getJunctionCollectionConfig, g as buildSdkData, h as visibleColumnProjection, j as getTableName$1, k as getColumnName, l as buildSearchColumnSpec, nt as isManyToMany, q as toWireKey, r as authUsersColumnSql, rt as Vector, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as hasForeignKeyOnTarget, u as hiddenColumnsOption, v as relationalCollections, w as policyToPostgres, x as getJunctionSecurityRules, y as resolveStringColumnLength, z as createRelationRef } from "./auth-users-columns-BfQHf9JE.js";
|
|
7
|
+
import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, l as isRelationalCollectionConfig } from "./src-DCdn3Val.js";
|
|
8
|
+
import { t as createPostgresWebSocket } from "./websocket-C8ZqVBiV.js";
|
|
9
|
+
import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-BJ_3wxwg.js";
|
|
10
|
+
import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-BH0Dzo_h.js";
|
|
11
|
+
import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-69hYT8nr.js";
|
|
10
12
|
import { Client, Pool } from "pg";
|
|
11
13
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
12
14
|
import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
13
|
-
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
14
|
-
import { PgArray,
|
|
15
|
+
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
|
|
16
|
+
import { PgArray, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
15
17
|
import fs, { promises } from "fs";
|
|
16
18
|
import path from "path";
|
|
17
19
|
import chokidar from "chokidar";
|
|
@@ -30,25 +32,6 @@ import { randomUUID } from "crypto";
|
|
|
30
32
|
function isChannelBusInstance(setting) {
|
|
31
33
|
return typeof setting?.publish === "function";
|
|
32
34
|
}
|
|
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
35
|
//#endregion
|
|
53
36
|
//#region ../common/src/util/email.ts
|
|
54
37
|
/**
|
|
@@ -152,6 +135,27 @@ var buildPropertyCallbacks = (properties) => {
|
|
|
152
135
|
return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
|
|
153
136
|
};
|
|
154
137
|
//#endregion
|
|
138
|
+
//#region ../common/src/data/filter-conditions.ts
|
|
139
|
+
/**
|
|
140
|
+
* Read one field's filter as the list of conditions it stands for.
|
|
141
|
+
*
|
|
142
|
+
* Accepts both declared shapes and normalises them to a list:
|
|
143
|
+
*
|
|
144
|
+
* ```ts
|
|
145
|
+
* toFilterTuples(["==", "active"]) // [["==", "active"]]
|
|
146
|
+
* toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
|
|
147
|
+
* ```
|
|
148
|
+
*
|
|
149
|
+
* A falsy, non-array or empty param has no conditions in it — the empty list,
|
|
150
|
+
* so a caller iterating adds nothing rather than compiling a tuple of
|
|
151
|
+
* `undefined`s and logging about an operator nobody sent.
|
|
152
|
+
*/
|
|
153
|
+
function toFilterTuples(filterParam) {
|
|
154
|
+
if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
|
|
155
|
+
if (Array.isArray(filterParam[0])) return filterParam;
|
|
156
|
+
return [filterParam];
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
155
159
|
//#region ../common/src/table-classification.ts
|
|
156
160
|
/** Schemas that are always considered Rebase-internal. */
|
|
157
161
|
var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
|
|
@@ -257,6 +261,55 @@ function getCollectionByPath(collectionPath, registry) {
|
|
|
257
261
|
}
|
|
258
262
|
return collection;
|
|
259
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Reject a write naming something that is not a column of the table.
|
|
266
|
+
*
|
|
267
|
+
* Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
|
|
268
|
+
* from `Object.keys(tableColumns)`, so a key the table does not carry is not
|
|
269
|
+
* rejected by anything — it is *left out of the statement*. The insert answers
|
|
270
|
+
* 201 having stored nothing under that name; the update, if the key was the
|
|
271
|
+
* only one, builds `update "posts" set where …` and Postgres raises a syntax
|
|
272
|
+
* error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
|
|
273
|
+
* 500 for what is a caller's typo.
|
|
274
|
+
*
|
|
275
|
+
* That makes this the last honest place to check, and the only one every write
|
|
276
|
+
* passes through. `assertKnownWriteFields` in the REST layer checks the same
|
|
277
|
+
* thing against the *config* and is skipped on four paths — `strictWrites:
|
|
278
|
+
* false`, a collection declaring no properties, an auth adapter that owns the
|
|
279
|
+
* body's shape, and a nested route whose target cannot be walked — and it never
|
|
280
|
+
* sees an in-process `rebase.data` write at all.
|
|
281
|
+
*
|
|
282
|
+
* It also gives `strictWrites: false` a truthful implementation. The flag is
|
|
283
|
+
* documented for "a column that really does exist which the config never
|
|
284
|
+
* declared", and skipping the config check alone could not deliver that: the
|
|
285
|
+
* value was dropped a layer later regardless. Skipping the config check and
|
|
286
|
+
* keeping this one does exactly what the flag says — the column must exist,
|
|
287
|
+
* the property need not.
|
|
288
|
+
*/
|
|
289
|
+
function assertWritableColumns(values, table, collectionPath) {
|
|
290
|
+
const columns = getTableColumns(table);
|
|
291
|
+
if (!columns || Object.keys(columns).length === 0) return;
|
|
292
|
+
const unknown = Object.keys(values).filter((key) => !(key in columns));
|
|
293
|
+
if (unknown.length === 0) return;
|
|
294
|
+
throw ApiError.badRequest(`'${collectionPath}' has no column${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => `'${key}'`).join(", ")}, so the value${unknown.length > 1 ? "s" : ""} would have been dropped before the statement was built.`, "VALIDATION_UNKNOWN_FIELDS");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* A relation whose names do not resolve against the registered schema.
|
|
298
|
+
*
|
|
299
|
+
* Every one of these used to be a `logger.warn` followed by `continue`, so a
|
|
300
|
+
* save reported success for a relation it had not written and a read answered
|
|
301
|
+
* `[]` for one it could not resolve. `assertRelationsResolve` (validate-relations)
|
|
302
|
+
* fails boot on the same defects, which is where they belong — a server that
|
|
303
|
+
* refuses to start is recoverable in a minute. This is the second line, for the
|
|
304
|
+
* paths that assemble a registry by hand, and it exists so that "cannot resolve"
|
|
305
|
+
* is never again reported as "done".
|
|
306
|
+
*
|
|
307
|
+
* @param label `<collection>.<relation>`
|
|
308
|
+
* @param detail what does not resolve, in terms of the schema
|
|
309
|
+
*/
|
|
310
|
+
function relationMisconfigured(label, detail) {
|
|
311
|
+
return ApiError.internal(`Relation '${label}' does not resolve against the registered schema: ${detail}. The operation was refused rather than skipped — silently dropping it would report success for a write that never happened, or emptiness for rows that exist. Run \`rebase schema generate\` if the generated schema is older than the database.`, "RELATION_MISCONFIGURED");
|
|
312
|
+
}
|
|
260
313
|
function getTableForCollection(collection, registry) {
|
|
261
314
|
const tableName = getTableName$1(collection);
|
|
262
315
|
const table = registry.getTable(tableName);
|
|
@@ -348,7 +401,7 @@ function requirePrimaryKeys(collection, registry) {
|
|
|
348
401
|
* they were *duplicated* and could disagree, not because they existed.
|
|
349
402
|
*/
|
|
350
403
|
function sourceKeyField(relation, sourceCollection, registry) {
|
|
351
|
-
if (relation.sourceKey) return relation.sourceKey;
|
|
404
|
+
if (relation.sourceKey) return fieldKeyForColumn(sourceCollection, relation.sourceKey);
|
|
352
405
|
return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
353
406
|
}
|
|
354
407
|
/**
|
|
@@ -362,7 +415,7 @@ function sourceKeyField(relation, sourceCollection, registry) {
|
|
|
362
415
|
*/
|
|
363
416
|
function joinsOnNaturalKey(relation, sourceCollection, registry) {
|
|
364
417
|
if (!relation.sourceKey) return false;
|
|
365
|
-
return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
418
|
+
return fieldKeyForColumn(sourceCollection, relation.sourceKey) !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
366
419
|
}
|
|
367
420
|
/**
|
|
368
421
|
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
@@ -436,6 +489,97 @@ function deriveRowAddress(row, collection, registry) {
|
|
|
436
489
|
//#endregion
|
|
437
490
|
//#region src/utils/drizzle-conditions.ts
|
|
438
491
|
/**
|
|
492
|
+
* Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
|
|
493
|
+
* because the fuzzy predicate has to know when the index-backed operator agrees
|
|
494
|
+
* with the collection's declared threshold and when it would narrow too far.
|
|
495
|
+
*/
|
|
496
|
+
var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
|
|
497
|
+
/**
|
|
498
|
+
* A user's search term, made safe to drop inside a `%…%` LIKE pattern.
|
|
499
|
+
*
|
|
500
|
+
* The term is already a bind parameter, so this is not about injection. It is
|
|
501
|
+
* about the two things a LIKE metacharacter does when it arrives from a search
|
|
502
|
+
* box:
|
|
503
|
+
*
|
|
504
|
+
* 1. **It changes the query.** `%` and `_` are wildcards, so searching for
|
|
505
|
+
* `50%` returned every row and `a_c` matched `abc`. Nothing the caller
|
|
506
|
+
* could type would find a literal `%`.
|
|
507
|
+
* 2. **It is a cost the caller chooses.** Postgres matches LIKE by
|
|
508
|
+
* backtracking: each `%` re-tries every remaining offset, so
|
|
509
|
+
* `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
|
|
510
|
+
* exponent — evaluated per row, OR-ed across every string property of the
|
|
511
|
+
* collection, on a sequential scan (a leading `%` cannot use an index), and
|
|
512
|
+
* the page limit does not bound it because the scan happens first.
|
|
513
|
+
*
|
|
514
|
+
* This is the server-side half of the pattern `like-pattern-redos.test.ts`
|
|
515
|
+
* hardened the offline evaluator against; that test's own note ("the same
|
|
516
|
+
* translation in the Mongo driver hands the expression to the database, where
|
|
517
|
+
* it occupies a server thread instead") describes this call site.
|
|
518
|
+
*
|
|
519
|
+
* Backslash is the default `ESCAPE` character for LIKE, and the pattern is
|
|
520
|
+
* bound rather than interpolated, so a single backslash here reaches the
|
|
521
|
+
* matcher as one. Escaping the escape character first is what keeps a term
|
|
522
|
+
* ending in `\` from swallowing the closing `%`.
|
|
523
|
+
*
|
|
524
|
+
* Note this is a *substring search*, not the `like` filter operator: a caller
|
|
525
|
+
* who wants wildcards has `?title=like.foo%` for that, where the pattern is the
|
|
526
|
+
* documented input.
|
|
527
|
+
*/
|
|
528
|
+
var escapeLikePattern = (value) => value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
529
|
+
/**
|
|
530
|
+
* The Drizzle column a relation's column name addresses on a table.
|
|
531
|
+
*
|
|
532
|
+
* A relation names its link in *column* terms — `localKey: "author_id"`,
|
|
533
|
+
* `foreignKeyOnTarget: "author_id"` — because that is what the database and
|
|
534
|
+
* every FK constraint call it. A Drizzle table is keyed by the *wire* name,
|
|
535
|
+
* `authorId`. Indexing the table with the column, which is what every one of
|
|
536
|
+
* these call sites used to do, therefore finds nothing the moment the two
|
|
537
|
+
* differ: for a `columnName`-carrying property that was already true, and it is
|
|
538
|
+
* now true of every derived foreign key.
|
|
539
|
+
*
|
|
540
|
+
* `undefined` rather than a throw: each caller already has a message naming the
|
|
541
|
+
* relation it was resolving, which is worth more than a generic one here.
|
|
542
|
+
*/
|
|
543
|
+
var relationColumn = (table, collection, column) => {
|
|
544
|
+
const key = fieldKeyForColumn(collection, column);
|
|
545
|
+
return (key in table ? table[key] : void 0) || void 0;
|
|
546
|
+
};
|
|
547
|
+
/** The target collection of a relation, or `undefined` if its thunk cannot resolve. */
|
|
548
|
+
var targetOf = (relation) => {
|
|
549
|
+
try {
|
|
550
|
+
return relation.target();
|
|
551
|
+
} catch {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
/** Column types `ILIKE '%…%'` is defined on. */
|
|
556
|
+
var ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
|
|
557
|
+
/**
|
|
558
|
+
* Can this column be matched with `ILIKE`?
|
|
559
|
+
*
|
|
560
|
+
* Asked of the column's *declared SQL type*, never with `instanceof`. The
|
|
561
|
+
* previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
|
|
562
|
+
* and `instanceof` compares class identity: it is only true when the column was
|
|
563
|
+
* constructed by the very same copy of `drizzle-orm` that this module imported.
|
|
564
|
+
*
|
|
565
|
+
* An application's generated schema builds its tables with the app's own
|
|
566
|
+
* `drizzle-orm`, and this driver declares its own dependency on one. When the
|
|
567
|
+
* two ranges do not overlap — an app scaffolded against `^0.44` with a driver
|
|
568
|
+
* asking for `^0.45` — a strict installer gives the driver a second copy, every
|
|
569
|
+
* check returns false, no condition is produced, and the caller compiles that
|
|
570
|
+
* into an impossible `WHERE`. The result is a 200 with an empty page for every
|
|
571
|
+
* search on every collection without a `search` block: the failure looks
|
|
572
|
+
* exactly like "nothing matched". Observed in production, not theorised.
|
|
573
|
+
*
|
|
574
|
+
* `getSQLType()` is a value the column reports about itself, so it crosses
|
|
575
|
+
* module instances the way a class identity cannot. It also happens to fix
|
|
576
|
+
* `citext`, which the `instanceof` list never covered.
|
|
577
|
+
*/
|
|
578
|
+
var supportsILike = (column) => {
|
|
579
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
580
|
+
return ILIKE_SQL_TYPES.test(sqlType);
|
|
581
|
+
};
|
|
582
|
+
/**
|
|
439
583
|
* Process-wide default, set once when the driver is constructed.
|
|
440
584
|
*
|
|
441
585
|
* The condition builder is a set of *static* methods reached from a dozen
|
|
@@ -493,7 +637,7 @@ function toMembershipList(value) {
|
|
|
493
637
|
* @example
|
|
494
638
|
* const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
|
|
495
639
|
*/
|
|
496
|
-
var DrizzleConditionBuilder = class {
|
|
640
|
+
var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
497
641
|
/**
|
|
498
642
|
* Express "reachable from this parent through this relation" as a plain
|
|
499
643
|
* `WHERE` condition on the target table.
|
|
@@ -533,7 +677,7 @@ var DrizzleConditionBuilder = class {
|
|
|
533
677
|
}
|
|
534
678
|
case "hasOne":
|
|
535
679
|
case "hasMany": {
|
|
536
|
-
const fkColumn = targetTable
|
|
680
|
+
const fkColumn = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
|
|
537
681
|
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
|
|
538
682
|
if (!relation.sourceKey) return eq(fkColumn, parentId);
|
|
539
683
|
const { table, idColumn } = parent();
|
|
@@ -621,14 +765,14 @@ var DrizzleConditionBuilder = class {
|
|
|
621
765
|
if (collection) {
|
|
622
766
|
const relation = resolveCollectionRelations(collection)[field];
|
|
623
767
|
if (relation?.kind === "belongsTo") {
|
|
624
|
-
const foreignKey =
|
|
768
|
+
const foreignKey = relationColumn(table, collection, relation.localKey);
|
|
625
769
|
if (foreignKey) return {
|
|
626
770
|
kind: "column",
|
|
627
771
|
column: foreignKey
|
|
628
772
|
};
|
|
629
773
|
}
|
|
630
774
|
if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
|
|
631
|
-
const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ?
|
|
775
|
+
const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ? relationColumn(table, collection, relation.sourceKey) : sourceIdColumn;
|
|
632
776
|
if (!correlationColumn) throw new Error(`\`sourceKey: "${relation.sourceKey}"\` on relation '${relation.relationName}' is not a column on '${collectionPath}', so a filter on that relation has nothing to correlate against.`);
|
|
633
777
|
return {
|
|
634
778
|
kind: "relation",
|
|
@@ -638,7 +782,12 @@ var DrizzleConditionBuilder = class {
|
|
|
638
782
|
};
|
|
639
783
|
}
|
|
640
784
|
}
|
|
641
|
-
for (const guess of [
|
|
785
|
+
for (const guess of [
|
|
786
|
+
`${field}Id`,
|
|
787
|
+
toWireKey(generateForeignKeyName(field)),
|
|
788
|
+
`${field}_id`,
|
|
789
|
+
generateForeignKeyName(field)
|
|
790
|
+
]) {
|
|
642
791
|
const foreignKey = columnAt(guess);
|
|
643
792
|
if (foreignKey) return {
|
|
644
793
|
kind: "column",
|
|
@@ -669,8 +818,7 @@ var DrizzleConditionBuilder = class {
|
|
|
669
818
|
if (!filterParam) continue;
|
|
670
819
|
const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
|
|
671
820
|
if (!target) continue;
|
|
672
|
-
|
|
673
|
-
for (const [op, value] of paramsList) {
|
|
821
|
+
for (const [op, value] of toFilterTuples(filterParam)) {
|
|
674
822
|
const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
|
|
675
823
|
if (condition) conditions.push(condition);
|
|
676
824
|
}
|
|
@@ -747,7 +895,7 @@ var DrizzleConditionBuilder = class {
|
|
|
747
895
|
const targetCollection = relation.target();
|
|
748
896
|
const targetTable = registry.getTable(getTableName$1(targetCollection));
|
|
749
897
|
if (!targetTable) throw new Error(`Table not found for the target of relation '${relation.relationName}' (collection '${targetCollection.slug}')`);
|
|
750
|
-
const fkColumn = targetTable
|
|
898
|
+
const fkColumn = relationColumn(targetTable, targetCollection, relation.foreignKeyOnTarget);
|
|
751
899
|
if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
|
|
752
900
|
const targetIdColumn = this.primaryKeyColumn(targetTable);
|
|
753
901
|
if (!targetIdColumn) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}', so a filter on it has nothing to match against.`);
|
|
@@ -899,9 +1047,10 @@ var DrizzleConditionBuilder = class {
|
|
|
899
1047
|
case "not-ilike": return sql`${column} NOT ILIKE ${String(value)}`;
|
|
900
1048
|
case "is-null": return sql`${column} IS NULL`;
|
|
901
1049
|
case "is-not-null": return sql`${column} IS NOT NULL`;
|
|
902
|
-
default:
|
|
903
|
-
|
|
904
|
-
|
|
1050
|
+
default: throw ApiError.badRequest(`Unknown filter operator '${op}'. Valid operators: ${ALL_WHERE_FILTER_OPS.join(", ")}.`, "UNKNOWN_FILTER_OPERATOR", {
|
|
1051
|
+
operator: op,
|
|
1052
|
+
validOperators: ALL_WHERE_FILTER_OPS
|
|
1053
|
+
});
|
|
905
1054
|
}
|
|
906
1055
|
}
|
|
907
1056
|
/**
|
|
@@ -1098,7 +1247,7 @@ var DrizzleConditionBuilder = class {
|
|
|
1098
1247
|
if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
|
|
1099
1248
|
return match(targetIdCol);
|
|
1100
1249
|
}
|
|
1101
|
-
const foreignKeyCol = targetTable
|
|
1250
|
+
const foreignKeyCol = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
|
|
1102
1251
|
if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A link through a junction is \`kind: "manyToMany"\`.`);
|
|
1103
1252
|
return match(foreignKeyCol);
|
|
1104
1253
|
}
|
|
@@ -1119,22 +1268,153 @@ var DrizzleConditionBuilder = class {
|
|
|
1119
1268
|
return or(...conditions);
|
|
1120
1269
|
}
|
|
1121
1270
|
/**
|
|
1122
|
-
* Build search conditions for text fields
|
|
1271
|
+
* Build search conditions for text fields.
|
|
1272
|
+
*
|
|
1273
|
+
* Two shapes, chosen by whether the collection declared a `search` block:
|
|
1274
|
+
*
|
|
1275
|
+
* - **Declared** — one `@@ websearch_to_tsquery` against the generated
|
|
1276
|
+
* `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
|
|
1277
|
+
* inside JSONB and arrays, and uses the GIN index.
|
|
1278
|
+
* - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
|
|
1279
|
+
* string properties, with the term escaped (see {@link escapeLikePattern})
|
|
1280
|
+
* so it is matched as the literal text the user typed.
|
|
1281
|
+
*
|
|
1282
|
+
* The second is the default and stays the default. A collection that has
|
|
1283
|
+
* not opted in compiles to exactly the SQL it compiled to before this
|
|
1284
|
+
* branch existed, which is the only reason it is safe to have added it.
|
|
1285
|
+
*
|
|
1286
|
+
* `collection` is optional so that the callers which genuinely have no
|
|
1287
|
+
* collection in hand — nested paths, derived views — keep working; without
|
|
1288
|
+
* one there is no `search` block to read and the ILIKE path is correct.
|
|
1123
1289
|
*/
|
|
1124
|
-
static buildSearchConditions(searchString, properties, table) {
|
|
1290
|
+
static buildSearchConditions(searchString, properties, table, collection) {
|
|
1125
1291
|
const searchConditions = [];
|
|
1292
|
+
const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
|
|
1293
|
+
if (ftsCondition) return [ftsCondition];
|
|
1294
|
+
let declaredStringProperties = 0;
|
|
1126
1295
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1127
1296
|
const p = prop;
|
|
1128
1297
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
1298
|
+
declaredStringProperties++;
|
|
1129
1299
|
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
|
-
}
|
|
1300
|
+
if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
|
|
1133
1301
|
}
|
|
1134
1302
|
}
|
|
1303
|
+
if (declaredStringProperties > 0 && searchConditions.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
|
|
1135
1304
|
return searchConditions;
|
|
1136
1305
|
}
|
|
1137
1306
|
/**
|
|
1307
|
+
* The `@@` predicate for a collection that declared a `search` block, or
|
|
1308
|
+
* undefined for one that did not.
|
|
1309
|
+
*
|
|
1310
|
+
* The query is normalized exactly as the indexed content was — same text
|
|
1311
|
+
* search configuration, same accent folding. Skipping that on the query
|
|
1312
|
+
* side is the subtle way to get a search that matches nothing: the column
|
|
1313
|
+
* would hold `gestion` while the query asked for `gestión`.
|
|
1314
|
+
*
|
|
1315
|
+
* `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
|
|
1316
|
+
* one that behaves the way a search box looks like it should — quoted
|
|
1317
|
+
* phrases, `or`, and a leading `-` to exclude — and because it never throws
|
|
1318
|
+
* on user input, which `to_tsquery` does on so much as a stray parenthesis.
|
|
1319
|
+
*/
|
|
1320
|
+
static buildFullTextCondition(searchString, table, collection) {
|
|
1321
|
+
let spec;
|
|
1322
|
+
try {
|
|
1323
|
+
spec = buildSearchColumnSpec(collection);
|
|
1324
|
+
} catch {
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (!spec) return void 0;
|
|
1328
|
+
const column = table[spec.column];
|
|
1329
|
+
if (!column) return;
|
|
1330
|
+
const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
|
|
1331
|
+
if (!spec.fuzzy) return exact;
|
|
1332
|
+
const fuzzyColumn = table[spec.fuzzy.column];
|
|
1333
|
+
if (!fuzzyColumn) return exact;
|
|
1334
|
+
const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
|
|
1335
|
+
const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
|
|
1336
|
+
return sql`(${exact} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* `websearch_to_tsquery(<config>, <normalized search string>)`.
|
|
1340
|
+
*
|
|
1341
|
+
* Split out because the ranking expression needs the identical query — a
|
|
1342
|
+
* row ranked against a different tsquery than it was matched against is a
|
|
1343
|
+
* ranking of something else.
|
|
1344
|
+
*/
|
|
1345
|
+
static normalizedTsQuery(searchString, spec) {
|
|
1346
|
+
const normalized = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
|
|
1347
|
+
return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* A JSONB array of `{ field, snippet }` naming which declared fields matched
|
|
1351
|
+
* and showing the text around each hit — what backs `_matches`.
|
|
1352
|
+
*
|
|
1353
|
+
* A ranked list answers "which rows", never "why this row". For a talent
|
|
1354
|
+
* pool that difference is the product: a candidate surfacing for
|
|
1355
|
+
* "iso 14001" on a *certification* is a different candidate from one whose
|
|
1356
|
+
* bio happens to mention the standard, and the score cannot tell them apart.
|
|
1357
|
+
*
|
|
1358
|
+
* Built as a correlated subquery over a `VALUES` list of the declared
|
|
1359
|
+
* fields, rather than one `CASE` per field, so the shape does not change
|
|
1360
|
+
* with the number of fields and the empty result is a plain `[]`.
|
|
1361
|
+
*
|
|
1362
|
+
* `ts_headline` runs over the same normalized text that was indexed. Over
|
|
1363
|
+
* the *original* text it would find nothing to mark whenever `unaccent` is
|
|
1364
|
+
* on — the query's lexemes are folded and the document's are not — and
|
|
1365
|
+
* would return the text silently unhighlighted. Folded-but-marked beats
|
|
1366
|
+
* pretty-but-inert.
|
|
1367
|
+
*
|
|
1368
|
+
* Undefined when the collection has not opted in, when the column is not on
|
|
1369
|
+
* the table yet, or when the caller did not ask: this costs a `ts_headline`
|
|
1370
|
+
* per field per row and `ts_headline` re-parses the document.
|
|
1371
|
+
*/
|
|
1372
|
+
static buildSearchMatchesExpression(searchString, table, collection) {
|
|
1373
|
+
let spec;
|
|
1374
|
+
try {
|
|
1375
|
+
spec = buildSearchColumnSpec(collection);
|
|
1376
|
+
} catch {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (!spec || spec.fields.length === 0) return void 0;
|
|
1380
|
+
if (!table[spec.column]) return void 0;
|
|
1381
|
+
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1382
|
+
const config = sql`${spec.language}`;
|
|
1383
|
+
const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
|
|
1384
|
+
return sql`(
|
|
1385
|
+
SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
|
|
1386
|
+
FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
|
|
1387
|
+
CROSS JOIN LATERAL (
|
|
1388
|
+
SELECT jsonb_build_object(
|
|
1389
|
+
'field', f.path,
|
|
1390
|
+
'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
|
|
1391
|
+
'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
|
|
1392
|
+
) AS m
|
|
1393
|
+
WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
|
|
1394
|
+
) s
|
|
1395
|
+
)`;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* `ts_rank(<column>, <query>)` for the collection, or undefined when it has
|
|
1399
|
+
* not opted in. This is what backs `orderBy: ["_score", "desc"]`.
|
|
1400
|
+
*/
|
|
1401
|
+
static buildSearchRankExpression(searchString, table, collection) {
|
|
1402
|
+
let spec;
|
|
1403
|
+
try {
|
|
1404
|
+
spec = buildSearchColumnSpec(collection);
|
|
1405
|
+
} catch {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (!spec) return void 0;
|
|
1409
|
+
const column = table[spec.column];
|
|
1410
|
+
if (!column) return void 0;
|
|
1411
|
+
const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
|
|
1412
|
+
if (!spec.fuzzy) return rank;
|
|
1413
|
+
const fuzzyColumn = table[spec.fuzzy.column];
|
|
1414
|
+
if (!fuzzyColumn) return rank;
|
|
1415
|
+
return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1138
1418
|
* Build a unique field check condition
|
|
1139
1419
|
*/
|
|
1140
1420
|
static buildUniqueFieldCondition(fieldColumn, value, idColumn, excludeId) {
|
|
@@ -1232,10 +1512,19 @@ var DrizzleConditionBuilder = class {
|
|
|
1232
1512
|
* - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
|
|
1233
1513
|
* - `filter`: optional WHERE clause for distance threshold
|
|
1234
1514
|
* - `distanceSelect`: SQL expression for selecting the distance as `_distance`
|
|
1515
|
+
*
|
|
1516
|
+
* `property` is `?vector_search=` off the querystring, so it is an untrusted
|
|
1517
|
+
* *name*, and it used to be looked up straight in the drizzle table object.
|
|
1518
|
+
* Two ways that went wrong, both answering 500 to a malformed request:
|
|
1519
|
+
* `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
|
|
1520
|
+
* database rejects with "operator does not exist"; and a table object also
|
|
1521
|
+
* carries non-column keys (`_`, methods), which passed the `if (!column)`
|
|
1522
|
+
* guard and compiled to nonsense. The name is resolved against the table's
|
|
1523
|
+
* actual columns and required to be a `vector` — anything else is the
|
|
1524
|
+
* caller's mistake and gets a 400 that says so.
|
|
1235
1525
|
*/
|
|
1236
1526
|
static buildVectorSearchConditions(table, vectorSearch) {
|
|
1237
|
-
const column = table
|
|
1238
|
-
if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1527
|
+
const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
|
|
1239
1528
|
if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
1240
1529
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1241
1530
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
@@ -1257,7 +1546,33 @@ var DrizzleConditionBuilder = class {
|
|
|
1257
1546
|
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
|
|
1258
1547
|
};
|
|
1259
1548
|
}
|
|
1549
|
+
/**
|
|
1550
|
+
* The `vector` column a request named, or a 400 explaining what it named.
|
|
1551
|
+
*
|
|
1552
|
+
* `getTableColumns` rather than a key lookup: it returns only the columns,
|
|
1553
|
+
* so `_`, `getSQL` and every other property of a drizzle table stop looking
|
|
1554
|
+
* like candidates. The type check is on the *physical* column
|
|
1555
|
+
* (`vector(1536)`) rather than on the declared property, so it holds for an
|
|
1556
|
+
* introspected collection too, where the property carries no Rebase type.
|
|
1557
|
+
*/
|
|
1558
|
+
static resolveVectorColumn(table, property) {
|
|
1559
|
+
const columns = getTableColumns(table);
|
|
1560
|
+
const column = columns?.[property];
|
|
1561
|
+
if (!column) {
|
|
1562
|
+
const known = Object.entries(columns ?? {}).filter(([, c]) => isVectorColumn(c)).map(([name]) => name);
|
|
1563
|
+
throw ApiError.badRequest(`Unknown vector property "${property}". ` + (known.length > 0 ? `This collection's vector properties are: ${known.join(", ")}.` : "This collection declares no `vector` property to search."), "UNKNOWN_VECTOR_PROPERTY");
|
|
1564
|
+
}
|
|
1565
|
+
if (!isVectorColumn(column)) throw ApiError.badRequest(`Property "${property}" is not a vector column (it is \`${columnSqlType(column) || "unknown"}\`), so it has no distance operator. Name the property declared as \`{ type: "vector" }\`.`, "UNKNOWN_VECTOR_PROPERTY");
|
|
1566
|
+
return column;
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
/** The column's SQL type, for a value that may not be a drizzle column at all. */
|
|
1570
|
+
var columnSqlType = (column) => {
|
|
1571
|
+
const getSQLType = column?.getSQLType;
|
|
1572
|
+
return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
|
|
1260
1573
|
};
|
|
1574
|
+
/** True for `vector(1536)` and its pgvector siblings, whatever the width. */
|
|
1575
|
+
var isVectorColumn = (column) => /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
|
|
1261
1576
|
/**
|
|
1262
1577
|
* Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
|
|
1263
1578
|
* This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
|
|
@@ -1276,7 +1591,11 @@ function sanitizeAndConvertDates(obj) {
|
|
|
1276
1591
|
if (obj instanceof Date) return obj.toISOString();
|
|
1277
1592
|
if (typeof obj === "object") {
|
|
1278
1593
|
const newObj = {};
|
|
1279
|
-
for (const key in obj)
|
|
1594
|
+
for (const key in obj) {
|
|
1595
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
1596
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
1597
|
+
newObj[key] = sanitizeAndConvertDates(obj[key]);
|
|
1598
|
+
}
|
|
1280
1599
|
return newObj;
|
|
1281
1600
|
}
|
|
1282
1601
|
if (typeof obj === "string") {
|
|
@@ -1302,9 +1621,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1302
1621
|
const joinPathRelationUpdates = [];
|
|
1303
1622
|
const foreignKeys = /* @__PURE__ */ new Set();
|
|
1304
1623
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1305
|
-
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
1624
|
+
if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
|
|
1306
1625
|
});
|
|
1307
1626
|
for (const [key, value] of Object.entries(row)) {
|
|
1627
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
1628
|
+
if (value === void 0) continue;
|
|
1308
1629
|
const property = properties[key];
|
|
1309
1630
|
const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
|
|
1310
1631
|
if (!property) {
|
|
@@ -1315,11 +1636,11 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1315
1636
|
const relation = findRelation(resolvedRelations, key);
|
|
1316
1637
|
if (relation) {
|
|
1317
1638
|
if (relation.kind === "belongsTo") {
|
|
1318
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1319
|
-
if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
|
|
1639
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1640
|
+
if (serializedValue !== void 0) result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
|
|
1320
1641
|
continue;
|
|
1321
1642
|
} else if (hasForeignKeyOnTarget(relation)) {
|
|
1322
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1643
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1323
1644
|
inverseRelationUpdates.push({
|
|
1324
1645
|
relationKey: key,
|
|
1325
1646
|
relation,
|
|
@@ -1327,7 +1648,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1327
1648
|
});
|
|
1328
1649
|
continue;
|
|
1329
1650
|
} else if (relation.kind === "via") {
|
|
1330
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
1651
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
1331
1652
|
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
1332
1653
|
relationKey: key,
|
|
1333
1654
|
relation,
|
|
@@ -1342,7 +1663,7 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1342
1663
|
}
|
|
1343
1664
|
}
|
|
1344
1665
|
}
|
|
1345
|
-
result[key] = serializePropertyToServer(effectiveValue, property);
|
|
1666
|
+
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
1346
1667
|
}
|
|
1347
1668
|
return {
|
|
1348
1669
|
scalarData: result,
|
|
@@ -1351,19 +1672,39 @@ function serializeDataToServer(row, properties, collection, registry) {
|
|
|
1351
1672
|
};
|
|
1352
1673
|
}
|
|
1353
1674
|
/**
|
|
1354
|
-
*
|
|
1675
|
+
* How to name a rejected value in an error, without quoting it back.
|
|
1676
|
+
*
|
|
1677
|
+
* The value may be anything the caller sent, including a secret in the wrong
|
|
1678
|
+
* field, so the message describes its shape rather than echoing it — an echoed
|
|
1679
|
+
* value ends up in logs and in error-reporting services.
|
|
1680
|
+
*/
|
|
1681
|
+
function describeValue(value) {
|
|
1682
|
+
if (value === null) return "null";
|
|
1683
|
+
if (Array.isArray(value)) return "an array";
|
|
1684
|
+
if (value instanceof Date) return "a date";
|
|
1685
|
+
const type = typeof value;
|
|
1686
|
+
return type === "object" ? "an object" : `a ${type}`;
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Serialize a single property value for database storage.
|
|
1690
|
+
*
|
|
1691
|
+
* `propertyKey` is only ever used to phrase errors and warnings. Without it the
|
|
1692
|
+
* one trace a bad value left was `Expected array value for array property, got
|
|
1693
|
+
* string` — no collection, no property, no value, which in the log of a
|
|
1694
|
+
* thousand-row import names nothing at all.
|
|
1355
1695
|
*/
|
|
1356
|
-
function serializePropertyToServer(value, property) {
|
|
1696
|
+
function serializePropertyToServer(value, property, propertyKey) {
|
|
1357
1697
|
if (value === null || value === void 0) return value;
|
|
1698
|
+
const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
|
|
1358
1699
|
switch (property.type) {
|
|
1359
1700
|
case "relation":
|
|
1360
|
-
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property));
|
|
1701
|
+
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
|
|
1361
1702
|
else if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
1362
1703
|
if (value === "") return null;
|
|
1363
1704
|
return value;
|
|
1364
1705
|
case "array":
|
|
1365
1706
|
if (Array.isArray(value)) {
|
|
1366
|
-
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of));
|
|
1707
|
+
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
|
|
1367
1708
|
else if (property.oneOf) {
|
|
1368
1709
|
const typeField = property.oneOf.typeField ?? "type";
|
|
1369
1710
|
const valueField = property.oneOf.valueField ?? "value";
|
|
@@ -1376,20 +1717,28 @@ function serializePropertyToServer(value, property) {
|
|
|
1376
1717
|
if (!type || !childProperty) return e;
|
|
1377
1718
|
return {
|
|
1378
1719
|
[typeField]: type,
|
|
1379
|
-
[valueField]: serializePropertyToServer(rec[valueField], childProperty)
|
|
1720
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
1380
1721
|
};
|
|
1381
1722
|
});
|
|
1382
1723
|
}
|
|
1383
1724
|
return value;
|
|
1384
1725
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1726
|
+
throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
1727
|
+
case "geopoint": {
|
|
1728
|
+
if (typeof value !== "object" || Array.isArray(value)) throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
1729
|
+
const point = value;
|
|
1730
|
+
if (typeof point.latitude !== "number" || typeof point.longitude !== "number") throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`, "VALIDATION_INVALID_VALUE");
|
|
1731
|
+
return {
|
|
1732
|
+
latitude: point.latitude,
|
|
1733
|
+
longitude: point.longitude
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1387
1736
|
case "map":
|
|
1388
1737
|
if (typeof value === "object" && property.properties) {
|
|
1389
1738
|
const result = {};
|
|
1390
1739
|
for (const [subKey, subValue] of Object.entries(value)) {
|
|
1391
1740
|
const subProperty = property.properties[subKey];
|
|
1392
|
-
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty);
|
|
1741
|
+
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
1393
1742
|
else result[subKey] = subValue;
|
|
1394
1743
|
}
|
|
1395
1744
|
return result;
|
|
@@ -1424,8 +1773,9 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1424
1773
|
for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
|
|
1425
1774
|
const relation = findRelation(resolvedRelations, propKey);
|
|
1426
1775
|
if (relation) {
|
|
1427
|
-
|
|
1428
|
-
|
|
1776
|
+
const localField = relation.kind === "belongsTo" ? fieldKeyForColumn(collection, relation.localKey) : "";
|
|
1777
|
+
if (relation.kind === "belongsTo" && localField in data) {
|
|
1778
|
+
const fkValue = data[localField];
|
|
1429
1779
|
if (fkValue !== null && fkValue !== void 0) try {
|
|
1430
1780
|
const targetCollection = relation.target();
|
|
1431
1781
|
result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
|
|
@@ -1438,7 +1788,7 @@ async function parseDataFromServer(data, collection, db, registry) {
|
|
|
1438
1788
|
const pks = getPrimaryKeys(collection, registry);
|
|
1439
1789
|
const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId(data, pks);
|
|
1440
1790
|
if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
|
|
1441
|
-
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
1791
|
+
const foreignKeyColumn = targetTable[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
1442
1792
|
if (foreignKeyColumn) {
|
|
1443
1793
|
const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
|
|
1444
1794
|
if (relatedRows.length > 0) if (relation.cardinality === "one") {
|
|
@@ -1627,6 +1977,7 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1627
1977
|
return isNaN(parsed) ? null : parsed;
|
|
1628
1978
|
}
|
|
1629
1979
|
return value;
|
|
1980
|
+
case "geopoint": return value;
|
|
1630
1981
|
case "vector": {
|
|
1631
1982
|
let nums = [];
|
|
1632
1983
|
if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
|
|
@@ -1670,17 +2021,35 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
|
1670
2021
|
* from the result (used by `normalizeDbValues` where
|
|
1671
2022
|
* Drizzle's relational API already hydrates them).
|
|
1672
2023
|
*/
|
|
2024
|
+
/**
|
|
2025
|
+
* Keys a query computes and attaches to a row, rather than reads from a column.
|
|
2026
|
+
*
|
|
2027
|
+
* An explicit set rather than a `_` prefix rule: a user's column may perfectly
|
|
2028
|
+
* well be called `_internal`, and passing it through here would put a value on
|
|
2029
|
+
* the row that no property describes and nothing downstream knows how to type.
|
|
2030
|
+
*/
|
|
2031
|
+
var QUERY_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
2032
|
+
"_score",
|
|
2033
|
+
"_distance",
|
|
2034
|
+
"_matches"
|
|
2035
|
+
]);
|
|
1673
2036
|
function normalizeScalarValues(data, properties, collection, resolvedRelations, options) {
|
|
1674
2037
|
const result = {};
|
|
1675
2038
|
const internalFKColumns = /* @__PURE__ */ new Set();
|
|
1676
2039
|
Object.values(resolvedRelations).forEach((relation) => {
|
|
1677
|
-
if (relation.kind
|
|
2040
|
+
if (relation.kind !== "belongsTo") return;
|
|
2041
|
+
const localField = fieldKeyForColumn(collection, relation.localKey);
|
|
2042
|
+
if (!properties[localField]) internalFKColumns.add(localField);
|
|
1678
2043
|
});
|
|
1679
2044
|
for (const [key, value] of Object.entries(data)) {
|
|
1680
2045
|
if (internalFKColumns.has(key)) {
|
|
1681
2046
|
result[key] = value === null ? null : typeof value === "number" ? value : String(value);
|
|
1682
2047
|
continue;
|
|
1683
2048
|
}
|
|
2049
|
+
if (QUERY_METADATA_KEYS.has(key)) {
|
|
2050
|
+
result[key] = value;
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
1684
2053
|
const property = properties[key];
|
|
1685
2054
|
if (!property) continue;
|
|
1686
2055
|
if (options.skipRelations && property.type === "relation") continue;
|
|
@@ -1703,34 +2072,179 @@ function normalizeDbValues(data, collection) {
|
|
|
1703
2072
|
return normalizeScalarValues(data, properties, collection, resolveCollectionRelations(collection), { skipRelations: true });
|
|
1704
2073
|
}
|
|
1705
2074
|
//#endregion
|
|
1706
|
-
//#region src/services/
|
|
2075
|
+
//#region src/services/write-denial.ts
|
|
2076
|
+
/**
|
|
2077
|
+
* Explain a write that matched no rows.
|
|
2078
|
+
*
|
|
2079
|
+
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
2080
|
+
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
2081
|
+
* like a successful one that happened to match nothing. Left unchecked, a
|
|
2082
|
+
* caller cannot tell "denied" from "done" — the write returns 200/204 and the
|
|
2083
|
+
* row is untouched. An agent handed a key with `orders:delete` and no delete
|
|
2084
|
+
* policy is the case that makes it concrete: it deletes nothing, forever, and
|
|
2085
|
+
* is told it worked every time.
|
|
2086
|
+
*
|
|
2087
|
+
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
2088
|
+
* cases. A visible row means the policy rejected the write (403); an invisible
|
|
2089
|
+
* one means there is nothing there to write for this caller (404, matching what
|
|
2090
|
+
* a GET would say). The re-read is bound by the caller's own policies, so it
|
|
2091
|
+
* discloses nothing a plain read wouldn't.
|
|
2092
|
+
*
|
|
2093
|
+
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
2094
|
+
*
|
|
2095
|
+
* It lives here, rather than beside its first caller, because every zero-row
|
|
2096
|
+
* write has to answer the same question and answer it identically: the rule
|
|
2097
|
+
* that a readable-but-unwritable target is a 403 is the contract, and a second
|
|
2098
|
+
* copy of it is a second chance to get it wrong.
|
|
2099
|
+
*
|
|
2100
|
+
* @param handle The RLS-scoped connection the write ran on — not a fresh
|
|
2101
|
+
* one, or the re-read would answer for a different caller.
|
|
2102
|
+
* @param table The table the write targeted (the junction, for a link).
|
|
2103
|
+
* @param conditions The write's own WHERE terms, reused verbatim.
|
|
2104
|
+
* @param denied Message for the 403: the target is there and was refused.
|
|
2105
|
+
* @param missing Message for the 404: there is nothing there for this caller.
|
|
2106
|
+
*/
|
|
2107
|
+
async function explainZeroRowWrite(handle, table, conditions, denied, missing) {
|
|
2108
|
+
if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(denied, "WRITE_DENIED");
|
|
2109
|
+
return ApiError.notFound(missing);
|
|
2110
|
+
}
|
|
2111
|
+
//#endregion
|
|
2112
|
+
//#region src/services/junction-writes.ts
|
|
2113
|
+
/** A junction that cannot be resolved is a broken relation, not a no-op. */
|
|
2114
|
+
var misconfigured = (label, detail) => relationMisconfigured(label, detail);
|
|
2115
|
+
function column(table, name, label, role) {
|
|
2116
|
+
const col = name ? table[name] : void 0;
|
|
2117
|
+
if (!col) throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
|
|
2118
|
+
return col;
|
|
2119
|
+
}
|
|
2120
|
+
/** The junction a `manyToMany` names outright. */
|
|
2121
|
+
function bindThroughJunction(registry, through, label) {
|
|
2122
|
+
const table = registry.getTable(through.table);
|
|
2123
|
+
if (!table) throw misconfigured(label, `no table '${through.table}' in the registry`);
|
|
2124
|
+
return {
|
|
2125
|
+
table,
|
|
2126
|
+
parentColumn: column(table, through.sourceColumn, label, "source"),
|
|
2127
|
+
targetColumn: column(table, through.targetColumn, label, "target"),
|
|
2128
|
+
label
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
/** The bare column name, whether written as `col` or `table.col`. */
|
|
2132
|
+
var columnPart = (spec) => {
|
|
2133
|
+
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2134
|
+
return one.includes(".") ? one.split(".")[1] : one;
|
|
2135
|
+
};
|
|
2136
|
+
/** The table a column spec names, or undefined when it is unqualified. */
|
|
2137
|
+
var tablePart = (spec) => {
|
|
2138
|
+
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2139
|
+
return one.includes(".") ? one.split(".")[0] : void 0;
|
|
2140
|
+
};
|
|
1707
2141
|
/**
|
|
1708
|
-
* The
|
|
2142
|
+
* The junction a `via` relation reaches through, from either end.
|
|
2143
|
+
*
|
|
2144
|
+
* A step is `{ table: T, on: { from, to } }` where `from` names a column on
|
|
2145
|
+
* whatever the walk was standing on and `to` names one on `T`. That positional
|
|
2146
|
+
* meaning is what makes the unqualified form work at all, so the walk carries
|
|
2147
|
+
* the previous table rather than asking the column names where they live.
|
|
2148
|
+
*/
|
|
2149
|
+
function bindJoinPathJunction(registry, joinPath, parentTableName, targetTableName, label) {
|
|
2150
|
+
const junctionName = joinPath.map((step) => step.table).find((table) => table !== parentTableName && table !== targetTableName);
|
|
2151
|
+
if (!junctionName) throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
|
|
2152
|
+
const table = registry.getTable(junctionName);
|
|
2153
|
+
if (!table) throw misconfigured(label, `no table '${junctionName}' in the registry`);
|
|
2154
|
+
let parentColumnName;
|
|
2155
|
+
let targetColumnName;
|
|
2156
|
+
let previousTable = parentTableName;
|
|
2157
|
+
for (const step of joinPath) {
|
|
2158
|
+
const fromTable = tablePart(step.on.from) ?? previousTable;
|
|
2159
|
+
const toTable = tablePart(step.on.to) ?? step.table;
|
|
2160
|
+
if (toTable === junctionName && fromTable === parentTableName) parentColumnName = columnPart(step.on.to);
|
|
2161
|
+
else if (fromTable === junctionName && toTable === parentTableName) parentColumnName = columnPart(step.on.from);
|
|
2162
|
+
else if (toTable === junctionName && fromTable === targetTableName) targetColumnName = columnPart(step.on.to);
|
|
2163
|
+
else if (fromTable === junctionName && toTable === targetTableName) targetColumnName = columnPart(step.on.from);
|
|
2164
|
+
previousTable = step.table;
|
|
2165
|
+
}
|
|
2166
|
+
if (!parentColumnName || !targetColumnName) throw misconfigured(label, `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`);
|
|
2167
|
+
return {
|
|
2168
|
+
table,
|
|
2169
|
+
parentColumn: column(table, parentColumnName, label, "source"),
|
|
2170
|
+
targetColumn: column(table, targetColumnName, label, "target"),
|
|
2171
|
+
label
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Remove one link, leaving the row on the far side alone.
|
|
1709
2176
|
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
2177
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
2178
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
2179
|
+
* post that uses it.
|
|
2180
|
+
*/
|
|
2181
|
+
async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
2182
|
+
const conditions = [eq(binding.parentColumn, parentId), eq(binding.targetColumn, targetId)];
|
|
2183
|
+
if (((await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0) === 0) throw await explainZeroRowWrite(tx, binding.table, conditions, `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": a row-level security policy rejected the write.`, `No "${subject.relation}" link between "${subject.parent}" "${parentId}" and "${targetId}" to remove.`);
|
|
2184
|
+
logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Make the junction say that `parentId` is linked to exactly `targetIds`, by
|
|
2188
|
+
* diffing against what is linked now rather than replacing the set.
|
|
2189
|
+
*
|
|
2190
|
+
* A save of the parent used to delete every junction row for it and re-insert
|
|
2191
|
+
* the ids the browser sent — a list the browser assembled from a read it did
|
|
2192
|
+
* earlier. Three things followed, all data loss rather than display:
|
|
2193
|
+
*
|
|
2194
|
+
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
|
|
2195
|
+
* saves any field from a form that predates it, and X is gone with nothing
|
|
2196
|
+
* reported to either of them.
|
|
2197
|
+
* - **A partially-read set is a partially-deleted set.** The read that fills
|
|
2198
|
+
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
2199
|
+
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
2200
|
+
* the links they were never shown. The select that drives the diff runs in
|
|
2201
|
+
* this same transaction under the same policies, so a link the caller cannot
|
|
2202
|
+
* read is in neither list and survives the save.
|
|
2203
|
+
* - **Junction payload columns.** A junction carrying its own columns
|
|
2204
|
+
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
2205
|
+
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
2206
|
+
*
|
|
2207
|
+
* The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
|
|
2208
|
+
* concurrently is a no-op rather than a unique violation.
|
|
2209
|
+
*/
|
|
2210
|
+
async function applyJunctionMembership(tx, binding, parentId, targetIds) {
|
|
2211
|
+
const existingRows = await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
|
|
2212
|
+
const existingById = /* @__PURE__ */ new Map();
|
|
2213
|
+
for (const row of existingRows) {
|
|
2214
|
+
if (row.targetId === null || row.targetId === void 0) continue;
|
|
2215
|
+
existingById.set(String(row.targetId), row.targetId);
|
|
2216
|
+
}
|
|
2217
|
+
const wantedById = /* @__PURE__ */ new Map();
|
|
2218
|
+
for (const targetId of targetIds) {
|
|
2219
|
+
if (targetId === null || targetId === void 0) continue;
|
|
2220
|
+
wantedById.set(String(targetId), targetId);
|
|
2221
|
+
}
|
|
2222
|
+
const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
|
|
2223
|
+
const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, value]) => value);
|
|
2224
|
+
if (removed.length > 0) await removeLinks(tx, binding, parentId, removed);
|
|
2225
|
+
if (added.length > 0) await tx.insert(binding.table).values(added.map((targetId) => ({
|
|
2226
|
+
[binding.parentColumn.name]: parentId,
|
|
2227
|
+
[binding.targetColumn.name]: targetId
|
|
2228
|
+
}))).onConflictDoNothing();
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Drop the named links, and refuse to call it done if the database kept any.
|
|
1719
2232
|
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
2233
|
+
* Every id here came out of the select in {@link applyJunctionMembership}, on
|
|
2234
|
+
* this same handle, so all of them were visible. Fewer deletions than that means
|
|
2235
|
+
* something refused them — and a save that reports success while the membership
|
|
2236
|
+
* it stored is not the membership it was given has told the caller something
|
|
2237
|
+
* untrue about the database.
|
|
1722
2238
|
*/
|
|
1723
|
-
function
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
if (typeof id === "string" || typeof id === "number") return id;
|
|
1730
|
-
}
|
|
1731
|
-
throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
|
|
1732
|
-
});
|
|
2239
|
+
async function removeLinks(tx, binding, parentId, removed) {
|
|
2240
|
+
const conditions = [eq(binding.parentColumn, parentId), inArray(binding.targetColumn, removed)];
|
|
2241
|
+
const deleted = (await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0;
|
|
2242
|
+
if (deleted >= removed.length) return;
|
|
2243
|
+
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2244
|
+
throw ApiError.forbidden(`Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) for relation '${binding.label}': a row-level security policy rejected the write.`, "WRITE_DENIED");
|
|
1733
2245
|
}
|
|
2246
|
+
//#endregion
|
|
2247
|
+
//#region src/services/RelationService.ts
|
|
1734
2248
|
/**
|
|
1735
2249
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
1736
2250
|
* Drizzle's `$dynamic()` queries lose the `innerJoin` method from
|
|
@@ -1838,6 +2352,10 @@ var RelationService = class {
|
|
|
1838
2352
|
const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
|
|
1839
2353
|
return keyByParentId.get(String(parentId));
|
|
1840
2354
|
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Shared with {@link RelationWriteService}: a write needs the same source
|
|
2357
|
+
* key a read does, and resolving it twice is how the two would disagree.
|
|
2358
|
+
*/
|
|
1841
2359
|
async resolveSourceKeys(parentCollection, relation, parentIds, db = this.db) {
|
|
1842
2360
|
const keyByParentId = /* @__PURE__ */ new Map();
|
|
1843
2361
|
const parentIdByKey = /* @__PURE__ */ new Map();
|
|
@@ -1933,7 +2451,7 @@ var RelationService = class {
|
|
|
1933
2451
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
1934
2452
|
const additionalFilters = [];
|
|
1935
2453
|
if (options.searchString) {
|
|
1936
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable);
|
|
2454
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
|
|
1937
2455
|
if (searchConditions.length === 0) return [];
|
|
1938
2456
|
const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
|
|
1939
2457
|
if (searchCombined) additionalFilters.push(searchCombined);
|
|
@@ -2008,30 +2526,6 @@ var RelationService = class {
|
|
|
2008
2526
|
return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
|
|
2009
2527
|
}
|
|
2010
2528
|
/**
|
|
2011
|
-
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
2012
|
-
* row itself alone.
|
|
2013
|
-
*
|
|
2014
|
-
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
2015
|
-
* target is shared, so deleting the row would remove the tag from every other
|
|
2016
|
-
* post that uses it. It used to do exactly that — resolve the path to the
|
|
2017
|
-
* `tags` table and delete by primary key.
|
|
2018
|
-
*/
|
|
2019
|
-
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
2020
|
-
if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
2021
|
-
const through = hop.relation.through;
|
|
2022
|
-
const junctionTable = this.registry.getTable(through.table);
|
|
2023
|
-
if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
|
|
2024
|
-
const sourceJunctionColumn = junctionTable[through.sourceColumn];
|
|
2025
|
-
const targetJunctionColumn = junctionTable[through.targetColumn];
|
|
2026
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
2027
|
-
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
2028
|
-
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
2029
|
-
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
2030
|
-
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
2031
|
-
await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
|
|
2032
|
-
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
2033
|
-
}
|
|
2034
|
-
/**
|
|
2035
2529
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
2036
2530
|
*/
|
|
2037
2531
|
async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
|
|
@@ -2079,7 +2573,7 @@ var RelationService = class {
|
|
|
2079
2573
|
}
|
|
2080
2574
|
if (relation.kind === "belongsTo") {
|
|
2081
2575
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
2082
|
-
const localKeyCol = parentTable[relation.localKey];
|
|
2576
|
+
const localKeyCol = parentTable[fieldKeyForColumn(parentCollection, relation.localKey)];
|
|
2083
2577
|
if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
2084
2578
|
const fkRows = await this.db.select({
|
|
2085
2579
|
parentId: parentIdCol,
|
|
@@ -2125,7 +2619,7 @@ var RelationService = class {
|
|
|
2125
2619
|
for (const row of results) {
|
|
2126
2620
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
2127
2621
|
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
2128
|
-
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
|
|
2622
|
+
const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
2129
2623
|
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
2130
2624
|
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
2131
2625
|
if (parentId !== void 0) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
@@ -2182,17 +2676,7 @@ var RelationService = class {
|
|
|
2182
2676
|
}
|
|
2183
2677
|
if (relation.kind === "manyToMany") {
|
|
2184
2678
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
2185
|
-
const junctionTable = this.registry
|
|
2186
|
-
if (!junctionTable) {
|
|
2187
|
-
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
2188
|
-
return /* @__PURE__ */ new Map();
|
|
2189
|
-
}
|
|
2190
|
-
const sourceJunctionCol = junctionTable[relation.through.sourceColumn];
|
|
2191
|
-
const targetJunctionCol = junctionTable[relation.through.targetColumn];
|
|
2192
|
-
if (!sourceJunctionCol || !targetJunctionCol) {
|
|
2193
|
-
logger.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
|
|
2194
|
-
return /* @__PURE__ */ new Map();
|
|
2195
|
-
}
|
|
2679
|
+
const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relation.relationName}`);
|
|
2196
2680
|
const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(inArray(sourceJunctionCol, parsedParentIds));
|
|
2197
2681
|
const resultMap = /* @__PURE__ */ new Map();
|
|
2198
2682
|
const targetTableName = getTableName$1(targetCollection);
|
|
@@ -2220,7 +2704,7 @@ var RelationService = class {
|
|
|
2220
2704
|
for (const row of results) {
|
|
2221
2705
|
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
2222
2706
|
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
2223
|
-
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
|
|
2707
|
+
const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
2224
2708
|
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
2225
2709
|
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
2226
2710
|
if (parentId !== void 0) {
|
|
@@ -2232,346 +2716,6 @@ var RelationService = class {
|
|
|
2232
2716
|
}
|
|
2233
2717
|
return resultMap;
|
|
2234
2718
|
}
|
|
2235
|
-
/**
|
|
2236
|
-
* Update many-to-many and junction relations
|
|
2237
|
-
*/
|
|
2238
|
-
async updateRelationsUsingJoins(tx, collection, id, relationValues) {
|
|
2239
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2240
|
-
for (const [key, value] of Object.entries(relationValues)) {
|
|
2241
|
-
const relation = findRelation(resolvedRelations, key);
|
|
2242
|
-
if (!relation || relation.cardinality !== "many") continue;
|
|
2243
|
-
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
2244
|
-
const targetCollection = relation.target();
|
|
2245
|
-
if (relation.kind === "via") {
|
|
2246
|
-
const parentTableName = getTableName$1(collection);
|
|
2247
|
-
const targetTableName = getTableName$1(targetCollection);
|
|
2248
|
-
let junctionTable = void 0;
|
|
2249
|
-
let sourceJunctionColumn = null;
|
|
2250
|
-
let targetJunctionColumn = null;
|
|
2251
|
-
const junctionTableName = relation.joinPath.find((step) => step.table !== parentTableName && step.table !== targetTableName)?.table;
|
|
2252
|
-
if (junctionTableName) {
|
|
2253
|
-
junctionTable = this.registry.getTable(junctionTableName);
|
|
2254
|
-
if (junctionTable) for (const joinStep of relation.joinPath) {
|
|
2255
|
-
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
|
|
2256
|
-
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
|
|
2257
|
-
if (fromTable === parentTableName && toTable === junctionTableName) {
|
|
2258
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
2259
|
-
sourceJunctionColumn = junctionTable[columnNames[0]];
|
|
2260
|
-
} else if (fromTable === junctionTableName && toTable === parentTableName) {
|
|
2261
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
2262
|
-
sourceJunctionColumn = junctionTable[columnNames[0]];
|
|
2263
|
-
}
|
|
2264
|
-
if (fromTable === junctionTableName && toTable === targetTableName) {
|
|
2265
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
|
|
2266
|
-
targetJunctionColumn = junctionTable[columnNames[0]];
|
|
2267
|
-
} else if (fromTable === targetTableName && toTable === junctionTableName) {
|
|
2268
|
-
const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
|
|
2269
|
-
targetJunctionColumn = junctionTable[columnNames[0]];
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
|
|
2274
|
-
logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
|
|
2275
|
-
continue;
|
|
2276
|
-
}
|
|
2277
|
-
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
2278
|
-
const parentIdInfo = parentPks[0];
|
|
2279
|
-
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2280
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
2281
|
-
if (targetEntityIds.length > 0) {
|
|
2282
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2283
|
-
const targetIdInfo = targetPks[0];
|
|
2284
|
-
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2285
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
2286
|
-
[targetJunctionColumn.name]: targetId
|
|
2287
|
-
}));
|
|
2288
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2289
|
-
}
|
|
2290
|
-
} else if (relation.kind === "manyToMany") {
|
|
2291
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
2292
|
-
if (!junctionTable) {
|
|
2293
|
-
logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
|
|
2294
|
-
continue;
|
|
2295
|
-
}
|
|
2296
|
-
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn];
|
|
2297
|
-
const targetJunctionColumn = junctionTable[relation.through.targetColumn];
|
|
2298
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2299
|
-
logger.warn(`Junction columns not found for relation '${key}'`);
|
|
2300
|
-
continue;
|
|
2301
|
-
}
|
|
2302
|
-
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
2303
|
-
const parentIdInfo = parentPks[0];
|
|
2304
|
-
const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
|
|
2305
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
2306
|
-
if (targetEntityIds.length > 0) {
|
|
2307
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2308
|
-
const targetIdInfo = targetPks[0];
|
|
2309
|
-
const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2310
|
-
[sourceJunctionColumn.name]: parsedParentId,
|
|
2311
|
-
[targetJunctionColumn.name]: targetId
|
|
2312
|
-
}));
|
|
2313
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2314
|
-
}
|
|
2315
|
-
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
2316
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2317
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2318
|
-
const targetIdInfo = targetPks[0];
|
|
2319
|
-
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
2320
|
-
const fkCol = targetTable[relation.foreignKeyOnTarget];
|
|
2321
|
-
if (!fkCol || !targetIdCol) {
|
|
2322
|
-
logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
|
|
2323
|
-
continue;
|
|
2324
|
-
}
|
|
2325
|
-
const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
2326
|
-
if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
|
|
2327
|
-
if (targetEntityIds.length > 0) {
|
|
2328
|
-
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
2329
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
|
|
2330
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
2331
|
-
} else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol, parentKeyValue));
|
|
2332
|
-
} else logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
2335
|
-
/**
|
|
2336
|
-
* Update inverse relations (where FK is on the target table)
|
|
2337
|
-
*/
|
|
2338
|
-
async updateInverseRelations(tx, sourceCollection, sourceEntityId, inverseRelationUpdates) {
|
|
2339
|
-
for (const update of inverseRelationUpdates) {
|
|
2340
|
-
const { relation, newValue } = update;
|
|
2341
|
-
try {
|
|
2342
|
-
const targetCollection = relation.target();
|
|
2343
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2344
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2345
|
-
const targetIdInfo = targetPks[0];
|
|
2346
|
-
requirePrimaryKeys(sourceCollection, this.registry)[0];
|
|
2347
|
-
if (relation.kind === "via") {
|
|
2348
|
-
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
2349
|
-
continue;
|
|
2350
|
-
}
|
|
2351
|
-
if (isManyToMany(relation)) {
|
|
2352
|
-
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
|
|
2353
|
-
table: relation.through.table,
|
|
2354
|
-
sourceColumn: relation.through.sourceColumn,
|
|
2355
|
-
targetColumn: relation.through.targetColumn
|
|
2356
|
-
});
|
|
2357
|
-
continue;
|
|
2358
|
-
}
|
|
2359
|
-
if (!hasForeignKeyOnTarget(relation)) {
|
|
2360
|
-
logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
|
|
2361
|
-
continue;
|
|
2362
|
-
}
|
|
2363
|
-
const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
|
|
2364
|
-
if (!foreignKeyColumn) {
|
|
2365
|
-
logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
|
|
2366
|
-
continue;
|
|
2367
|
-
}
|
|
2368
|
-
const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
2369
|
-
if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
|
|
2370
|
-
if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
2371
|
-
else {
|
|
2372
|
-
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
2373
|
-
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
2374
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
2375
|
-
await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
2376
|
-
}
|
|
2377
|
-
} catch (e) {
|
|
2378
|
-
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
/**
|
|
2383
|
-
* Handle inverse relations with joinPath
|
|
2384
|
-
*/
|
|
2385
|
-
async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
|
|
2386
|
-
try {
|
|
2387
|
-
const sourceTableName = getTableName$1(sourceCollection);
|
|
2388
|
-
const targetTableName = getTableName$1(targetCollection);
|
|
2389
|
-
const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
|
|
2390
|
-
if (intermediateTables.length === 1 && relation.cardinality === "many") {
|
|
2391
|
-
const junctionTableName = intermediateTables[0];
|
|
2392
|
-
const junctionTable = this.registry.getTable(junctionTableName);
|
|
2393
|
-
if (!junctionTable) {
|
|
2394
|
-
logger.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
|
|
2395
|
-
return;
|
|
2396
|
-
}
|
|
2397
|
-
let sourceJunctionColumn = null;
|
|
2398
|
-
let targetJunctionColumn = null;
|
|
2399
|
-
for (const step of relation.joinPath) if (step.table === junctionTableName) {
|
|
2400
|
-
const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
|
|
2401
|
-
const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
|
|
2402
|
-
const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
|
|
2403
|
-
if (fromTable === sourceTableName) sourceJunctionColumn = junctionTable[toColumnNames[0]];
|
|
2404
|
-
else if (fromTable === targetTableName) targetJunctionColumn = junctionTable[toColumnNames[0]];
|
|
2405
|
-
else {
|
|
2406
|
-
const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
|
|
2407
|
-
if (toTable === sourceTableName) sourceJunctionColumn = junctionTable[fromColumnNames[0]];
|
|
2408
|
-
else if (toTable === targetTableName) targetJunctionColumn = junctionTable[fromColumnNames[0]];
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2412
|
-
logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
|
|
2413
|
-
return;
|
|
2414
|
-
}
|
|
2415
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2416
|
-
const sourceIdInfo = sourcePks[0];
|
|
2417
|
-
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
2418
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
2419
|
-
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
2420
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2421
|
-
const targetIdInfo = targetPks[0];
|
|
2422
|
-
const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2423
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2424
|
-
[targetJunctionColumn.name]: targetId
|
|
2425
|
-
}));
|
|
2426
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2427
|
-
} else if (newValue && !Array.isArray(newValue)) {
|
|
2428
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2429
|
-
const targetIdInfo = targetPks[0];
|
|
2430
|
-
const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
|
|
2431
|
-
const newLink = {
|
|
2432
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2433
|
-
[targetJunctionColumn.name]: parsedTargetId
|
|
2434
|
-
};
|
|
2435
|
-
await tx.insert(junctionTable).values(newLink);
|
|
2436
|
-
}
|
|
2437
|
-
}
|
|
2438
|
-
} catch (error) {
|
|
2439
|
-
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
2440
|
-
throw error;
|
|
2441
|
-
}
|
|
2442
|
-
}
|
|
2443
|
-
/**
|
|
2444
|
-
* Handle many-to-many inverse relation updates using junction tables
|
|
2445
|
-
*/
|
|
2446
|
-
async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
|
|
2447
|
-
try {
|
|
2448
|
-
const junctionTable = this.registry.getTable(junctionInfo.table);
|
|
2449
|
-
if (!junctionTable) {
|
|
2450
|
-
logger.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
|
|
2451
|
-
return;
|
|
2452
|
-
}
|
|
2453
|
-
const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn];
|
|
2454
|
-
const targetJunctionColumn = junctionTable[junctionInfo.targetColumn];
|
|
2455
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2456
|
-
logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
|
|
2457
|
-
return;
|
|
2458
|
-
}
|
|
2459
|
-
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
2460
|
-
const sourceIdInfo = sourcePks[0];
|
|
2461
|
-
const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
|
|
2462
|
-
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
|
|
2463
|
-
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
2464
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2465
|
-
const targetIdInfo = targetPks[0];
|
|
2466
|
-
const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
|
|
2467
|
-
[sourceJunctionColumn.name]: parsedSourceId,
|
|
2468
|
-
[targetJunctionColumn.name]: targetId
|
|
2469
|
-
}));
|
|
2470
|
-
if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
|
|
2471
|
-
}
|
|
2472
|
-
} catch (error) {
|
|
2473
|
-
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
2474
|
-
throw error;
|
|
2475
|
-
}
|
|
2476
|
-
}
|
|
2477
|
-
/**
|
|
2478
|
-
* Update one-to-one relations that use joinPath
|
|
2479
|
-
*/
|
|
2480
|
-
async updateJoinPathOneToOneRelations(tx, parentCollection, parentId, updates) {
|
|
2481
|
-
for (const upd of updates) {
|
|
2482
|
-
const { relation, newTargetId } = upd;
|
|
2483
|
-
const targetCollection = relation.target();
|
|
2484
|
-
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2485
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2486
|
-
const targetIdInfo = targetPks[0];
|
|
2487
|
-
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
2488
|
-
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
2489
|
-
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
2490
|
-
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2491
|
-
const parentIdInfo = parentPks[0];
|
|
2492
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
2493
|
-
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
2494
|
-
const parentSourceCol = parentTable[parentSourceColName];
|
|
2495
|
-
const targetFKCol = targetTable[targetFKColName];
|
|
2496
|
-
if (!parentSourceCol) {
|
|
2497
|
-
logger.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
2498
|
-
continue;
|
|
2499
|
-
}
|
|
2500
|
-
if (!targetFKCol) {
|
|
2501
|
-
logger.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
|
|
2502
|
-
continue;
|
|
2503
|
-
}
|
|
2504
|
-
const parentRows = await tx.select({ val: parentSourceCol }).from(parentTable).where(eq(parentIdCol, parsedParentId)).limit(1);
|
|
2505
|
-
if (parentRows.length === 0) continue;
|
|
2506
|
-
const parentFKValue = parentRows[0].val;
|
|
2507
|
-
if (newTargetId === null || newTargetId === void 0) {
|
|
2508
|
-
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
2509
|
-
continue;
|
|
2510
|
-
}
|
|
2511
|
-
const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
2512
|
-
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
2513
|
-
else {
|
|
2514
|
-
logger.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
|
|
2515
|
-
continue;
|
|
2516
|
-
}
|
|
2517
|
-
await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
/**
|
|
2521
|
-
* Resolve joinPath write mapping for one-to-one relations
|
|
2522
|
-
*/
|
|
2523
|
-
resolveJoinPathWriteMapping(parentCollection, relation) {
|
|
2524
|
-
if (!relation.joinPath || relation.joinPath.length === 0) throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
2525
|
-
const parentTableName = getTableName$1(parentCollection);
|
|
2526
|
-
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
2527
|
-
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
2528
|
-
let currentFrom = lastStep.on.from;
|
|
2529
|
-
let safety = 0;
|
|
2530
|
-
while (safety++ < 10) {
|
|
2531
|
-
if (DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0] === parentTableName) break;
|
|
2532
|
-
const prevStep = relation.joinPath.find((s) => {
|
|
2533
|
-
return (Array.isArray(s.on.to) ? s.on.to[0] : s.on.to) === currentFrom;
|
|
2534
|
-
});
|
|
2535
|
-
if (!prevStep) throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
2536
|
-
currentFrom = prevStep.on.from;
|
|
2537
|
-
}
|
|
2538
|
-
return {
|
|
2539
|
-
targetFKColName,
|
|
2540
|
-
parentSourceColName: DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0]
|
|
2541
|
-
};
|
|
2542
|
-
}
|
|
2543
|
-
/**
|
|
2544
|
-
* Handle junction table creation for many-to-many path-based saves
|
|
2545
|
-
*/
|
|
2546
|
-
async handleJunctionTableCreation(tx, newEntityId, junctionTableInfo) {
|
|
2547
|
-
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
2548
|
-
const targetCollection = relation.target();
|
|
2549
|
-
try {
|
|
2550
|
-
const junctionTable = this.registry.getTable(relation.through.table);
|
|
2551
|
-
if (!junctionTable) {
|
|
2552
|
-
logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
|
|
2553
|
-
return;
|
|
2554
|
-
}
|
|
2555
|
-
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn];
|
|
2556
|
-
const targetJunctionColumn = junctionTable[relation.through.targetColumn];
|
|
2557
|
-
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
2558
|
-
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
2559
|
-
return;
|
|
2560
|
-
}
|
|
2561
|
-
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
2562
|
-
const targetIdInfo = targetPks[0];
|
|
2563
|
-
const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
|
|
2564
|
-
const junctionData = {
|
|
2565
|
-
[sourceJunctionColumn.name]: parentId,
|
|
2566
|
-
[targetJunctionColumn.name]: parsedNewEntityId
|
|
2567
|
-
};
|
|
2568
|
-
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
2569
|
-
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
2570
|
-
} catch (error) {
|
|
2571
|
-
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
|
|
2572
|
-
throw error;
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
2719
|
};
|
|
2576
2720
|
//#endregion
|
|
2577
2721
|
//#region src/services/row-pipeline.ts
|
|
@@ -3019,8 +3163,7 @@ function sanitizeErrorForClient(error, context) {
|
|
|
3019
3163
|
column: pgError.column,
|
|
3020
3164
|
table: pgError.table,
|
|
3021
3165
|
constraint: pgError.constraint,
|
|
3022
|
-
dataType: pgError.dataType
|
|
3023
|
-
drizzleMessage: error instanceof Error ? error.message : String(error)
|
|
3166
|
+
dataType: pgError.dataType
|
|
3024
3167
|
});
|
|
3025
3168
|
return pgErrorToFriendlyMessage(pgError, context);
|
|
3026
3169
|
}
|
|
@@ -3039,7 +3182,7 @@ function sanitizeErrorForClient(error, context) {
|
|
|
3039
3182
|
* Service for handling all row read operations.
|
|
3040
3183
|
* Handles fetching, searching, counting, and filtering rows.
|
|
3041
3184
|
*/
|
|
3042
|
-
var FetchService = class {
|
|
3185
|
+
var FetchService = class FetchService {
|
|
3043
3186
|
db;
|
|
3044
3187
|
registry;
|
|
3045
3188
|
relationService;
|
|
@@ -3112,16 +3255,39 @@ var FetchService = class {
|
|
|
3112
3255
|
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
3113
3256
|
* caller that hands over no collection to resolve against.
|
|
3114
3257
|
*/
|
|
3258
|
+
/**
|
|
3259
|
+
* The ORDER BY target, which may be relevance rather than a column.
|
|
3260
|
+
*
|
|
3261
|
+
* `_score` is only meaningful for a collection that declared a `search`
|
|
3262
|
+
* block *and* for a request that carried a search string — ranking rows
|
|
3263
|
+
* against no query ranks them all at zero. Outside those two conditions it
|
|
3264
|
+
* is an unknown field and gets the same 400 as any other typo, which is the
|
|
3265
|
+
* behaviour that matters: a sort that is silently dropped returns 200 with
|
|
3266
|
+
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
3267
|
+
*/
|
|
3268
|
+
static SCORE_FIELD = "_score";
|
|
3269
|
+
resolveOrderTarget(table, orderBy, collection, searchString) {
|
|
3270
|
+
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
3271
|
+
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
3272
|
+
if (rank) return rank;
|
|
3273
|
+
}
|
|
3274
|
+
return this.resolveOrderByField(table, orderBy, collection);
|
|
3275
|
+
}
|
|
3115
3276
|
resolveOrderByField(table, orderBy, collection) {
|
|
3116
3277
|
const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
|
|
3117
3278
|
const direct = columnAt(orderBy);
|
|
3118
3279
|
if (direct) return direct;
|
|
3119
3280
|
const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : void 0;
|
|
3120
3281
|
if (declaredRelation?.kind === "belongsTo") {
|
|
3121
|
-
const foreignKey = columnAt(declaredRelation.localKey);
|
|
3282
|
+
const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
|
|
3122
3283
|
if (foreignKey) return foreignKey;
|
|
3123
3284
|
}
|
|
3124
|
-
for (const guess of [
|
|
3285
|
+
for (const guess of [
|
|
3286
|
+
`${orderBy}Id`,
|
|
3287
|
+
toWireKey(generateForeignKeyName(orderBy)),
|
|
3288
|
+
`${orderBy}_id`,
|
|
3289
|
+
generateForeignKeyName(orderBy)
|
|
3290
|
+
]) {
|
|
3125
3291
|
const foreignKey = columnAt(guess);
|
|
3126
3292
|
if (foreignKey) return foreignKey;
|
|
3127
3293
|
}
|
|
@@ -3195,6 +3361,7 @@ var FetchService = class {
|
|
|
3195
3361
|
row[key] = createRelationRefWithData(e.id, e.path, e);
|
|
3196
3362
|
} else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
|
|
3197
3363
|
} catch (e) {
|
|
3364
|
+
if (reachedDatabase(e)) throw e;
|
|
3198
3365
|
logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
|
|
3199
3366
|
}
|
|
3200
3367
|
});
|
|
@@ -3230,6 +3397,7 @@ var FetchService = class {
|
|
|
3230
3397
|
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
3231
3398
|
}
|
|
3232
3399
|
} catch (e) {
|
|
3400
|
+
if (reachedDatabase(e)) throw e;
|
|
3233
3401
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
3234
3402
|
}
|
|
3235
3403
|
}
|
|
@@ -3239,12 +3407,14 @@ var FetchService = class {
|
|
|
3239
3407
|
*/
|
|
3240
3408
|
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
|
|
3241
3409
|
const queryOpts = {};
|
|
3410
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), this.registry.getCollectionByPath(collectionPath) ?? void 0);
|
|
3411
|
+
if (hidden) queryOpts.columns = hidden;
|
|
3242
3412
|
if (withConfig) queryOpts.with = withConfig;
|
|
3243
3413
|
const allConditions = [];
|
|
3244
3414
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3245
3415
|
if (options.searchString) {
|
|
3246
3416
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3247
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3417
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3248
3418
|
if (searchConditions.length === 0) {
|
|
3249
3419
|
queryOpts.where = and(eq(idField, -99999999));
|
|
3250
3420
|
return queryOpts;
|
|
@@ -3267,7 +3437,7 @@ var FetchService = class {
|
|
|
3267
3437
|
const orderExpressions = [];
|
|
3268
3438
|
if (options.orderBy) {
|
|
3269
3439
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3270
|
-
const orderByField = this.
|
|
3440
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
3271
3441
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3272
3442
|
}
|
|
3273
3443
|
orderExpressions.push(desc(idField));
|
|
@@ -3284,6 +3454,7 @@ var FetchService = class {
|
|
|
3284
3454
|
if (!options.startAfter) return [];
|
|
3285
3455
|
const cursor = options.startAfter;
|
|
3286
3456
|
if (options.orderBy) {
|
|
3457
|
+
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
3458
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
|
|
3288
3459
|
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
3289
3460
|
if (orderByField) {
|
|
@@ -3354,9 +3525,11 @@ var FetchService = class {
|
|
|
3354
3525
|
const qb = this.getQueryBuilder(tableName);
|
|
3355
3526
|
if (qb) try {
|
|
3356
3527
|
const withConfig = this.buildWithConfig(collection);
|
|
3528
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
3357
3529
|
const row = await qb.findFirst({
|
|
3358
3530
|
where: eq(idField, parsedId),
|
|
3359
|
-
with: withConfig
|
|
3531
|
+
with: withConfig,
|
|
3532
|
+
...hidden ? { columns: hidden } : {}
|
|
3360
3533
|
});
|
|
3361
3534
|
if (!row) return void 0;
|
|
3362
3535
|
const flatRow = toFlatRow(row, collection, this.registry);
|
|
@@ -3370,7 +3543,8 @@ var FetchService = class {
|
|
|
3370
3543
|
if (reachedDatabase(e)) throw e;
|
|
3371
3544
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
3372
3545
|
}
|
|
3373
|
-
const
|
|
3546
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
3547
|
+
const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
|
|
3374
3548
|
if (result.length === 0) return void 0;
|
|
3375
3549
|
const raw = result[0];
|
|
3376
3550
|
const values = await parseDataFromServer(raw, collection, this.db, this.registry);
|
|
@@ -3388,6 +3562,7 @@ var FetchService = class {
|
|
|
3388
3562
|
values[key] = createRelationRef(e.id, e.path);
|
|
3389
3563
|
}
|
|
3390
3564
|
} catch (e) {
|
|
3565
|
+
if (reachedDatabase(e)) throw e;
|
|
3391
3566
|
logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
|
|
3392
3567
|
}
|
|
3393
3568
|
}
|
|
@@ -3426,14 +3601,21 @@ var FetchService = class {
|
|
|
3426
3601
|
}
|
|
3427
3602
|
let vectorMeta;
|
|
3428
3603
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3604
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
3605
|
+
const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
|
|
3606
|
+
const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
|
|
3429
3607
|
let query = vectorMeta ? this.db.select({
|
|
3430
|
-
table_row: table,
|
|
3608
|
+
table_row: visible ?? table,
|
|
3431
3609
|
_distance: vectorMeta.distanceSelect
|
|
3432
|
-
}).from(table).$dynamic() : this.db.select(
|
|
3610
|
+
}).from(table).$dynamic() : rankSelect ? this.db.select({
|
|
3611
|
+
table_row: visible ?? table,
|
|
3612
|
+
_score: rankSelect,
|
|
3613
|
+
...matchesSelect ? { _matches: matchesSelect } : {}
|
|
3614
|
+
}).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3433
3615
|
const allConditions = [];
|
|
3434
3616
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3435
3617
|
if (options.searchString) {
|
|
3436
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3618
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3437
3619
|
if (searchConditions.length === 0) return [];
|
|
3438
3620
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3439
3621
|
}
|
|
@@ -3453,7 +3635,7 @@ var FetchService = class {
|
|
|
3453
3635
|
const orderExpressions = [];
|
|
3454
3636
|
if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
|
|
3455
3637
|
else if (options.orderBy) {
|
|
3456
|
-
const orderByField = this.
|
|
3638
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
3457
3639
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3458
3640
|
}
|
|
3459
3641
|
orderExpressions.push(desc(idField));
|
|
@@ -3473,6 +3655,10 @@ var FetchService = class {
|
|
|
3473
3655
|
const results = vectorMeta ? rawResults.map((r) => ({
|
|
3474
3656
|
...r.table_row,
|
|
3475
3657
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3658
|
+
})) : rankSelect ? rawResults.map((r) => ({
|
|
3659
|
+
...r.table_row,
|
|
3660
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
3661
|
+
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
3476
3662
|
})) : rawResults;
|
|
3477
3663
|
return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
3478
3664
|
}
|
|
@@ -3514,6 +3700,7 @@ var FetchService = class {
|
|
|
3514
3700
|
if (relatedRow) item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
3515
3701
|
});
|
|
3516
3702
|
} catch (e) {
|
|
3703
|
+
if (reachedDatabase(e)) throw e;
|
|
3517
3704
|
logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
|
|
3518
3705
|
}
|
|
3519
3706
|
}
|
|
@@ -3527,6 +3714,7 @@ var FetchService = class {
|
|
|
3527
3714
|
item.values[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
|
|
3528
3715
|
});
|
|
3529
3716
|
} catch (e) {
|
|
3717
|
+
if (reachedDatabase(e)) throw e;
|
|
3530
3718
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
3531
3719
|
}
|
|
3532
3720
|
}
|
|
@@ -3564,7 +3752,7 @@ var FetchService = class {
|
|
|
3564
3752
|
const allConditions = [];
|
|
3565
3753
|
if (hop) allConditions.push(this.buildRelationScope(hop));
|
|
3566
3754
|
if (options.searchString) {
|
|
3567
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3755
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3568
3756
|
if (searchConditions.length === 0) return 0;
|
|
3569
3757
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3570
3758
|
}
|
|
@@ -3660,6 +3848,7 @@ var FetchService = class {
|
|
|
3660
3848
|
if (related) row[key] = { ...related.values };
|
|
3661
3849
|
}
|
|
3662
3850
|
} catch (e) {
|
|
3851
|
+
if (reachedDatabase(e)) throw e;
|
|
3663
3852
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
3664
3853
|
}
|
|
3665
3854
|
}
|
|
@@ -3672,6 +3861,7 @@ var FetchService = class {
|
|
|
3672
3861
|
row[key] = batchResults.get(String(eid)) || [];
|
|
3673
3862
|
}
|
|
3674
3863
|
} catch (e) {
|
|
3864
|
+
if (reachedDatabase(e)) throw e;
|
|
3675
3865
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
3676
3866
|
}
|
|
3677
3867
|
}
|
|
@@ -3709,7 +3899,8 @@ var FetchService = class {
|
|
|
3709
3899
|
if (reachedDatabase(e)) throw e;
|
|
3710
3900
|
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
3711
3901
|
}
|
|
3712
|
-
const
|
|
3902
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
3903
|
+
const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
|
|
3713
3904
|
if (result.length === 0) return null;
|
|
3714
3905
|
const flatEntity = { ...result[0] };
|
|
3715
3906
|
if (!include || include.length === 0) return flatEntity;
|
|
@@ -3733,6 +3924,7 @@ var FetchService = class {
|
|
|
3733
3924
|
...e.values
|
|
3734
3925
|
}));
|
|
3735
3926
|
} catch (e) {
|
|
3927
|
+
if (reachedDatabase(e)) throw e;
|
|
3736
3928
|
logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
|
|
3737
3929
|
}
|
|
3738
3930
|
}
|
|
@@ -3747,14 +3939,21 @@ var FetchService = class {
|
|
|
3747
3939
|
const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
|
|
3748
3940
|
let vectorMeta;
|
|
3749
3941
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3942
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
3943
|
+
const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
|
|
3944
|
+
const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
|
|
3750
3945
|
let query = vectorMeta ? this.db.select({
|
|
3751
|
-
table_row: table,
|
|
3946
|
+
table_row: visible ?? table,
|
|
3752
3947
|
_distance: vectorMeta.distanceSelect
|
|
3753
|
-
}).from(table).$dynamic() : this.db.select(
|
|
3948
|
+
}).from(table).$dynamic() : rankSelect ? this.db.select({
|
|
3949
|
+
table_row: visible ?? table,
|
|
3950
|
+
_score: rankSelect,
|
|
3951
|
+
...matchesSelect ? { _matches: matchesSelect } : {}
|
|
3952
|
+
}).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3754
3953
|
const allConditions = [];
|
|
3755
3954
|
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
3756
3955
|
if (options.searchString) {
|
|
3757
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
|
|
3956
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3758
3957
|
if (searchConditions.length === 0) return [];
|
|
3759
3958
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3760
3959
|
}
|
|
@@ -3762,6 +3961,10 @@ var FetchService = class {
|
|
|
3762
3961
|
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3763
3962
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3764
3963
|
}
|
|
3964
|
+
if (options.logical) {
|
|
3965
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3966
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3967
|
+
}
|
|
3765
3968
|
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
3766
3969
|
if (allConditions.length > 0) {
|
|
3767
3970
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -3770,7 +3973,7 @@ var FetchService = class {
|
|
|
3770
3973
|
const orderExpressions = [];
|
|
3771
3974
|
if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
|
|
3772
3975
|
else if (options.orderBy) {
|
|
3773
|
-
const orderByField = this.
|
|
3976
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
3774
3977
|
if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
3775
3978
|
}
|
|
3776
3979
|
orderExpressions.push(desc(idField));
|
|
@@ -3783,6 +3986,11 @@ var FetchService = class {
|
|
|
3783
3986
|
...r.table_row,
|
|
3784
3987
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3785
3988
|
}));
|
|
3989
|
+
if (rankSelect) return rawResults.map((r) => ({
|
|
3990
|
+
...r.table_row,
|
|
3991
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
3992
|
+
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
3993
|
+
}));
|
|
3786
3994
|
return rawResults;
|
|
3787
3995
|
}
|
|
3788
3996
|
/**
|
|
@@ -3796,51 +4004,6 @@ var FetchService = class {
|
|
|
3796
4004
|
return !!this.getQueryBuilder(tableName);
|
|
3797
4005
|
}
|
|
3798
4006
|
/**
|
|
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
4007
|
* Fallback path used when db.query is unavailable.
|
|
3845
4008
|
* The primary path uses db.query.findMany with `with` config, which
|
|
3846
4009
|
* loads all relations in a single query.
|
|
@@ -3865,6 +4028,269 @@ var FetchService = class {
|
|
|
3865
4028
|
}
|
|
3866
4029
|
};
|
|
3867
4030
|
//#endregion
|
|
4031
|
+
//#region src/services/RelationWriteService.ts
|
|
4032
|
+
/**
|
|
4033
|
+
* The ids in a to-many relation write, whatever shape the caller sent.
|
|
4034
|
+
*
|
|
4035
|
+
* A membership list is written as either the related rows (`[{ id: 1 }]`, what
|
|
4036
|
+
* the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
|
|
4037
|
+
* what anyone writing the API by hand sends). Only the first was read, via a
|
|
4038
|
+
* blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
|
|
4039
|
+
* on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
|
|
4040
|
+
* and on a string-keyed one it did not surface at all — `String(undefined)`
|
|
4041
|
+
* wrote a junction row pointing at the literal `"undefined"`, which no read
|
|
4042
|
+
* would ever match. Both shapes are accepted here, in one place, because both
|
|
4043
|
+
* call sites had the same assumption.
|
|
4044
|
+
*
|
|
4045
|
+
* An element that carries no key is refused rather than skipped: dropping it
|
|
4046
|
+
* would silently write a shorter membership list than the caller asked for.
|
|
4047
|
+
*/
|
|
4048
|
+
function relationTargetIds(value, relationName, collectionSlug) {
|
|
4049
|
+
if (!Array.isArray(value)) return [];
|
|
4050
|
+
return value.map((element, index) => {
|
|
4051
|
+
if (typeof element === "string" || typeof element === "number") return element;
|
|
4052
|
+
if (element && typeof element === "object") {
|
|
4053
|
+
const id = element.id;
|
|
4054
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
4055
|
+
}
|
|
4056
|
+
throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
|
|
4057
|
+
});
|
|
4058
|
+
}
|
|
4059
|
+
/**
|
|
4060
|
+
* Writing relations: junction membership, foreign-key stamping, and the links a
|
|
4061
|
+
* nested path creates or removes.
|
|
4062
|
+
*
|
|
4063
|
+
* Split from {@link RelationService}, which now only reads. The two had grown
|
|
4064
|
+
* into one 1700-line class doing two unrelated jobs, and sharing one habit —
|
|
4065
|
+
* warning about a relation it could not resolve and carrying on, which surfaces
|
|
4066
|
+
* as an empty list on the read side and as a successful save on the write side.
|
|
4067
|
+
* Separating them is what made that visible as one class of defect rather than
|
|
4068
|
+
* eighteen scattered log lines.
|
|
4069
|
+
*
|
|
4070
|
+
* The reads it still needs — the source key a link joins on — it asks
|
|
4071
|
+
* {@link RelationService} for rather than reimplementing.
|
|
4072
|
+
*/
|
|
4073
|
+
var RelationWriteService = class {
|
|
4074
|
+
db;
|
|
4075
|
+
registry;
|
|
4076
|
+
reads;
|
|
4077
|
+
constructor(db, registry) {
|
|
4078
|
+
this.db = db;
|
|
4079
|
+
this.registry = registry;
|
|
4080
|
+
this.reads = new RelationService(db, registry);
|
|
4081
|
+
}
|
|
4082
|
+
/**
|
|
4083
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
4084
|
+
* row itself alone.
|
|
4085
|
+
*
|
|
4086
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
4087
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
4088
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
4089
|
+
* `tags` table and delete by primary key.
|
|
4090
|
+
*/
|
|
4091
|
+
async unlinkRelatedEntity(tx, hop, targetId) {
|
|
4092
|
+
if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
4093
|
+
await removeJunctionLink(tx, bindThroughJunction(this.registry, hop.relation.through, `${hop.parentCollection.slug}.${hop.relationKey}`), this.parsedId(hop.parentCollection, hop.parentId), this.parsedId(hop.targetCollection, targetId), {
|
|
4094
|
+
parent: hop.parentCollection.slug,
|
|
4095
|
+
relation: hop.relationKey
|
|
4096
|
+
});
|
|
4097
|
+
}
|
|
4098
|
+
/** A collection's id, parsed to the type its primary key column holds. */
|
|
4099
|
+
parsedId(collection, id) {
|
|
4100
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4101
|
+
return parseIdValues(id, pks)[pks[0].fieldName];
|
|
4102
|
+
}
|
|
4103
|
+
/** The same, for the membership list a to-many write names. */
|
|
4104
|
+
parsedIds(collection, ids) {
|
|
4105
|
+
if (ids.length === 0) return [];
|
|
4106
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4107
|
+
return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
|
|
4108
|
+
}
|
|
4109
|
+
/**
|
|
4110
|
+
* Update many-to-many and junction relations
|
|
4111
|
+
*/
|
|
4112
|
+
async updateRelationsUsingJoins(tx, collection, id, relationValues) {
|
|
4113
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
4114
|
+
for (const [key, value] of Object.entries(relationValues)) {
|
|
4115
|
+
const relation = findRelation(resolvedRelations, key);
|
|
4116
|
+
if (!relation || relation.cardinality !== "many") continue;
|
|
4117
|
+
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
4118
|
+
const targetCollection = relation.target();
|
|
4119
|
+
const label = `${collection.slug}.${key}`;
|
|
4120
|
+
if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
|
|
4121
|
+
else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
|
|
4122
|
+
else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
4123
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4124
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
4125
|
+
const targetIdInfo = targetPks[0];
|
|
4126
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
4127
|
+
const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
|
|
4128
|
+
const fkCol = targetTable[fkField];
|
|
4129
|
+
if (!fkCol || !targetIdCol) throw relationMisconfigured(label, `the target table '${getTableName$1(targetCollection)}' has no ${fkCol ? `'${targetIdInfo.fieldName}' key column` : `'${relation.foreignKeyOnTarget}' foreign-key column`}`);
|
|
4130
|
+
const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
4131
|
+
if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
|
|
4132
|
+
if (targetEntityIds.length > 0) {
|
|
4133
|
+
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
4134
|
+
await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds)));
|
|
4135
|
+
await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
4136
|
+
} else await tx.update(targetTable).set({ [fkField]: null }).where(eq(fkCol, parentKeyValue));
|
|
4137
|
+
} else throw relationMisconfigured(label, "it is a to-many relation with no way to write links — a `manyToMany` needs `through`, a `hasMany` needs `foreignKeyOnTarget`");
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
/**
|
|
4141
|
+
* Update inverse relations (where FK is on the target table)
|
|
4142
|
+
*/
|
|
4143
|
+
async updateInverseRelations(tx, sourceCollection, sourceEntityId, inverseRelationUpdates) {
|
|
4144
|
+
for (const update of inverseRelationUpdates) {
|
|
4145
|
+
const { relation, newValue } = update;
|
|
4146
|
+
try {
|
|
4147
|
+
const targetCollection = relation.target();
|
|
4148
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4149
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
4150
|
+
const targetIdInfo = targetPks[0];
|
|
4151
|
+
requirePrimaryKeys(sourceCollection, this.registry)[0];
|
|
4152
|
+
if (relation.kind === "via") {
|
|
4153
|
+
await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
|
|
4154
|
+
continue;
|
|
4155
|
+
}
|
|
4156
|
+
if (isManyToMany(relation)) {
|
|
4157
|
+
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
|
|
4158
|
+
table: relation.through.table,
|
|
4159
|
+
sourceColumn: relation.through.sourceColumn,
|
|
4160
|
+
targetColumn: relation.through.targetColumn
|
|
4161
|
+
});
|
|
4162
|
+
continue;
|
|
4163
|
+
}
|
|
4164
|
+
const label = `${sourceCollection.slug}.${relation.relationName}`;
|
|
4165
|
+
if (!hasForeignKeyOnTarget(relation)) throw relationMisconfigured(label, `a '${relation.kind}' relation names no column on the target to write the link into`);
|
|
4166
|
+
const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
|
|
4167
|
+
const foreignKeyColumn = targetTable[fkField];
|
|
4168
|
+
if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
|
|
4169
|
+
const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
4170
|
+
if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
|
|
4171
|
+
if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
4172
|
+
else {
|
|
4173
|
+
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
4174
|
+
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
4175
|
+
await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
4176
|
+
await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
4177
|
+
}
|
|
4178
|
+
} catch (e) {
|
|
4179
|
+
if (e instanceof ApiError || e?.name === "ApiError") throw e;
|
|
4180
|
+
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* Handle inverse relations with joinPath
|
|
4186
|
+
*/
|
|
4187
|
+
async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
|
|
4188
|
+
const sourceTableName = getTableName$1(sourceCollection);
|
|
4189
|
+
const targetTableName = getTableName$1(targetCollection);
|
|
4190
|
+
if (relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName).length !== 1 || relation.cardinality !== "many") return;
|
|
4191
|
+
try {
|
|
4192
|
+
const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
|
|
4193
|
+
const targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationTargetIds([newValue], relation.relationName, sourceCollection.slug);
|
|
4194
|
+
await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
|
|
4195
|
+
} catch (error) {
|
|
4196
|
+
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
4197
|
+
throw error;
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
/**
|
|
4201
|
+
* Handle many-to-many inverse relation updates using junction tables
|
|
4202
|
+
*/
|
|
4203
|
+
async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
|
|
4204
|
+
try {
|
|
4205
|
+
const targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : [];
|
|
4206
|
+
await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, `${sourceCollection.slug}.${relation.relationName}`), this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
|
|
4207
|
+
} catch (error) {
|
|
4208
|
+
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
4209
|
+
throw error;
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
/**
|
|
4213
|
+
* Update one-to-one relations that use joinPath
|
|
4214
|
+
*/
|
|
4215
|
+
async updateJoinPathOneToOneRelations(tx, parentCollection, parentId, updates) {
|
|
4216
|
+
for (const upd of updates) {
|
|
4217
|
+
const { relation, newTargetId } = upd;
|
|
4218
|
+
const targetCollection = relation.target();
|
|
4219
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4220
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
4221
|
+
const targetIdInfo = targetPks[0];
|
|
4222
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName];
|
|
4223
|
+
const { targetFKColName: targetFKColumn, parentSourceColName: parentSourceColumn } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
4224
|
+
const targetFKColName = fieldKeyForColumn(targetCollection, targetFKColumn);
|
|
4225
|
+
const parentSourceColName = fieldKeyForColumn(parentCollection, parentSourceColumn);
|
|
4226
|
+
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
4227
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
4228
|
+
const parentIdInfo = parentPks[0];
|
|
4229
|
+
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
4230
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
4231
|
+
const parentSourceCol = parentTable[parentSourceColName];
|
|
4232
|
+
const targetFKCol = targetTable[targetFKColName];
|
|
4233
|
+
const label = `${parentCollection.slug}.${relation.relationName}`;
|
|
4234
|
+
if (!parentSourceCol) throw relationMisconfigured(label, `its joinPath reads '${parentSourceColName}', which is not a column on '${getTableName$1(parentCollection)}'`);
|
|
4235
|
+
if (!targetFKCol) throw relationMisconfigured(label, `its joinPath writes '${targetFKColName}', which is not a column on '${getTableName$1(targetCollection)}'`);
|
|
4236
|
+
const parentRows = await tx.select({ val: parentSourceCol }).from(parentTable).where(eq(parentIdCol, parsedParentId)).limit(1);
|
|
4237
|
+
if (parentRows.length === 0) continue;
|
|
4238
|
+
const parentFKValue = parentRows[0].val;
|
|
4239
|
+
if (newTargetId === null || newTargetId === void 0) {
|
|
4240
|
+
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
4241
|
+
continue;
|
|
4242
|
+
}
|
|
4243
|
+
const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
4244
|
+
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
4245
|
+
else throw ApiError.badRequest(`Cannot write relation '${label}': row '${parentId}' has no value in '${parentSourceColName}', which is the column its joinPath joins on, so there is nothing for the related row to point at.`, "RELATION_SOURCE_KEY_EMPTY");
|
|
4246
|
+
await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Resolve joinPath write mapping for one-to-one relations
|
|
4251
|
+
*/
|
|
4252
|
+
resolveJoinPathWriteMapping(parentCollection, relation) {
|
|
4253
|
+
if (!relation.joinPath || relation.joinPath.length === 0) throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
4254
|
+
const parentTableName = getTableName$1(parentCollection);
|
|
4255
|
+
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
4256
|
+
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
4257
|
+
let currentFrom = lastStep.on.from;
|
|
4258
|
+
let safety = 0;
|
|
4259
|
+
while (safety++ < 10) {
|
|
4260
|
+
if (DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0] === parentTableName) break;
|
|
4261
|
+
const prevStep = relation.joinPath.find((s) => {
|
|
4262
|
+
return (Array.isArray(s.on.to) ? s.on.to[0] : s.on.to) === currentFrom;
|
|
4263
|
+
});
|
|
4264
|
+
if (!prevStep) throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
4265
|
+
currentFrom = prevStep.on.from;
|
|
4266
|
+
}
|
|
4267
|
+
return {
|
|
4268
|
+
targetFKColName,
|
|
4269
|
+
parentSourceColName: DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0]
|
|
4270
|
+
};
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* Handle junction table creation for many-to-many path-based saves
|
|
4274
|
+
*/
|
|
4275
|
+
async handleJunctionTableCreation(tx, newEntityId, junctionTableInfo) {
|
|
4276
|
+
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
4277
|
+
const targetCollection = relation.target();
|
|
4278
|
+
try {
|
|
4279
|
+
const binding = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relationKey}`);
|
|
4280
|
+
const parsedNewEntityId = this.parsedId(targetCollection, newEntityId);
|
|
4281
|
+
const junctionData = {
|
|
4282
|
+
[binding.parentColumn.name]: parentId,
|
|
4283
|
+
[binding.targetColumn.name]: parsedNewEntityId
|
|
4284
|
+
};
|
|
4285
|
+
await tx.insert(binding.table).values(junctionData).onConflictDoNothing();
|
|
4286
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
4287
|
+
} catch (error) {
|
|
4288
|
+
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
|
|
4289
|
+
throw error;
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
};
|
|
4293
|
+
//#endregion
|
|
3868
4294
|
//#region src/services/PersistService.ts
|
|
3869
4295
|
/**
|
|
3870
4296
|
* Service for handling all row write operations.
|
|
@@ -3873,34 +4299,24 @@ var FetchService = class {
|
|
|
3873
4299
|
var PersistService = class {
|
|
3874
4300
|
db;
|
|
3875
4301
|
registry;
|
|
4302
|
+
/** Reads: whether a row is under a parent, the key a link joins on. */
|
|
3876
4303
|
relationService;
|
|
4304
|
+
/** Writes: junction membership, foreign-key stamping, links. */
|
|
4305
|
+
relationWrites;
|
|
3877
4306
|
fetchService;
|
|
3878
4307
|
constructor(db, registry) {
|
|
3879
4308
|
this.db = db;
|
|
3880
4309
|
this.registry = registry;
|
|
3881
4310
|
this.relationService = new RelationService(db, registry);
|
|
4311
|
+
this.relationWrites = new RelationWriteService(db, registry);
|
|
3882
4312
|
this.fetchService = new FetchService(db, registry);
|
|
3883
4313
|
}
|
|
3884
4314
|
/**
|
|
3885
|
-
* Explain a write that matched
|
|
3886
|
-
*
|
|
3887
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
3888
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
3889
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
3890
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and
|
|
3891
|
-
* the row is untouched.
|
|
3892
|
-
*
|
|
3893
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
3894
|
-
* cases. A visible row means the policy rejected the write (403); an
|
|
3895
|
-
* invisible one means there is nothing there to write for this caller (404,
|
|
3896
|
-
* matching what a GET would say). The re-read is bound by the caller's own
|
|
3897
|
-
* policies, so it discloses nothing a plain read wouldn't.
|
|
3898
|
-
*
|
|
3899
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
4315
|
+
* Explain a row write that matched nothing — see {@link explainZeroRowWrite}
|
|
4316
|
+
* for why a zero-row write cannot be reported as success.
|
|
3900
4317
|
*/
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
4318
|
+
explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
|
|
4319
|
+
return explainZeroRowWrite(handle, table, conditions, `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, `No row "${id}" in "${collectionPath}" to ${operation}.`);
|
|
3904
4320
|
}
|
|
3905
4321
|
/**
|
|
3906
4322
|
* Delete an row by ID
|
|
@@ -3912,7 +4328,7 @@ var PersistService = class {
|
|
|
3912
4328
|
if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
3913
4329
|
if (isJunctionBackedRelation(hop.relation)) {
|
|
3914
4330
|
if (!isManyToMany(hop.relation)) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
|
|
3915
|
-
await this.
|
|
4331
|
+
await this.relationWrites.unlinkRelatedEntity(this.db, hop, id);
|
|
3916
4332
|
return;
|
|
3917
4333
|
}
|
|
3918
4334
|
}
|
|
@@ -3936,8 +4352,14 @@ var PersistService = class {
|
|
|
3936
4352
|
await this.db.delete(table);
|
|
3937
4353
|
}
|
|
3938
4354
|
/**
|
|
3939
|
-
* The
|
|
3940
|
-
*
|
|
4355
|
+
* The field on the *target* row that records the parent, for a create under
|
|
4356
|
+
* a nested one-to-many path.
|
|
4357
|
+
*
|
|
4358
|
+
* A **field**, not the column: the value is stamped into the caller's
|
|
4359
|
+
* payload, which is keyed by wire names — `authorId`, never the `author_id`
|
|
4360
|
+
* the relation names its link by. Stamping the column instead put a key on
|
|
4361
|
+
* the payload that no property answers to, and `strictWrites` rejected the
|
|
4362
|
+
* request the framework had just written to.
|
|
3941
4363
|
*
|
|
3942
4364
|
* Returns `undefined` when the link is not a column at all (a multi-hop
|
|
3943
4365
|
* `joinPath`), so the caller writes the row without stamping anything.
|
|
@@ -3950,8 +4372,8 @@ var PersistService = class {
|
|
|
3950
4372
|
const { relation } = hop;
|
|
3951
4373
|
switch (relation.kind) {
|
|
3952
4374
|
case "hasOne":
|
|
3953
|
-
case "hasMany": return relation.foreignKeyOnTarget;
|
|
3954
|
-
case "via": return relation.joinPath.length === 1 ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0] : void 0;
|
|
4375
|
+
case "hasMany": return fieldKeyForColumn(hop.targetCollection, relation.foreignKeyOnTarget);
|
|
4376
|
+
case "via": return relation.joinPath.length === 1 ? fieldKeyForColumn(hop.targetCollection, DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]) : void 0;
|
|
3955
4377
|
default: return;
|
|
3956
4378
|
}
|
|
3957
4379
|
}
|
|
@@ -4023,12 +4445,13 @@ var PersistService = class {
|
|
|
4023
4445
|
const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
|
|
4024
4446
|
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
4025
4447
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
4448
|
+
assertWritableColumns(entityData, table, effectiveCollectionPath);
|
|
4026
4449
|
savedId = await this.db.transaction(async (tx) => {
|
|
4027
4450
|
let currentId;
|
|
4028
4451
|
if (id && !options?.upsert) {
|
|
4029
4452
|
currentId = id;
|
|
4030
4453
|
const idValues = parseIdValues(id, idInfoArray);
|
|
4031
|
-
if (joinPathRelationUpdates.length > 0) await this.
|
|
4454
|
+
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
4032
4455
|
if (Object.keys(entityData).length > 0) {
|
|
4033
4456
|
const updateQuery = tx.update(table).set(entityData);
|
|
4034
4457
|
const conditions = [];
|
|
@@ -4049,6 +4472,7 @@ var PersistService = class {
|
|
|
4049
4472
|
const target = idInfoArray.map((info) => table[info.fieldName]);
|
|
4050
4473
|
const set = { ...dataForInsert };
|
|
4051
4474
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
4475
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
|
|
4052
4476
|
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
4053
4477
|
target,
|
|
4054
4478
|
set
|
|
@@ -4058,11 +4482,11 @@ var PersistService = class {
|
|
|
4058
4482
|
if (!resultRow) if (id) currentId = id;
|
|
4059
4483
|
else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
|
|
4060
4484
|
else currentId = buildCompositeId(resultRow, idInfoArray);
|
|
4061
|
-
if (joinPathRelationUpdates.length > 0) await this.
|
|
4485
|
+
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
4062
4486
|
}
|
|
4063
|
-
if (inverseRelationUpdates.length > 0) await this.
|
|
4064
|
-
if (Object.keys(relationValues).length > 0) await this.
|
|
4065
|
-
if (junctionTableInfo) await this.
|
|
4487
|
+
if (inverseRelationUpdates.length > 0) await this.relationWrites.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
|
|
4488
|
+
if (Object.keys(relationValues).length > 0) await this.relationWrites.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
|
|
4489
|
+
if (junctionTableInfo) await this.relationWrites.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
|
|
4066
4490
|
return currentId;
|
|
4067
4491
|
});
|
|
4068
4492
|
} catch (error) {
|
|
@@ -4079,6 +4503,15 @@ var PersistService = class {
|
|
|
4079
4503
|
return this.relationService;
|
|
4080
4504
|
}
|
|
4081
4505
|
/**
|
|
4506
|
+
* The write half, for external use. Separate from
|
|
4507
|
+
* {@link getRelationService} because they are separate objects now: reads
|
|
4508
|
+
* answer questions, writes change rows, and the callers of one are not the
|
|
4509
|
+
* callers of the other.
|
|
4510
|
+
*/
|
|
4511
|
+
getRelationWriteService() {
|
|
4512
|
+
return this.relationWrites;
|
|
4513
|
+
}
|
|
4514
|
+
/**
|
|
4082
4515
|
* Get the FetchService instance for external use
|
|
4083
4516
|
*/
|
|
4084
4517
|
getFetchService() {
|
|
@@ -4091,7 +4524,8 @@ var PersistService = class {
|
|
|
4091
4524
|
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
4092
4525
|
const pgError = extractPgError(error);
|
|
4093
4526
|
if (pgError) {
|
|
4094
|
-
const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
4527
|
+
const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
4528
|
+
if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
|
|
4095
4529
|
return new Error(message);
|
|
4096
4530
|
}
|
|
4097
4531
|
const causeMessage = extractCauseMessage(error);
|
|
@@ -4316,6 +4750,7 @@ var BranchService = class {
|
|
|
4316
4750
|
metadata JSONB DEFAULT '{}'
|
|
4317
4751
|
);
|
|
4318
4752
|
`));
|
|
4753
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
|
|
4319
4754
|
}
|
|
4320
4755
|
/**
|
|
4321
4756
|
* Create a new branch database by templating the source database.
|
|
@@ -4422,241 +4857,6 @@ var BranchService = class {
|
|
|
4422
4857
|
}
|
|
4423
4858
|
};
|
|
4424
4859
|
//#endregion
|
|
4425
|
-
//#region src/security/rls-enforcement.ts
|
|
4426
|
-
/**
|
|
4427
|
-
* Unified RLS enforcement — the "user context vs server context" model.
|
|
4428
|
-
*
|
|
4429
|
-
* Every operation runs in one of two contexts:
|
|
4430
|
-
*
|
|
4431
|
-
* - **User context** — a request authenticated (or anonymous) via
|
|
4432
|
-
* `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a
|
|
4433
|
-
* non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*
|
|
4434
|
-
* statement (SELECT, INSERT, UPDATE, DELETE). The collection's
|
|
4435
|
-
* `securityRules` are the whole authorization model; app-layer callbacks
|
|
4436
|
-
* are validation/side-effects, not a security boundary.
|
|
4437
|
-
*
|
|
4438
|
-
* - **Server context** — the base (owner) connection: auth flows, migrations,
|
|
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`.
|
|
4442
|
-
*
|
|
4443
|
-
* This module provides the three pieces:
|
|
4444
|
-
*
|
|
4445
|
-
* 1. {@link detectConnectionPosture} — is the connection subject to RLS at
|
|
4446
|
-
* all? (superuser / BYPASSRLS / table owner ⇒ no)
|
|
4447
|
-
* 2. {@link ensureAppRole} — idempotently provision `rebase_user` with
|
|
4448
|
-
* SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future
|
|
4449
|
-
* tables stay covered).
|
|
4450
|
-
* 3. {@link applyAuthContext} — per-transaction: set the `app.*` GUCs the
|
|
4451
|
-
* policies read (`auth.uid()` etc.) and `SET LOCAL ROLE rebase_user` so
|
|
4452
|
-
* RLS binds. Transaction-scoped, so it composes with poolers.
|
|
4453
|
-
*
|
|
4454
|
-
* Provisioning runs from the framework's own bootstrap/migrate (which already
|
|
4455
|
-
* self-creates the `auth` schema and functions) — enforcement is default-on,
|
|
4456
|
-
* not an operator opt-in.
|
|
4457
|
-
*/
|
|
4458
|
-
/** The restricted role every authenticated (user-context) request runs as. */
|
|
4459
|
-
var REBASE_USER_ROLE = "rebase_user";
|
|
4460
|
-
var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
|
|
4461
|
-
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
4462
|
-
var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
4463
|
-
async function detectConnectionPosture(run) {
|
|
4464
|
-
const row = (await run(`
|
|
4465
|
-
SELECT current_user AS role,
|
|
4466
|
-
r.rolsuper AS superuser,
|
|
4467
|
-
r.rolbypassrls AS bypassrls,
|
|
4468
|
-
EXISTS (
|
|
4469
|
-
SELECT 1 FROM pg_tables t
|
|
4470
|
-
WHERE t.tableowner = current_user
|
|
4471
|
-
AND t.schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
4472
|
-
) AS owns_tables
|
|
4473
|
-
FROM pg_roles r
|
|
4474
|
-
WHERE r.rolname = current_user
|
|
4475
|
-
`))[0] ?? {};
|
|
4476
|
-
const superuser = row.superuser === true;
|
|
4477
|
-
const bypassRLS = row.bypassrls === true;
|
|
4478
|
-
const ownsTables = row.owns_tables === true;
|
|
4479
|
-
return {
|
|
4480
|
-
role: String(row.role ?? "unknown"),
|
|
4481
|
-
superuser,
|
|
4482
|
-
bypassRLS,
|
|
4483
|
-
ownsTables,
|
|
4484
|
-
privileged: superuser || bypassRLS || ownsTables
|
|
4485
|
-
};
|
|
4486
|
-
}
|
|
4487
|
-
/**
|
|
4488
|
-
* Human-actionable instructions for when the connection cannot provision the
|
|
4489
|
-
* user role itself (no CREATEROLE and role not pre-created by the platform).
|
|
4490
|
-
*/
|
|
4491
|
-
function appRoleSetupInstructions(connectionRole, schemas) {
|
|
4492
|
-
const grants = schemas.map((s) => `GRANT USAGE ON SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};\nGRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};\nGRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};`).join("\n");
|
|
4493
|
-
return `Rebase enforces row-level security by running authenticated requests as the restricted role "${REBASE_USER_ROLE}", but the connection role "${connectionRole}" bypasses RLS and cannot create that role itself.\nRun the following as a database administrator, then restart:\n\nCREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;\nGRANT ${REBASE_USER_ROLE} TO ${quoteIdent$1(connectionRole)};\n` + grants;
|
|
4494
|
-
}
|
|
4495
|
-
/**
|
|
4496
|
-
* Idempotently provision the `rebase_user` role, membership for the current
|
|
4497
|
-
* connection role, and DML grants (+ default privileges for future tables)
|
|
4498
|
-
* on every existing schema in `schemas`.
|
|
4499
|
-
*
|
|
4500
|
-
* Split into privilege tiers so it works both when the connection is a
|
|
4501
|
-
* superuser (creates everything) and when the platform pre-created the role
|
|
4502
|
-
* and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is
|
|
4503
|
-
* merely the table owner — owners can always run the grant tier themselves.
|
|
4504
|
-
*
|
|
4505
|
-
* RLS still filters every row: these grants only make the tables *reachable*
|
|
4506
|
-
* by the role; the policies decide which rows/commands actually pass.
|
|
4507
|
-
*
|
|
4508
|
-
* Throws with precise setup instructions when the role is missing and the
|
|
4509
|
-
* connection cannot create it.
|
|
4510
|
-
*/
|
|
4511
|
-
async function ensureAppRole(run, schemas) {
|
|
4512
|
-
const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
|
|
4513
|
-
if ((await run(`SELECT 1 FROM pg_roles WHERE rolname = 'rebase_user'`)).length === 0) try {
|
|
4514
|
-
await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
|
|
4515
|
-
} catch (err) {
|
|
4516
|
-
throw new Error(`Failed to create the "${REBASE_USER_ROLE}" role: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions("current connection role", uniqueSchemas));
|
|
4517
|
-
}
|
|
4518
|
-
const memberRows = await run(`
|
|
4519
|
-
SELECT (pg_has_role(current_user, '${REBASE_USER_ROLE}', 'MEMBER')
|
|
4520
|
-
OR (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)) AS can_set,
|
|
4521
|
-
current_user AS role
|
|
4522
|
-
`);
|
|
4523
|
-
if (memberRows[0]?.can_set !== true) try {
|
|
4524
|
-
await run(`GRANT ${REBASE_USER_ROLE} TO CURRENT_USER`);
|
|
4525
|
-
} catch (err) {
|
|
4526
|
-
throw new Error(`The connection role is not a member of "${REBASE_USER_ROLE}" and cannot grant itself membership: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions(String(memberRows[0]?.role ?? "current connection role"), uniqueSchemas));
|
|
4527
|
-
}
|
|
4528
|
-
const nspRows = await run("SELECT nspname FROM pg_namespace");
|
|
4529
|
-
const existing = new Set(nspRows.map((r) => String(r.nspname)));
|
|
4530
|
-
for (const schema of uniqueSchemas) {
|
|
4531
|
-
if (!existing.has(schema)) continue;
|
|
4532
|
-
const s = quoteIdent$1(schema);
|
|
4533
|
-
await run(`GRANT USAGE ON SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4534
|
-
await run(`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4535
|
-
await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
|
|
4536
|
-
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
4537
|
-
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
4538
|
-
}
|
|
4539
|
-
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
4540
|
-
}
|
|
4541
|
-
/**
|
|
4542
|
-
* Apply the authenticated context to a transaction: the `app.*` GUCs that RLS
|
|
4543
|
-
* policies read via `auth.uid()` / `auth.roles()` / `auth.jwt()`, and — when
|
|
4544
|
-
* `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this
|
|
4545
|
-
* transaction (reads *and* writes).
|
|
4546
|
-
*
|
|
4547
|
-
* GUCs are set with `is_local = true` and the role switch is `LOCAL`: both
|
|
4548
|
-
* reset at commit/rollback, so pooled connections are never polluted.
|
|
4549
|
-
*
|
|
4550
|
-
* Fails closed by construction: if the role switch errors, the transaction
|
|
4551
|
-
* aborts instead of proceeding privileged.
|
|
4552
|
-
*
|
|
4553
|
-
* SECURITY: this function is only ever called on the **user** path (the server
|
|
4554
|
-
* context uses the base/owner driver and never calls it). The default policies
|
|
4555
|
-
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
4556
|
-
* is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
|
|
4557
|
-
* be read as NULL and silently escalate a user request to server privileges.
|
|
4558
|
-
* Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
|
|
4559
|
-
* rather than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
4560
|
-
* That sentinel is exported from `@rebasepro/types` because it leaks into rule
|
|
4561
|
-
* semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
|
|
4562
|
-
*/
|
|
4563
|
-
async function applyAuthContext(tx, auth, userRole) {
|
|
4564
|
-
const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
|
|
4565
|
-
const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
|
|
4566
|
-
await tx.execute(sql`
|
|
4567
|
-
SELECT
|
|
4568
|
-
set_config('app.uid', ${uid}, true),
|
|
4569
|
-
set_config('app.user_id', ${uid}, true),
|
|
4570
|
-
set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
|
|
4571
|
-
set_config('app.jwt', ${JSON.stringify({
|
|
4572
|
-
sub: uid,
|
|
4573
|
-
roles: auth.roles
|
|
4574
|
-
})}, true)
|
|
4575
|
-
`);
|
|
4576
|
-
if (userRole) await tx.execute(sql.raw(`SET LOCAL ROLE ${quoteIdent$1(userRole)}`));
|
|
4577
|
-
}
|
|
4578
|
-
/** Role names from other BaaS platforms that people reach for out of habit. */
|
|
4579
|
-
var FOREIGN_CONVENTION_ROLES = {
|
|
4580
|
-
authenticated: "Supabase",
|
|
4581
|
-
anon: "Supabase",
|
|
4582
|
-
service_role: "Supabase"
|
|
4583
|
-
};
|
|
4584
|
-
/**
|
|
4585
|
-
* Warn about rules that read as "signed-in users only" but admit anonymous
|
|
4586
|
-
* callers — `auth.uid() IS NOT NULL`, or a comparison against another
|
|
4587
|
-
* platform's magic user id such as `'anon'`.
|
|
4588
|
-
*
|
|
4589
|
-
* The sibling of {@link validatePolicyPgRoles}, for the more dangerous spelling
|
|
4590
|
-
* of the same habit. A foreign `pgRoles` value makes a policy unreachable and
|
|
4591
|
-
* the table reads empty — loud, and that guard throws. These do the opposite:
|
|
4592
|
-
* the rule compiles to a grant, and nothing looks wrong until the data is
|
|
4593
|
-
* already public.
|
|
4594
|
-
*
|
|
4595
|
-
* Warns rather than throws. Unlike an unreachable `pgRoles`, these rules are
|
|
4596
|
-
* serving traffic today: refusing to boot would take an app offline to report a
|
|
4597
|
-
* problem it already has, and on the read path it would take it offline
|
|
4598
|
-
* *because* its data was exposed. Rewriting the author's SQL is not an option
|
|
4599
|
-
* either — this is the escape hatch whose whole promise is that it means what it
|
|
4600
|
-
* says. So: say so, loudly, and leave the rule alone.
|
|
4601
|
-
*/
|
|
4602
|
-
function warnOnAnonymousGrants(collections) {
|
|
4603
|
-
const byRisk = /* @__PURE__ */ new Map();
|
|
4604
|
-
for (const collection of collections) for (const rule of collection.securityRules ?? []) {
|
|
4605
|
-
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
4606
|
-
const risks = [usingExpr, withCheckExpr].filter((e) => e !== null).flatMap(findAnonymousGrants);
|
|
4607
|
-
for (const risk of risks) {
|
|
4608
|
-
const key = `${risk.pattern}:${risk.detail}`;
|
|
4609
|
-
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
4610
|
-
const entry = byRisk.get(key) ?? {
|
|
4611
|
-
risk,
|
|
4612
|
-
sites: []
|
|
4613
|
-
};
|
|
4614
|
-
if (!entry.sites.includes(site)) entry.sites.push(site);
|
|
4615
|
-
byRisk.set(key, entry);
|
|
4616
|
-
}
|
|
4617
|
-
}
|
|
4618
|
-
if (byRisk.size === 0) return;
|
|
4619
|
-
const problems = [...byRisk.values()].map(({ risk, sites }) => ` • ${risk.explanation}\n ${sites.length} rule(s): ${sites.join(", ")}`);
|
|
4620
|
-
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
|
-
}
|
|
4622
|
-
/**
|
|
4623
|
-
* Reject `pgRoles` that this server can never satisfy.
|
|
4624
|
-
*
|
|
4625
|
-
* `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
|
|
4626
|
-
* role the request never runs as simply never applies — and RLS then filters
|
|
4627
|
-
* every row. The table reads as empty, which is indistinguishable from having
|
|
4628
|
-
* no data, so the mistake survives review and ships.
|
|
4629
|
-
*
|
|
4630
|
-
* Requests run as `rebase_user`, so a policy is only reachable if it targets
|
|
4631
|
-
* `public` or a role `rebase_user` holds. Anything else is a configuration
|
|
4632
|
-
* error worth failing the boot for.
|
|
4633
|
-
*/
|
|
4634
|
-
async function validatePolicyPgRoles(run, collections, requestRole = REBASE_USER_ROLE) {
|
|
4635
|
-
const wanted = /* @__PURE__ */ new Map();
|
|
4636
|
-
for (const collection of collections) for (const rule of collection.securityRules ?? []) for (const role of rule.pgRoles ?? []) {
|
|
4637
|
-
if (role === "public") continue;
|
|
4638
|
-
wanted.set(role, [...wanted.get(role) ?? [], collection.slug ?? "(unnamed)"]);
|
|
4639
|
-
}
|
|
4640
|
-
if (wanted.size === 0) return;
|
|
4641
|
-
const names = [...wanted.keys()].map((r) => `'${r.replace(/'/g, "''")}'`).join(",");
|
|
4642
|
-
const rows = await run(`
|
|
4643
|
-
SELECT r.rolname AS role,
|
|
4644
|
-
COALESCE(pg_has_role(to_regrole('${requestRole.replace(/'/g, "''")}'), r.oid, 'MEMBER'), false) AS reachable
|
|
4645
|
-
FROM pg_roles r
|
|
4646
|
-
WHERE r.rolname IN (${names})
|
|
4647
|
-
`);
|
|
4648
|
-
const reachable = new Map(rows.map((row) => [String(row.role), row.reachable === true]));
|
|
4649
|
-
const problems = [];
|
|
4650
|
-
for (const [role, slugs] of wanted) {
|
|
4651
|
-
if (reachable.get(role) === true) continue;
|
|
4652
|
-
const why = reachable.has(role) ? `"${requestRole}" is not a member of it` : "no such role exists in this database";
|
|
4653
|
-
const platform = FOREIGN_CONVENTION_ROLES[role];
|
|
4654
|
-
const hint = platform ? `"${role}" is a ${platform} convention, not a PostgreSQL role. Application roles belong in \`roles: ["${role === "service_role" ? "admin" : role}"]\`, which is checked inside the policy via auth.roles().` : `Either grant it (GRANT ${role} TO ${requestRole}) or drop \`pgRoles\` so the policy targets \`public\`.`;
|
|
4655
|
-
problems.push(` • pgRoles: ["${role}"] on ${slugs.join(", ")} — ${why}.\n ${hint}`);
|
|
4656
|
-
}
|
|
4657
|
-
if (problems.length > 0) throw new Error(`Security rules target PostgreSQL roles this server cannot use. Requests run as "${requestRole}", so these policies would never apply and every row would be filtered out — the collections would look empty rather than error.\n\n` + problems.join("\n\n") + "\n");
|
|
4658
|
-
}
|
|
4659
|
-
//#endregion
|
|
4660
4860
|
//#region src/PostgresBackendDriver.ts
|
|
4661
4861
|
var PostgresBackendDriver = class PostgresBackendDriver {
|
|
4662
4862
|
db;
|
|
@@ -4749,6 +4949,25 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
4749
4949
|
}
|
|
4750
4950
|
};
|
|
4751
4951
|
}
|
|
4952
|
+
/**
|
|
4953
|
+
* Build the context handed to every collection callback.
|
|
4954
|
+
*
|
|
4955
|
+
* Note `data: this.data` — `this` is whichever driver is running the
|
|
4956
|
+
* operation, so the callback's data plane inherits that driver's privilege.
|
|
4957
|
+
* On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
|
|
4958
|
+
* constructs a fresh base driver bound to the RLS-scoped transaction and
|
|
4959
|
+
* runs the operation on it, so `this.data` speaks through that connection
|
|
4960
|
+
* and policies apply. On server-context work `this` is the base driver on
|
|
4961
|
+
* the owner connection, and they do not. Pinned by the
|
|
4962
|
+
* `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
|
|
4963
|
+
* suite, because it is the kind of property that is easy to break from a
|
|
4964
|
+
* distance and impossible to notice.
|
|
4965
|
+
*
|
|
4966
|
+
* Previously returned through `as unknown as RebaseCallContext`, which
|
|
4967
|
+
* disabled checking for the whole object and let `driver` — documented in
|
|
4968
|
+
* the callbacks guide — sit on the runtime context while absent from the
|
|
4969
|
+
* contract. Both are declared now, so this is a plain typed return.
|
|
4970
|
+
*/
|
|
4752
4971
|
buildCallContext() {
|
|
4753
4972
|
return {
|
|
4754
4973
|
user: this.user,
|
|
@@ -5204,6 +5423,106 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5204
5423
|
return saved;
|
|
5205
5424
|
});
|
|
5206
5425
|
}
|
|
5426
|
+
/**
|
|
5427
|
+
* Update many rows through the same pipeline as {@link save}, in one
|
|
5428
|
+
* transaction.
|
|
5429
|
+
*
|
|
5430
|
+
* Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
|
|
5431
|
+
* same deferred notifications, same per-row error labelling — but it calls
|
|
5432
|
+
* `save` with an explicit `id` and `status: "existing"`, which is precisely
|
|
5433
|
+
* what `saveMany` cannot do: that one passes `status: "new"` and keeps the
|
|
5434
|
+
* key inside `values`, so it inserts or upserts and can never target a
|
|
5435
|
+
* particular row.
|
|
5436
|
+
*
|
|
5437
|
+
* All-or-nothing, so an id matching no row aborts the batch. A partial
|
|
5438
|
+
* update is the outcome with no good recovery: the caller cannot tell which
|
|
5439
|
+
* half landed without re-reading everything.
|
|
5440
|
+
*/
|
|
5441
|
+
async updateMany({ path, updates, collection }) {
|
|
5442
|
+
return this.db.transaction(async (tx) => {
|
|
5443
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
5444
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
5445
|
+
txDriver.client = this.client;
|
|
5446
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
5447
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
5448
|
+
const saved = [];
|
|
5449
|
+
for (let i = 0; i < updates.length; i++) {
|
|
5450
|
+
const { id, values } = updates[i];
|
|
5451
|
+
try {
|
|
5452
|
+
if (!await txDriver.fetchOne({
|
|
5453
|
+
path,
|
|
5454
|
+
id: String(id),
|
|
5455
|
+
collection
|
|
5456
|
+
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
5457
|
+
statusCode: 404,
|
|
5458
|
+
code: "NOT_FOUND"
|
|
5459
|
+
});
|
|
5460
|
+
saved.push(await txDriver.save({
|
|
5461
|
+
path,
|
|
5462
|
+
id: String(id),
|
|
5463
|
+
values,
|
|
5464
|
+
collection,
|
|
5465
|
+
status: "existing"
|
|
5466
|
+
}));
|
|
5467
|
+
} catch (error) {
|
|
5468
|
+
throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
5469
|
+
statusCode: error?.statusCode,
|
|
5470
|
+
code: error?.code,
|
|
5471
|
+
name: error?.name
|
|
5472
|
+
});
|
|
5473
|
+
}
|
|
5474
|
+
}
|
|
5475
|
+
return saved;
|
|
5476
|
+
});
|
|
5477
|
+
}
|
|
5478
|
+
/**
|
|
5479
|
+
* Delete many rows in one transaction, running the full delete pipeline —
|
|
5480
|
+
* `beforeDelete`, the delete, `afterDelete` — for each.
|
|
5481
|
+
*
|
|
5482
|
+
* Looping the single-row {@link delete} rather than emitting one
|
|
5483
|
+
* `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
|
|
5484
|
+
* statement would be faster and would skip every callback, so a collection
|
|
5485
|
+
* relying on `beforeDelete` to veto or on `afterDelete` to clean up
|
|
5486
|
+
* dependents would behave differently depending on how many rows the caller
|
|
5487
|
+
* happened to delete at once. Same pipeline, one transaction.
|
|
5488
|
+
*/
|
|
5489
|
+
async deleteMany({ path, ids, collection }) {
|
|
5490
|
+
await this.db.transaction(async (tx) => {
|
|
5491
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
5492
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
5493
|
+
txDriver.client = this.client;
|
|
5494
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
5495
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
5496
|
+
for (let i = 0; i < ids.length; i++) {
|
|
5497
|
+
const id = ids[i];
|
|
5498
|
+
try {
|
|
5499
|
+
const existing = await txDriver.fetchOne({
|
|
5500
|
+
path,
|
|
5501
|
+
id: String(id),
|
|
5502
|
+
collection
|
|
5503
|
+
});
|
|
5504
|
+
if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
5505
|
+
statusCode: 404,
|
|
5506
|
+
code: "NOT_FOUND"
|
|
5507
|
+
});
|
|
5508
|
+
await txDriver.delete({
|
|
5509
|
+
row: {
|
|
5510
|
+
id: String(id),
|
|
5511
|
+
path,
|
|
5512
|
+
values: existing
|
|
5513
|
+
},
|
|
5514
|
+
collection
|
|
5515
|
+
});
|
|
5516
|
+
} catch (error) {
|
|
5517
|
+
throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
5518
|
+
statusCode: error?.statusCode,
|
|
5519
|
+
code: error?.code,
|
|
5520
|
+
name: error?.name
|
|
5521
|
+
});
|
|
5522
|
+
}
|
|
5523
|
+
}
|
|
5524
|
+
});
|
|
5525
|
+
}
|
|
5207
5526
|
async delete({ row, collection }) {
|
|
5208
5527
|
const targetPath = row.path;
|
|
5209
5528
|
const targetRow = { ...row.values ?? {} };
|
|
@@ -5781,6 +6100,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
5781
6100
|
* that rotates immediately after it.
|
|
5782
6101
|
*/
|
|
5783
6102
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
6103
|
+
/**
|
|
6104
|
+
* The assurance level the sign-in was established at — `aal2` only
|
|
6105
|
+
* where a second factor was actually presented. Carried across
|
|
6106
|
+
* rotations, because refresh is not a new authentication and has
|
|
6107
|
+
* nothing else to read the level from.
|
|
6108
|
+
*/
|
|
6109
|
+
aal: text("aal"),
|
|
5784
6110
|
userAgent: text("user_agent"),
|
|
5785
6111
|
ipAddress: text("ip_address"),
|
|
5786
6112
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -5826,6 +6152,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
5826
6152
|
secretEncrypted: text("secret_encrypted").notNull(),
|
|
5827
6153
|
friendlyName: text("friendly_name"),
|
|
5828
6154
|
verified: boolean("verified").default(false).notNull(),
|
|
6155
|
+
/**
|
|
6156
|
+
* The highest TOTP time step ever accepted for this factor. RFC 6238
|
|
6157
|
+
* §5.2 forbids accepting an OTP twice, and the ±1 step window that
|
|
6158
|
+
* exists for clock drift is also a 90-second replay window: without
|
|
6159
|
+
* this, one observed code buys a fresh session for a minute and a half.
|
|
6160
|
+
*/
|
|
6161
|
+
lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
|
|
5829
6162
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
5830
6163
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
5831
6164
|
});
|
|
@@ -5843,6 +6176,8 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
5843
6176
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
5844
6177
|
verifiedAt: timestamp("verified_at"),
|
|
5845
6178
|
ipAddress: text("ip_address"),
|
|
6179
|
+
/** Failed guesses recorded against this challenge; bounded by the route. */
|
|
6180
|
+
attempts: integer("attempts").default(0).notNull(),
|
|
5846
6181
|
expiresAt: timestamp("expires_at").notNull()
|
|
5847
6182
|
}),
|
|
5848
6183
|
recoveryCodes: tableCreator("recovery_codes", {
|
|
@@ -5919,6 +6254,26 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
|
|
|
5919
6254
|
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
5920
6255
|
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
5921
6256
|
*/
|
|
6257
|
+
var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
6258
|
+
/**
|
|
6259
|
+
* A string literal for the generated schema file.
|
|
6260
|
+
*
|
|
6261
|
+
* Column names, table names and enum values are all written into this file as
|
|
6262
|
+
* literals, and none of them is constrained to be quote-free: a Postgres
|
|
6263
|
+
* identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
|
|
6264
|
+
* Interpolating them raw ended the literal early — for enum values, inside
|
|
6265
|
+
* single quotes, where an apostrophe is not an edge case.
|
|
6266
|
+
*/
|
|
6267
|
+
var quote$1 = (value) => JSON.stringify(value);
|
|
6268
|
+
/** An object key: verbatim when it is an identifier, quoted otherwise. */
|
|
6269
|
+
var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
|
|
6270
|
+
/**
|
|
6271
|
+
* A property access on a generated table variable.
|
|
6272
|
+
*
|
|
6273
|
+
* `users.full name` is not an expression; `users["full name"]` is, and Drizzle
|
|
6274
|
+
* treats the two identically.
|
|
6275
|
+
*/
|
|
6276
|
+
var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
|
|
5922
6277
|
var resolveColumnName = (propName, prop) => {
|
|
5923
6278
|
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
5924
6279
|
return toSnakeCase(propName);
|
|
@@ -5949,26 +6304,16 @@ var getPrimaryKeyProp = (collection) => {
|
|
|
5949
6304
|
};
|
|
5950
6305
|
};
|
|
5951
6306
|
/**
|
|
5952
|
-
* Given a raw DB column name (e.g. "client_id"),
|
|
5953
|
-
*
|
|
5954
|
-
* (a) it has an explicit `columnName` equal to the given column, OR
|
|
5955
|
-
* (b) its snake_case form equals the given column.
|
|
6307
|
+
* Given a raw DB column name (e.g. "client_id"), the Drizzle property key that
|
|
6308
|
+
* maps to it.
|
|
5956
6309
|
*
|
|
5957
|
-
*
|
|
5958
|
-
*
|
|
6310
|
+
* One line, because the rule is shared: the Drizzle object key is the wire
|
|
6311
|
+
* name, and {@link fieldKeyForColumn} is the one definition of what a column is
|
|
6312
|
+
* named on the wire. This used to be a private copy that fell back to the
|
|
6313
|
+
* column verbatim, which is how a derived foreign key ended up served as
|
|
6314
|
+
* `author_id` beside a hand-authored `displayName`.
|
|
5959
6315
|
*/
|
|
5960
|
-
var resolvePropertyKeyForColumn = (collection, column) =>
|
|
5961
|
-
if (!collection.properties) return column;
|
|
5962
|
-
for (const [propKey, prop] of Object.entries(collection.properties)) {
|
|
5963
|
-
const p = prop;
|
|
5964
|
-
if ("columnName" in p && typeof p.columnName === "string") {
|
|
5965
|
-
if (p.columnName === column) return propKey;
|
|
5966
|
-
}
|
|
5967
|
-
if (toSnakeCase(propKey) === column) return propKey;
|
|
5968
|
-
if (propKey === column) return propKey;
|
|
5969
|
-
}
|
|
5970
|
-
return column;
|
|
5971
|
-
};
|
|
6316
|
+
var resolvePropertyKeyForColumn = (collection, column) => fieldKeyForColumn(collection, column);
|
|
5972
6317
|
var isNumericId = (collection) => {
|
|
5973
6318
|
return getPrimaryKeyProp(collection).type === "number";
|
|
5974
6319
|
};
|
|
@@ -5979,18 +6324,25 @@ var isIdProperty = (propName, prop, collection) => {
|
|
|
5979
6324
|
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
5980
6325
|
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
5981
6326
|
};
|
|
6327
|
+
/**
|
|
6328
|
+
* The Drizzle column declaration a property compiles to, or `null` when the
|
|
6329
|
+
* property puts no column on *this* table (an inverse relation, whose column
|
|
6330
|
+
* lives on the target). Exported so it can be checked against its DDL twin
|
|
6331
|
+
* `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
|
|
6332
|
+
* with a database column and no Drizzle key.
|
|
6333
|
+
*/
|
|
5982
6334
|
var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
5983
6335
|
const colName = resolveColumnName(propName, prop);
|
|
5984
6336
|
let columnDefinition;
|
|
5985
6337
|
switch (prop.type) {
|
|
5986
6338
|
case "string": {
|
|
5987
6339
|
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(
|
|
6340
|
+
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
|
|
6341
|
+
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
6342
|
+
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
6343
|
+
else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
6344
|
+
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
6345
|
+
else columnDefinition = `text(${quote$1(colName)})`;
|
|
5994
6346
|
if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
|
|
5995
6347
|
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
5996
6348
|
if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
|
|
@@ -6006,10 +6358,10 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6006
6358
|
case "number": {
|
|
6007
6359
|
const numProp = prop;
|
|
6008
6360
|
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}(
|
|
6361
|
+
let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
|
|
6362
|
+
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
|
|
6363
|
+
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
|
|
6364
|
+
else baseType = `${numProp.columnType}(${quote$1(colName)})`;
|
|
6013
6365
|
if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
6014
6366
|
else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
6015
6367
|
columnDefinition = baseType;
|
|
@@ -6021,19 +6373,22 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6021
6373
|
break;
|
|
6022
6374
|
}
|
|
6023
6375
|
case "boolean":
|
|
6024
|
-
columnDefinition = `boolean(
|
|
6376
|
+
columnDefinition = `boolean(${quote$1(colName)})`;
|
|
6025
6377
|
break;
|
|
6026
6378
|
case "date": {
|
|
6027
6379
|
const dateProp = prop;
|
|
6028
|
-
if (dateProp.columnType === "date") columnDefinition = `date(
|
|
6029
|
-
else if (dateProp.columnType === "time") columnDefinition = `time(
|
|
6030
|
-
else columnDefinition = `timestamp(
|
|
6380
|
+
if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
|
|
6381
|
+
else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
|
|
6382
|
+
else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
|
|
6031
6383
|
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
|
|
6032
6384
|
break;
|
|
6033
6385
|
}
|
|
6034
6386
|
case "map":
|
|
6035
|
-
if (prop.columnType === "json") columnDefinition = `json(
|
|
6036
|
-
else columnDefinition = `jsonb(
|
|
6387
|
+
if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
6388
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6389
|
+
break;
|
|
6390
|
+
case "geopoint":
|
|
6391
|
+
columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6037
6392
|
break;
|
|
6038
6393
|
case "array": {
|
|
6039
6394
|
const arrayProp = prop;
|
|
@@ -6044,25 +6399,28 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6044
6399
|
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
6045
6400
|
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
6046
6401
|
}
|
|
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(
|
|
6402
|
+
if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
6403
|
+
else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
|
|
6404
|
+
else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
|
|
6405
|
+
else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
|
|
6406
|
+
else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
|
|
6407
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
6053
6408
|
break;
|
|
6054
6409
|
}
|
|
6055
|
-
case "vector":
|
|
6056
|
-
|
|
6410
|
+
case "vector": {
|
|
6411
|
+
const vp = prop;
|
|
6412
|
+
columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
|
|
6057
6413
|
break;
|
|
6414
|
+
}
|
|
6058
6415
|
case "binary":
|
|
6059
|
-
columnDefinition = `customType({ dataType() { return 'bytea'; } })(
|
|
6416
|
+
columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
|
|
6060
6417
|
break;
|
|
6061
6418
|
case "relation": {
|
|
6062
6419
|
const refProp = prop;
|
|
6063
6420
|
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
6064
6421
|
if (!relation || relation.kind !== "belongsTo") return null;
|
|
6065
|
-
|
|
6422
|
+
const fkFieldKey = fieldKeyForColumn(collection, relation.localKey);
|
|
6423
|
+
if (collection.properties[fkFieldKey] && propName !== fkFieldKey) return null;
|
|
6066
6424
|
let targetCollection;
|
|
6067
6425
|
try {
|
|
6068
6426
|
targetCollection = relation.target();
|
|
@@ -6078,30 +6436,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6078
6436
|
const required = prop.validation?.required;
|
|
6079
6437
|
const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
|
|
6080
6438
|
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
6081
|
-
let columnDef = `${baseColumn}.references(() => ${targetTableVar
|
|
6439
|
+
let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
|
|
6082
6440
|
if (required) columnDef += ".notNull()";
|
|
6083
|
-
return ` ${
|
|
6441
|
+
return ` ${propKey(fkFieldKey)}: ${columnDef}`;
|
|
6084
6442
|
}
|
|
6085
6443
|
case "reference": {
|
|
6086
6444
|
const refProp = prop;
|
|
6087
6445
|
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
|
|
6088
6446
|
if (!targetCollection) {
|
|
6089
|
-
columnDefinition = `text(
|
|
6447
|
+
columnDefinition = `text(${quote$1(colName)})`;
|
|
6090
6448
|
break;
|
|
6091
6449
|
}
|
|
6092
6450
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
6093
6451
|
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
6094
6452
|
const targetIdField = pkProp.name;
|
|
6095
|
-
const baseColumn = pkProp.type === "number" ? `integer(
|
|
6453
|
+
const baseColumn = pkProp.type === "number" ? `integer(${quote$1(colName)})` : pkProp.isUuid ? `uuid(${quote$1(colName)})` : `text(${quote$1(colName)})`;
|
|
6096
6454
|
const required = prop.validation?.required;
|
|
6097
|
-
|
|
6455
|
+
const refOptions = `{ onDelete: "${required ? "cascade" : "set null"}" }`;
|
|
6456
|
+
columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
|
|
6098
6457
|
if (required) columnDefinition += ".notNull()";
|
|
6099
|
-
return ` ${propName}: ${columnDefinition}`;
|
|
6458
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
6100
6459
|
}
|
|
6101
|
-
default:
|
|
6460
|
+
default: throw new Error(`No Postgres column mapping for property '${propName}' of type '${prop.type}' in collection '${collection.slug}'. Add a case to \`getDrizzleColumn\` (and to \`getSqlColumnType\`, which must agree).`);
|
|
6102
6461
|
}
|
|
6103
6462
|
if (prop.validation?.required) columnDefinition += ".notNull()";
|
|
6104
|
-
return ` ${propName}: ${columnDefinition}`;
|
|
6463
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
6105
6464
|
};
|
|
6106
6465
|
/**
|
|
6107
6466
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
@@ -6134,7 +6493,7 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
|
|
|
6134
6493
|
parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
|
|
6135
6494
|
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
6136
6495
|
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
6137
|
-
return ` pgPolicy(
|
|
6496
|
+
return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
|
|
6138
6497
|
};
|
|
6139
6498
|
/**
|
|
6140
6499
|
* Computes a deterministic shared relation name for Drizzle.
|
|
@@ -6168,11 +6527,13 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
|
6168
6527
|
}
|
|
6169
6528
|
return fallback;
|
|
6170
6529
|
};
|
|
6171
|
-
var generateSchema = async (
|
|
6530
|
+
var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
6531
|
+
const collections = sortCollectionsBySlug(relationalCollections(allCollections));
|
|
6172
6532
|
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
6173
6533
|
const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
|
|
6174
6534
|
const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
|
|
6175
6535
|
const hasBinary = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "binary"));
|
|
6536
|
+
const hasSearch = collections.some((c) => buildSearchColumnSpec(c) !== void 0);
|
|
6176
6537
|
const pgCoreImports = [
|
|
6177
6538
|
"primaryKey",
|
|
6178
6539
|
"pgTable",
|
|
@@ -6197,7 +6558,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6197
6558
|
];
|
|
6198
6559
|
if (hasUuid) pgCoreImports.push("uuid");
|
|
6199
6560
|
if (hasVector) pgCoreImports.push("vector");
|
|
6200
|
-
if (hasBinary) pgCoreImports.push("customType");
|
|
6561
|
+
if (hasBinary || hasSearch) pgCoreImports.push("customType");
|
|
6201
6562
|
const uniqueSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
|
|
6202
6563
|
if (uniqueSchemas.length > 0) pgCoreImports.push("pgSchema");
|
|
6203
6564
|
schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
|
|
@@ -6218,7 +6579,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6218
6579
|
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
6219
6580
|
const values = Array.isArray(prop.enum) ? prop.enum.map((v) => String(typeof v === "object" && v !== null && "id" in v ? v.id : v)) : Object.keys(prop.enum);
|
|
6220
6581
|
if (values.length > 0) {
|
|
6221
|
-
schemaContent += `export const ${enumVarName} = pgEnum(
|
|
6582
|
+
schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
|
|
6222
6583
|
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
6223
6584
|
}
|
|
6224
6585
|
}
|
|
@@ -6279,6 +6640,11 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6279
6640
|
const columnString = getDrizzleColumn(propName, prop, collection, collections);
|
|
6280
6641
|
if (columnString) columns.add(columnString);
|
|
6281
6642
|
});
|
|
6643
|
+
const searchSpec = buildSearchColumnSpec(collection);
|
|
6644
|
+
if (searchSpec) {
|
|
6645
|
+
columns.add(` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}").generatedAlwaysAs(sql\`${searchSpec.expression}\`)`);
|
|
6646
|
+
if (searchSpec.fuzzy) columns.add(` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}").generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`);
|
|
6647
|
+
}
|
|
6282
6648
|
if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
|
|
6283
6649
|
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
6284
6650
|
const securityRules = getEffectiveSecurityRules(collection);
|
|
@@ -6315,9 +6681,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6315
6681
|
break;
|
|
6316
6682
|
}
|
|
6317
6683
|
} catch {}
|
|
6318
|
-
tableRelations.push(`
|
|
6684
|
+
tableRelations.push(` ${quote$1(relation.through.sourceColumn)}: one(${sourceTableVar}, {\n fields: [${member(tableVarName, relation.through.sourceColumn)}],\n references: [${member(sourceTableVar, sourceId)}],\n relationName: ${quote$1(owningRelationName)}\n })`);
|
|
6319
6685
|
const targetRelationName = inverseRelationName ? inverseRelationName : `${tableName}_${relation.through.targetColumn}`;
|
|
6320
|
-
tableRelations.push(`
|
|
6686
|
+
tableRelations.push(` ${quote$1(relation.through.targetColumn)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, relation.through.targetColumn)}],\n references: [${member(targetTableVar, targetId)}],\n relationName: ${quote$1(targetRelationName)}\n })`);
|
|
6321
6687
|
}
|
|
6322
6688
|
} else {
|
|
6323
6689
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
@@ -6332,7 +6698,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6332
6698
|
switch (rel.kind) {
|
|
6333
6699
|
case "belongsTo": {
|
|
6334
6700
|
const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
|
|
6335
|
-
tableRelations.push(`
|
|
6701
|
+
tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6336
6702
|
break;
|
|
6337
6703
|
}
|
|
6338
6704
|
case "hasOne":
|
|
@@ -6363,7 +6729,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6363
6729
|
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
6364
6730
|
const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
|
|
6365
6731
|
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
6366
|
-
tableRelations.push(`
|
|
6732
|
+
tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6367
6733
|
emittedRelationNames.add(deduplicationKey);
|
|
6368
6734
|
}
|
|
6369
6735
|
}
|
|
@@ -6383,6 +6749,44 @@ var generateSchema = async (collections, stripPolicies = false) => {
|
|
|
6383
6749
|
return schemaContent;
|
|
6384
6750
|
};
|
|
6385
6751
|
//#endregion
|
|
6752
|
+
//#region src/cli-output.ts
|
|
6753
|
+
/**
|
|
6754
|
+
* Terminal output for the `rebase db|schema|doctor` commands.
|
|
6755
|
+
*
|
|
6756
|
+
* These commands used to write every line through `logger`, and that is a
|
|
6757
|
+
* category error with three separate consequences:
|
|
6758
|
+
*
|
|
6759
|
+
* - `logger` prefixes each line with its own level, so a box-drawn report
|
|
6760
|
+
* arrived as `ℹ️ [INFO] ┌─ ✗ Missing Column ───` and the frame no longer
|
|
6761
|
+
* lined up with anything;
|
|
6762
|
+
* - `logger` is gated by `LOG_LEVEL`, which ships in the scaffold's own
|
|
6763
|
+
* `.env.example` — a developer who quietened their dev server with
|
|
6764
|
+
* `LOG_LEVEL=warn` got a `rebase db push` that printed almost nothing and
|
|
6765
|
+
* still exited non-zero, indistinguishable from a crash;
|
|
6766
|
+
* - under `NODE_ENV=production` `logger` emits JSON, so the whole report
|
|
6767
|
+
* became log records with the chalk escape codes embedded in them.
|
|
6768
|
+
*
|
|
6769
|
+
* A CLI's report *is* its return value. It goes to the terminal unconditionally
|
|
6770
|
+
* and unadorned. `packages/cli` has always written its output this way; this is
|
|
6771
|
+
* the same three functions for the plugin CLI that `rebase` delegates to.
|
|
6772
|
+
*
|
|
6773
|
+
* `logger` still belongs in this package's *runtime* — a request handler has no
|
|
6774
|
+
* terminal and its lines want levels, timestamps and redaction. The rule is the
|
|
6775
|
+
* caller, not the severity: anything a developer reads because they typed a
|
|
6776
|
+
* command goes here, anything a server emits while running goes to `logger`.
|
|
6777
|
+
*
|
|
6778
|
+
* Errors and warnings go to stderr so `rebase db push > plan.txt` keeps the
|
|
6779
|
+
* diagnosis on the terminal where it is readable.
|
|
6780
|
+
*/
|
|
6781
|
+
/** One line of human-facing output on stdout. */
|
|
6782
|
+
var out = (line = "") => {
|
|
6783
|
+
console.log(line);
|
|
6784
|
+
};
|
|
6785
|
+
/** One line of human-facing error output on stderr. */
|
|
6786
|
+
var outError = (line = "") => {
|
|
6787
|
+
console.error(line);
|
|
6788
|
+
};
|
|
6789
|
+
//#endregion
|
|
6386
6790
|
//#region src/schema/generate-drizzle-schema.ts
|
|
6387
6791
|
var formatTerminalText = (text, options = {}) => {
|
|
6388
6792
|
let codes = "";
|
|
@@ -6410,7 +6814,7 @@ var formatTerminalText = (text, options = {}) => {
|
|
|
6410
6814
|
var runGeneration = async (collectionsFilePath, outputPath) => {
|
|
6411
6815
|
try {
|
|
6412
6816
|
if (!collectionsFilePath) {
|
|
6413
|
-
|
|
6817
|
+
outError("Error: No collections file path provided. Skipping schema generation.");
|
|
6414
6818
|
return;
|
|
6415
6819
|
}
|
|
6416
6820
|
let collections = await loadCollectionsFromDirectory(path.resolve(collectionsFilePath));
|
|
@@ -6421,18 +6825,18 @@ var runGeneration = async (collectionsFilePath, outputPath) => {
|
|
|
6421
6825
|
const outputDir = path.dirname(outputPath);
|
|
6422
6826
|
await promises.mkdir(outputDir, { recursive: true });
|
|
6423
6827
|
await promises.writeFile(outputPath, schemaContent);
|
|
6424
|
-
|
|
6828
|
+
out(`✅ Drizzle schema generated successfully at ${outputPath}`);
|
|
6425
6829
|
} else {
|
|
6426
|
-
|
|
6427
|
-
|
|
6830
|
+
out("✅ Drizzle schema generated successfully.");
|
|
6831
|
+
out(String(schemaContent));
|
|
6428
6832
|
}
|
|
6429
|
-
|
|
6833
|
+
out(`You can now run ${formatTerminalText("rebase db generate", {
|
|
6430
6834
|
bold: true,
|
|
6431
6835
|
backgroundColor: "blue",
|
|
6432
6836
|
textColor: "black"
|
|
6433
6837
|
})} to generate the SQL migration files.`);
|
|
6434
6838
|
} catch (error) {
|
|
6435
|
-
|
|
6839
|
+
outError(`Error generating schema: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
6436
6840
|
}
|
|
6437
6841
|
};
|
|
6438
6842
|
var main = () => {
|
|
@@ -6442,18 +6846,18 @@ var main = () => {
|
|
|
6442
6846
|
const outputPath = outputPathArg ? outputPathArg.split("=")[1] : void 0;
|
|
6443
6847
|
const watch = process.argv.includes("--watch");
|
|
6444
6848
|
if (!collectionsFilePath) {
|
|
6445
|
-
|
|
6849
|
+
out("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
|
|
6446
6850
|
return;
|
|
6447
6851
|
}
|
|
6448
6852
|
const resolvedPath = path.resolve(process.cwd(), collectionsFilePath);
|
|
6449
6853
|
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : void 0;
|
|
6450
6854
|
if (watch) {
|
|
6451
|
-
|
|
6855
|
+
out(`Watching for changes in ${resolvedPath}...`);
|
|
6452
6856
|
chokidar.watch(resolvedPath, {
|
|
6453
6857
|
persistent: true,
|
|
6454
6858
|
ignoreInitial: false
|
|
6455
6859
|
}).on("all", (event, filePath) => {
|
|
6456
|
-
|
|
6860
|
+
out(`[${event}] ${filePath}. Regenerating schema...`);
|
|
6457
6861
|
runGeneration(resolvedPath, resolvedOutputPath);
|
|
6458
6862
|
});
|
|
6459
6863
|
} else runGeneration(resolvedPath, resolvedOutputPath);
|
|
@@ -6637,7 +7041,7 @@ async function provisionTriggerCdc(run, tables) {
|
|
|
6637
7041
|
logger.warn(`⚠️ [CDC] Could not attach change-capture trigger to "${key}" — is the table migrated? Writes to it won't emit database-level events.`, { detail: reason });
|
|
6638
7042
|
}
|
|
6639
7043
|
}
|
|
6640
|
-
logger.
|
|
7044
|
+
logger.debug(`📡 [CDC] Trigger-based change capture provisioned on ${installed.length} table(s)` + (skipped.length ? ` (${skipped.length} skipped)` : "") + ".");
|
|
6641
7045
|
return {
|
|
6642
7046
|
installed,
|
|
6643
7047
|
skipped
|
|
@@ -6727,7 +7131,7 @@ var PgNotifyListener = class {
|
|
|
6727
7131
|
await client.connect();
|
|
6728
7132
|
await client.query(`LISTEN ${channel}`);
|
|
6729
7133
|
this.client = client;
|
|
6730
|
-
logger.
|
|
7134
|
+
logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
6731
7135
|
} catch (err) {
|
|
6732
7136
|
if (initial) throw err;
|
|
6733
7137
|
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
@@ -6982,6 +7386,8 @@ var ChannelHistoryStore = class {
|
|
|
6982
7386
|
last_seq BIGINT NOT NULL
|
|
6983
7387
|
)
|
|
6984
7388
|
`);
|
|
7389
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
|
|
7390
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
|
|
6985
7391
|
this.tablesReady = true;
|
|
6986
7392
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
6987
7393
|
}
|
|
@@ -7165,6 +7571,7 @@ var ChannelPresenceStore = class {
|
|
|
7165
7571
|
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
7166
7572
|
ON rebase.channel_presence (last_seen)
|
|
7167
7573
|
`);
|
|
7574
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
|
|
7168
7575
|
this.tablesReady = true;
|
|
7169
7576
|
}
|
|
7170
7577
|
/** Record (or refresh) a client's presence. */
|
|
@@ -7556,6 +7963,12 @@ var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
|
7556
7963
|
var RealtimeService = class RealtimeService extends EventEmitter {
|
|
7557
7964
|
db;
|
|
7558
7965
|
registry;
|
|
7966
|
+
/**
|
|
7967
|
+
* Declares to the multi-engine router that channel frames can be handled
|
|
7968
|
+
* here. Read by `createRoutedRealtimeService`, which otherwise would have to
|
|
7969
|
+
* guess — and guessed "the default provider", whichever engine that is.
|
|
7970
|
+
*/
|
|
7971
|
+
supportsChannels = true;
|
|
7559
7972
|
clients = /* @__PURE__ */ new Map();
|
|
7560
7973
|
channels = /* @__PURE__ */ new Map();
|
|
7561
7974
|
presence = /* @__PURE__ */ new Map();
|
|
@@ -7602,6 +8015,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7602
8015
|
* so a hot channel logs the problem once rather than once per message.
|
|
7603
8016
|
*/
|
|
7604
8017
|
oversizedBroadcastWarned = /* @__PURE__ */ new Set();
|
|
8018
|
+
/**
|
|
8019
|
+
* Optional narrowing on top of the membership floor — see
|
|
8020
|
+
* {@link ChannelAuthorizer}. Unset by default, which leaves membership as
|
|
8021
|
+
* the whole of the rule.
|
|
8022
|
+
*/
|
|
8023
|
+
channelAuthorizer;
|
|
8024
|
+
/**
|
|
8025
|
+
* Whether a notification from another instance has ever arrived.
|
|
8026
|
+
*
|
|
8027
|
+
* The entity LISTEN handler sees a foreign `sid` on every cross-instance
|
|
8028
|
+
* change, which is proof that this deployment runs more than one pod — the
|
|
8029
|
+
* one fact needed to tell "the memory bus is fine here" from "broadcast and
|
|
8030
|
+
* presence silently reach a fraction of your users".
|
|
8031
|
+
*/
|
|
8032
|
+
foreignInstanceSeen = false;
|
|
8033
|
+
/** So the multi-pod memory-bus warning is emitted once, not once per join. */
|
|
8034
|
+
memoryBusWarned = false;
|
|
7605
8035
|
presenceInterval;
|
|
7606
8036
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
7607
8037
|
/** How often stale roster rows from other instances are reaped. */
|
|
@@ -7695,7 +8125,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7695
8125
|
limit: config.limit,
|
|
7696
8126
|
startAfter: config.startAfter,
|
|
7697
8127
|
databaseId: config.databaseId,
|
|
7698
|
-
searchString: config.searchString
|
|
8128
|
+
searchString: config.searchString,
|
|
8129
|
+
searchExplain: config.searchExplain
|
|
7699
8130
|
}
|
|
7700
8131
|
});
|
|
7701
8132
|
if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
|
|
@@ -7775,26 +8206,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7775
8206
|
await this.handleUnsubscribe(clientId, message.subscriptionId);
|
|
7776
8207
|
break;
|
|
7777
8208
|
case "join_channel":
|
|
7778
|
-
this.joinChannel(clientId, payload?.channel);
|
|
7779
|
-
break;
|
|
7780
8209
|
case "leave_channel":
|
|
7781
|
-
this.leaveChannel(clientId, payload?.channel);
|
|
7782
|
-
break;
|
|
7783
8210
|
case "broadcast":
|
|
7784
|
-
this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
|
|
7785
|
-
break;
|
|
7786
8211
|
case "channel_history":
|
|
7787
|
-
await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
|
|
7788
|
-
break;
|
|
7789
8212
|
case "presence_track":
|
|
7790
|
-
this.joinChannel(clientId, payload?.channel);
|
|
7791
|
-
this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
|
|
7792
|
-
break;
|
|
7793
8213
|
case "presence_untrack":
|
|
7794
|
-
this.removePresence(clientId, payload?.channel);
|
|
7795
|
-
break;
|
|
7796
8214
|
case "presence_state":
|
|
7797
|
-
this.
|
|
8215
|
+
await this.handleChannelMessage(clientId, message.type, payload, authContext);
|
|
7798
8216
|
break;
|
|
7799
8217
|
default: this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
|
|
7800
8218
|
}
|
|
@@ -7809,30 +8227,40 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7809
8227
|
this.sendError(clientId, msg, subscriptionId);
|
|
7810
8228
|
return;
|
|
7811
8229
|
}
|
|
7812
|
-
|
|
8230
|
+
if (request.vectorSearch) {
|
|
8231
|
+
const msg = "Realtime subscriptions do not support vector search: a subscription is re-run on every matching write, and nothing here computes distances. Use `.vectorSearch(...).find()` for the query, and subscribe without it if you need live updates.";
|
|
8232
|
+
logger.warn(`[RealtimeService] ${msg}`);
|
|
8233
|
+
this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
|
|
8234
|
+
return;
|
|
8235
|
+
}
|
|
8236
|
+
let boundedLimit;
|
|
8237
|
+
try {
|
|
8238
|
+
boundedLimit = resolveClientListLimit(request.limit);
|
|
8239
|
+
} catch (e) {
|
|
8240
|
+
if (!(e instanceof ListLimitError)) throw e;
|
|
8241
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
8242
|
+
this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
|
|
8243
|
+
return;
|
|
8244
|
+
}
|
|
7813
8245
|
this._subscriptions.set(subscriptionId, {
|
|
7814
8246
|
clientId,
|
|
7815
8247
|
type: "collection",
|
|
7816
8248
|
path: request.path,
|
|
7817
8249
|
collectionRequest: {
|
|
7818
8250
|
filter: request.filter,
|
|
8251
|
+
logical: request.logical,
|
|
7819
8252
|
orderBy: request.orderBy,
|
|
7820
8253
|
order: request.order,
|
|
7821
8254
|
limit: boundedLimit,
|
|
8255
|
+
offset: request.offset,
|
|
7822
8256
|
startAfter: request.startAfter,
|
|
7823
8257
|
databaseId: request.collection?.databaseId,
|
|
7824
|
-
searchString: request.searchString
|
|
8258
|
+
searchString: request.searchString,
|
|
8259
|
+
searchExplain: request.searchExplain
|
|
7825
8260
|
},
|
|
7826
8261
|
authContext
|
|
7827
8262
|
});
|
|
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);
|
|
8263
|
+
const rows = await this.fetchCollectionWithAuth(request.path, this._subscriptions.get(subscriptionId).collectionRequest, authContext);
|
|
7836
8264
|
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
7837
8265
|
} catch (error) {
|
|
7838
8266
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
@@ -7917,7 +8345,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7917
8345
|
this.debugLog("🔔 [RealtimeService] notifyUpdate completed for path:", path);
|
|
7918
8346
|
}
|
|
7919
8347
|
/**
|
|
7920
|
-
* Notify subscriptions for a specific path
|
|
8348
|
+
* Notify subscriptions for a specific path.
|
|
8349
|
+
*
|
|
8350
|
+
* **A subscriber only ever receives rows re-read under its own scope.**
|
|
8351
|
+
* `row` is used to decide *that* something changed, never to say *what* —
|
|
8352
|
+
* every delivery below goes through a refetch that binds the subscription's
|
|
8353
|
+
* own auth context.
|
|
8354
|
+
*
|
|
8355
|
+
* It used to be conditional. The CDC path already did the right thing: it
|
|
8356
|
+
* discards the captured tuple and emits `{_rebase_invalidated: true}`, and
|
|
8357
|
+
* that marker selected the refetch branch. But the marker is produced in
|
|
8358
|
+
* exactly two places, and the *other* side of each branch here shipped the
|
|
8359
|
+
* row it was handed straight to the socket. Two of the three entry paths
|
|
8360
|
+
* took that side — every API mutation (`PostgresBackendDriver.save` passes
|
|
8361
|
+
* the row it just wrote, read under the **writer's** scope) and the legacy
|
|
8362
|
+
* cross-instance LISTEN handler (which re-reads on the owner connection,
|
|
8363
|
+
* bypassing RLS altogether). Path matching was the only filter applied: the
|
|
8364
|
+
* subscription's own `filter`/`logical` was never evaluated, and any
|
|
8365
|
+
* `afterRead` redaction was the writer's rather than the reader's.
|
|
8366
|
+
*
|
|
8367
|
+
* A single-row subscription was the sharpest case. `subscribe_one` on a row
|
|
8368
|
+
* RLS denies is accepted and answered `null`; the next update then pushed
|
|
8369
|
+
* the full row with no later correction. The collection variant was merely
|
|
8370
|
+
* papered over ~300 ms later by the debounced refetch — after the bytes had
|
|
8371
|
+
* already reached the browser.
|
|
8372
|
+
*
|
|
8373
|
+
* The same defect was found and fixed on the Mongo driver in `065e2b615`
|
|
8374
|
+
* (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
|
|
8375
|
+
* the Postgres half, stated as one rule rather than three patched branches.
|
|
8376
|
+
*
|
|
8377
|
+
* The cost is the instant row-level patch that used to precede the refetch:
|
|
8378
|
+
* cross-tab feedback now waits for the debounce. That is the price of not
|
|
8379
|
+
* being able to know, without asking the database as this subscriber,
|
|
8380
|
+
* whether this subscriber may see the row at all.
|
|
7921
8381
|
*/
|
|
7922
8382
|
async notifyPathUpdate(notifyPath, originalPath, id, row, _databaseId) {
|
|
7923
8383
|
this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
|
|
@@ -7931,12 +8391,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7931
8391
|
const webSocketSubscriptions = allSubscriptions.filter(([, sub]) => sub.clientId !== "driver" && this.clients.has(sub.clientId));
|
|
7932
8392
|
const driverSubscriptions = allSubscriptions.filter(([subscriptionId, sub]) => sub.clientId === "driver" && this.subscriptionCallbacks.has(subscriptionId));
|
|
7933
8393
|
for (const [subscriptionId, subscription] of webSocketSubscriptions) try {
|
|
7934
|
-
if (subscription.type === "single" && notifyPath === originalPath)
|
|
7935
|
-
else
|
|
7936
|
-
else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
7937
|
-
if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
7938
|
-
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
7939
|
-
}
|
|
8394
|
+
if (subscription.type === "single" && notifyPath === originalPath) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
8395
|
+
else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
7940
8396
|
} catch (error) {
|
|
7941
8397
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
7942
8398
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -7944,8 +8400,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
7944
8400
|
for (const [subscriptionId, subscription] of driverSubscriptions) try {
|
|
7945
8401
|
const callback = this.subscriptionCallbacks.get(subscriptionId);
|
|
7946
8402
|
if (!callback) continue;
|
|
7947
|
-
if (subscription.type === "single" && notifyPath === originalPath)
|
|
7948
|
-
else callback(row);
|
|
8403
|
+
if (subscription.type === "single" && notifyPath === originalPath) this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
|
|
7949
8404
|
else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
|
|
7950
8405
|
} catch (error) {
|
|
7951
8406
|
logger.error(`❌ [RealtimeService] Error processing DataDriver subscription ${subscriptionId}`, { error });
|
|
@@ -8009,13 +8464,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8009
8464
|
let fetchedEntities;
|
|
8010
8465
|
if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8011
8466
|
filter: collectionRequest.filter,
|
|
8467
|
+
logical: collectionRequest.logical,
|
|
8012
8468
|
orderBy: collectionRequest.orderBy,
|
|
8013
8469
|
order: collectionRequest.order,
|
|
8014
8470
|
limit: collectionRequest.limit,
|
|
8015
|
-
databaseId: collectionRequest.databaseId
|
|
8471
|
+
databaseId: collectionRequest.databaseId,
|
|
8472
|
+
searchExplain: collectionRequest.searchExplain
|
|
8016
8473
|
});
|
|
8017
8474
|
else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
8018
8475
|
filter: collectionRequest.filter,
|
|
8476
|
+
logical: collectionRequest.logical,
|
|
8019
8477
|
orderBy: collectionRequest.orderBy,
|
|
8020
8478
|
order: collectionRequest.order,
|
|
8021
8479
|
limit: collectionRequest.limit,
|
|
@@ -8068,13 +8526,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8068
8526
|
}
|
|
8069
8527
|
if (collectionRequest.searchString) return await this.dataService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8070
8528
|
filter: collectionRequest.filter,
|
|
8529
|
+
logical: collectionRequest.logical,
|
|
8071
8530
|
orderBy: collectionRequest.orderBy,
|
|
8072
8531
|
order: collectionRequest.order,
|
|
8073
8532
|
limit: collectionRequest.limit,
|
|
8074
|
-
databaseId: collectionRequest.databaseId
|
|
8533
|
+
databaseId: collectionRequest.databaseId,
|
|
8534
|
+
searchExplain: collectionRequest.searchExplain
|
|
8075
8535
|
});
|
|
8076
8536
|
return await this.dataService.fetchCollection(notifyPath, {
|
|
8077
8537
|
filter: collectionRequest.filter,
|
|
8538
|
+
logical: collectionRequest.logical,
|
|
8078
8539
|
orderBy: collectionRequest.orderBy,
|
|
8079
8540
|
order: collectionRequest.order,
|
|
8080
8541
|
limit: collectionRequest.limit,
|
|
@@ -8204,16 +8665,6 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8204
8665
|
* columns and no address. The SDK holds no collection config to derive one
|
|
8205
8666
|
* from, so this is the only place the mapping can come from.
|
|
8206
8667
|
*/
|
|
8207
|
-
sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
|
|
8208
|
-
const message = {
|
|
8209
|
-
type: "collection_patch",
|
|
8210
|
-
subscriptionId,
|
|
8211
|
-
id,
|
|
8212
|
-
row,
|
|
8213
|
-
pks: this.primaryKeysForPath(notifyPath)
|
|
8214
|
-
};
|
|
8215
|
-
this.sendMessage(clientId, message);
|
|
8216
|
-
}
|
|
8217
8668
|
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
8218
8669
|
primaryKeysForPath(path) {
|
|
8219
8670
|
try {
|
|
@@ -8258,12 +8709,148 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8258
8709
|
}
|
|
8259
8710
|
return parentPaths;
|
|
8260
8711
|
}
|
|
8712
|
+
/**
|
|
8713
|
+
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
8714
|
+
*
|
|
8715
|
+
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
8716
|
+
* be built on, kept deliberately separate from the membership floor so the
|
|
8717
|
+
* floor holds whether or not anyone uses it.
|
|
8718
|
+
*/
|
|
8719
|
+
setChannelAuthorizer(authorizer) {
|
|
8720
|
+
this.channelAuthorizer = authorizer;
|
|
8721
|
+
}
|
|
8722
|
+
/** Which action each channel frame is asking to perform. */
|
|
8723
|
+
static CHANNEL_ACTIONS = {
|
|
8724
|
+
join_channel: "join",
|
|
8725
|
+
broadcast: "broadcast",
|
|
8726
|
+
channel_history: "history",
|
|
8727
|
+
presence_track: "join",
|
|
8728
|
+
presence_state: "presence"
|
|
8729
|
+
};
|
|
8730
|
+
/**
|
|
8731
|
+
* The one door every channel frame comes through.
|
|
8732
|
+
*
|
|
8733
|
+
* Returns synchronously — and so dispatches synchronously — unless an
|
|
8734
|
+
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
8735
|
+
* `presence_state` and `channel_history` back to back on connect, and the
|
|
8736
|
+
* socket's message handler processes each frame up to its first `await`,
|
|
8737
|
+
* so a gate that always yielded would let the reads overtake the join that
|
|
8738
|
+
* is about to authorize them.
|
|
8739
|
+
*/
|
|
8740
|
+
handleChannelMessage(clientId, type, payload, authContext) {
|
|
8741
|
+
const channel = payload?.channel;
|
|
8742
|
+
if (type === "leave_channel") {
|
|
8743
|
+
this.leaveChannel(clientId, channel);
|
|
8744
|
+
return;
|
|
8745
|
+
}
|
|
8746
|
+
if (type === "presence_untrack") {
|
|
8747
|
+
this.removePresence(clientId, channel);
|
|
8748
|
+
return;
|
|
8749
|
+
}
|
|
8750
|
+
const action = RealtimeService.CHANNEL_ACTIONS[type];
|
|
8751
|
+
const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
|
|
8752
|
+
if (allowed === false) return;
|
|
8753
|
+
if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
8754
|
+
return allowed.then((ok) => {
|
|
8755
|
+
if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
8756
|
+
});
|
|
8757
|
+
}
|
|
8758
|
+
/** Perform an already-authorized channel frame. */
|
|
8759
|
+
dispatchChannelMessage(clientId, type, channel, payload) {
|
|
8760
|
+
switch (type) {
|
|
8761
|
+
case "join_channel":
|
|
8762
|
+
this.joinChannel(clientId, channel);
|
|
8763
|
+
return;
|
|
8764
|
+
case "broadcast":
|
|
8765
|
+
this.broadcastToChannel(clientId, channel, payload?.event, payload?.payload);
|
|
8766
|
+
return;
|
|
8767
|
+
case "channel_history": return this.handleChannelHistoryRequest(clientId, channel, payload?.sinceSeq, payload?.limit);
|
|
8768
|
+
case "presence_track":
|
|
8769
|
+
this.joinChannel(clientId, channel);
|
|
8770
|
+
this.trackPresence(clientId, channel, payload?.state ?? {});
|
|
8771
|
+
return;
|
|
8772
|
+
case "presence_state":
|
|
8773
|
+
this.sendPresenceState(clientId, channel);
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
/**
|
|
8778
|
+
* Decide whether a client may perform an action on a channel.
|
|
8779
|
+
*
|
|
8780
|
+
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
8781
|
+
* its retained history and broadcasting into it all require that this client
|
|
8782
|
+
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
8783
|
+
* the channel — but it is not the bar that was there before, which was none
|
|
8784
|
+
* at all: `channel_history` and `presence_state` answered any socket about
|
|
8785
|
+
* any channel, and a broadcast fanned out to members the sender had never
|
|
8786
|
+
* joined. Two internal tables (`rebase.channel_presence`,
|
|
8787
|
+
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
8788
|
+
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
8789
|
+
*
|
|
8790
|
+
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
8791
|
+
* this method is where it is consulted, and the only place.
|
|
8792
|
+
*/
|
|
8793
|
+
authorizeChannelAction(clientId, channel, action, authContext) {
|
|
8794
|
+
if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
|
|
8795
|
+
this.denyChannelAction(clientId, channel, action, "not a member of the channel");
|
|
8796
|
+
return false;
|
|
8797
|
+
}
|
|
8798
|
+
const authorizer = this.channelAuthorizer;
|
|
8799
|
+
if (!authorizer) return true;
|
|
8800
|
+
let verdict;
|
|
8801
|
+
try {
|
|
8802
|
+
verdict = authorizer({
|
|
8803
|
+
channel,
|
|
8804
|
+
action,
|
|
8805
|
+
clientId,
|
|
8806
|
+
user: authContext
|
|
8807
|
+
});
|
|
8808
|
+
} catch (error) {
|
|
8809
|
+
logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
|
|
8810
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
8811
|
+
return false;
|
|
8812
|
+
}
|
|
8813
|
+
if (typeof verdict === "boolean") {
|
|
8814
|
+
if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
8815
|
+
return verdict;
|
|
8816
|
+
}
|
|
8817
|
+
return verdict.then((ok) => {
|
|
8818
|
+
if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
8819
|
+
return ok;
|
|
8820
|
+
}, (error) => {
|
|
8821
|
+
logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
|
|
8822
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
8823
|
+
return false;
|
|
8824
|
+
});
|
|
8825
|
+
}
|
|
8826
|
+
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
8827
|
+
denyChannelAction(clientId, channel, action, reason) {
|
|
8828
|
+
this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
|
|
8829
|
+
this.sendError(clientId, `Refused ${action} on channel "${channel}": ${reason}`, void 0, "CHANNEL_FORBIDDEN");
|
|
8830
|
+
}
|
|
8261
8831
|
/** Join a broadcast channel */
|
|
8262
8832
|
joinChannel(clientId, channel) {
|
|
8263
8833
|
if (!this.channels.has(channel)) this.channels.set(channel, /* @__PURE__ */ new Set());
|
|
8264
8834
|
this.channels.get(channel).add(clientId);
|
|
8835
|
+
this.warnIfMemoryBusOnMultiplePods();
|
|
8265
8836
|
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
8266
8837
|
}
|
|
8838
|
+
/**
|
|
8839
|
+
* Say something the first time channels are used on a deployment that is
|
|
8840
|
+
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
8841
|
+
*
|
|
8842
|
+
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
8843
|
+
* the case where the operator already knew a bus mattered. The common
|
|
8844
|
+
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
8845
|
+
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
8846
|
+
* fraction of the room. The evidence is already in the process, so use it.
|
|
8847
|
+
*/
|
|
8848
|
+
warnIfMemoryBusOnMultiplePods() {
|
|
8849
|
+
if (this.memoryBusWarned) return;
|
|
8850
|
+
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
8851
|
+
this.memoryBusWarned = true;
|
|
8852
|
+
logger.warn("⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another instance have been seen — this deployment runs more than one process. Broadcast and presence reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) to make channels cross-instance.");
|
|
8853
|
+
}
|
|
8267
8854
|
/** Leave a broadcast channel */
|
|
8268
8855
|
leaveChannel(clientId, channel) {
|
|
8269
8856
|
const members = this.channels.get(channel);
|
|
@@ -8758,7 +9345,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8758
9345
|
throw err;
|
|
8759
9346
|
}
|
|
8760
9347
|
this.cdcActive = true;
|
|
8761
|
-
logger.
|
|
9348
|
+
logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
|
|
8762
9349
|
}
|
|
8763
9350
|
/** Stop the CDC listener and clear its state. */
|
|
8764
9351
|
async stopCdc() {
|
|
@@ -8941,6 +9528,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8941
9528
|
try {
|
|
8942
9529
|
const { sid, p, eid, db } = JSON.parse(msg.payload);
|
|
8943
9530
|
if (sid === this.instanceId) return;
|
|
9531
|
+
this.foreignInstanceSeen = true;
|
|
8944
9532
|
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
|
|
8945
9533
|
let refetchedRow = null;
|
|
8946
9534
|
try {
|
|
@@ -9131,7 +9719,7 @@ function createBackupCron(config) {
|
|
|
9131
9719
|
enabled: config.enabled ?? true,
|
|
9132
9720
|
timeoutSeconds: 3600,
|
|
9133
9721
|
async handler({ log }) {
|
|
9134
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
9722
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BH0Dzo_h.js").then((n) => n.r);
|
|
9135
9723
|
const { destination } = config;
|
|
9136
9724
|
if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
|
|
9137
9725
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -9203,6 +9791,54 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
|
|
|
9203
9791
|
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
9204
9792
|
var asColumns = (value) => Array.isArray(value) ? value : [value];
|
|
9205
9793
|
/**
|
|
9794
|
+
* Distinguish "this column name is wrong" from "the generated schema is old".
|
|
9795
|
+
*
|
|
9796
|
+
* They present identically here — a relation asks for a column the registered
|
|
9797
|
+
* table does not have — but they are opposite problems with opposite fixes, and
|
|
9798
|
+
* getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
|
|
9799
|
+
* projects.
|
|
9800
|
+
*
|
|
9801
|
+
* The registered table is not the database. It comes from the project's
|
|
9802
|
+
* checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
|
|
9803
|
+
* derives foreign-key names: `categories` yields `category_id` where it used to
|
|
9804
|
+
* yield `categorie_id`. Boot-ensure renames the database column to match, so by
|
|
9805
|
+
* the time this runs the *database* is correct and the *generated module* is the
|
|
9806
|
+
* stale one. Reporting "not a column" then points at the wrong artifact, and the
|
|
9807
|
+
* generic fix — "set `through.targetColumn` to one of: …", listing the legacy
|
|
9808
|
+
* name because that is what the stale module still has — talks the reader into
|
|
9809
|
+
* pinning a column that no longer exists.
|
|
9810
|
+
*
|
|
9811
|
+
* So when the wanted name is what the current rule derives, and the table
|
|
9812
|
+
* carries what the *previous* rule would have derived from the same source, say
|
|
9813
|
+
* that instead.
|
|
9814
|
+
*
|
|
9815
|
+
* @param wanted the column the relation asks for
|
|
9816
|
+
* @param available every column the registered table has
|
|
9817
|
+
* @param sources names the default could have been derived from (a slug, a
|
|
9818
|
+
* relation name) — checking against these rather than guessing
|
|
9819
|
+
* backwards from `wanted` keeps the match exact
|
|
9820
|
+
*/
|
|
9821
|
+
function staleCodegenRename(wanted, available, sources) {
|
|
9822
|
+
for (const source of sources) {
|
|
9823
|
+
if (!source) continue;
|
|
9824
|
+
const current = generateForeignKeyName(source);
|
|
9825
|
+
const legacy = legacyForeignKeyName(source);
|
|
9826
|
+
if (current !== wanted || legacy === current) continue;
|
|
9827
|
+
if (available.has(legacy) && !available.has(current)) return {
|
|
9828
|
+
legacy,
|
|
9829
|
+
current
|
|
9830
|
+
};
|
|
9831
|
+
}
|
|
9832
|
+
return null;
|
|
9833
|
+
}
|
|
9834
|
+
/** The shared explanation, so every relation kind reports it identically. */
|
|
9835
|
+
function staleCodegenDefect(table, { legacy, current }) {
|
|
9836
|
+
return {
|
|
9837
|
+
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`,
|
|
9838
|
+
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.`
|
|
9839
|
+
};
|
|
9840
|
+
}
|
|
9841
|
+
/**
|
|
9206
9842
|
* Relations whose names do not resolve against the registered schema.
|
|
9207
9843
|
*
|
|
9208
9844
|
* Fails open wherever it cannot see enough to be sure — an unregistered source
|
|
@@ -9249,19 +9885,31 @@ function findRelationDefects(collections, registry) {
|
|
|
9249
9885
|
const targetColumns = columnNames(targetTable);
|
|
9250
9886
|
switch (relation.kind) {
|
|
9251
9887
|
case "belongsTo":
|
|
9252
|
-
if (!sourceColumns.has(relation.localKey))
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
9888
|
+
if (!sourceColumns.has(relation.localKey)) {
|
|
9889
|
+
const stale = staleCodegenRename(relation.localKey, sourceColumns, [relation.relationName, targetCollection.slug]);
|
|
9890
|
+
defects.push(stale ? {
|
|
9891
|
+
...at,
|
|
9892
|
+
...staleCodegenDefect(sourceTableName, stale)
|
|
9893
|
+
} : {
|
|
9894
|
+
...at,
|
|
9895
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
9896
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
9897
|
+
});
|
|
9898
|
+
}
|
|
9257
9899
|
break;
|
|
9258
9900
|
case "hasOne":
|
|
9259
9901
|
case "hasMany":
|
|
9260
|
-
if (!targetColumns.has(relation.foreignKeyOnTarget))
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9902
|
+
if (!targetColumns.has(relation.foreignKeyOnTarget)) {
|
|
9903
|
+
const stale = staleCodegenRename(relation.foreignKeyOnTarget, targetColumns, [collection.slug]);
|
|
9904
|
+
defects.push(stale ? {
|
|
9905
|
+
...at,
|
|
9906
|
+
...staleCodegenDefect(targetTableName, stale)
|
|
9907
|
+
} : {
|
|
9908
|
+
...at,
|
|
9909
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
9910
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
9911
|
+
});
|
|
9912
|
+
}
|
|
9265
9913
|
if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
|
|
9266
9914
|
...at,
|
|
9267
9915
|
problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
|
|
@@ -9280,11 +9928,21 @@ function findRelationDefects(collections, registry) {
|
|
|
9280
9928
|
break;
|
|
9281
9929
|
}
|
|
9282
9930
|
const junctionColumns = columnNames(junction);
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9931
|
+
const derivedFrom = {
|
|
9932
|
+
sourceColumn: [collection.slug],
|
|
9933
|
+
targetColumn: [targetCollection.slug]
|
|
9934
|
+
};
|
|
9935
|
+
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) {
|
|
9936
|
+
const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
|
|
9937
|
+
defects.push(stale ? {
|
|
9938
|
+
...at,
|
|
9939
|
+
...staleCodegenDefect(table, stale)
|
|
9940
|
+
} : {
|
|
9941
|
+
...at,
|
|
9942
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
9943
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
9944
|
+
});
|
|
9945
|
+
}
|
|
9288
9946
|
break;
|
|
9289
9947
|
}
|
|
9290
9948
|
case "via": {
|
|
@@ -9356,7 +10014,16 @@ function assertRelationsResolve(collections, registry) {
|
|
|
9356
10014
|
const defects = findRelationDefects(collections, registry);
|
|
9357
10015
|
if (defects.length === 0) return;
|
|
9358
10016
|
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
|
|
10017
|
+
throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against \`backend/src/schema.generated.ts\`.
|
|
10018
|
+
|
|
10019
|
+
Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
|
|
10020
|
+
|
|
10021
|
+
If the database was migrated recently — an upgrade, a \`db push\`, a restore — this file is
|
|
10022
|
+
probably older than the schema it describes. Regenerate it before changing anything else:
|
|
10023
|
+
|
|
10024
|
+
rebase schema generate
|
|
10025
|
+
|
|
10026
|
+
If it is already current, then the collection is what disagrees with it:
|
|
9360
10027
|
|
|
9361
10028
|
` + lines.join("\n\n") + "\n");
|
|
9362
10029
|
}
|
|
@@ -9377,7 +10044,7 @@ function buildCollectionRegistry(schema) {
|
|
|
9377
10044
|
const registry = new PostgresCollectionRegistry();
|
|
9378
10045
|
if (schema.collections) {
|
|
9379
10046
|
registry.registerMultiple(schema.collections);
|
|
9380
|
-
logger.
|
|
10047
|
+
logger.debug(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
|
|
9381
10048
|
}
|
|
9382
10049
|
if (schema.tables) Object.values(schema.tables).forEach((table) => {
|
|
9383
10050
|
if (isTable(table)) registry.registerTable(table, getTableName(table));
|
|
@@ -9552,7 +10219,7 @@ async function probeAuthSchema(db, authSchema) {
|
|
|
9552
10219
|
* When omitted, a default `rebase.users` table is created.
|
|
9553
10220
|
*/
|
|
9554
10221
|
async function ensureAuthTablesExist(db, collection) {
|
|
9555
|
-
logger.
|
|
10222
|
+
logger.debug("🔍 Checking auth tables...");
|
|
9556
10223
|
await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
|
|
9557
10224
|
try {
|
|
9558
10225
|
let usersTableName = "\"rebase\".\"users\"";
|
|
@@ -9567,7 +10234,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9567
10234
|
if (idProp) {
|
|
9568
10235
|
const isId = "isId" in idProp ? idProp.isId : void 0;
|
|
9569
10236
|
if (isId === "uuid") userIdType = "UUID";
|
|
9570
|
-
else if (isId === "
|
|
10237
|
+
else if (isId === "increment") userIdType = "INTEGER";
|
|
9571
10238
|
}
|
|
9572
10239
|
}
|
|
9573
10240
|
try {
|
|
@@ -9583,7 +10250,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9583
10250
|
if (dbType === "UUID") userIdType = "UUID";
|
|
9584
10251
|
else if (dbType === "INTEGER" || dbType === "SMALLINT" || dbType === "BIGINT") userIdType = "INTEGER";
|
|
9585
10252
|
else userIdType = "TEXT";
|
|
9586
|
-
logger.
|
|
10253
|
+
logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
|
|
9587
10254
|
}
|
|
9588
10255
|
} catch (err) {
|
|
9589
10256
|
logger.warn(`⚠️ Failed to introspect ${usersTableName}.id type from database, falling back to config type: ${userIdType}`, { error: err });
|
|
@@ -9600,43 +10267,42 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9600
10267
|
const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
|
|
9601
10268
|
const emailLowerUniqueIndex = authIdentifier("email_lower_key");
|
|
9602
10269
|
const verificationTokenIndex = authIdentifier("email_verification_token_idx");
|
|
10270
|
+
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
10271
|
await db.execute(sql`
|
|
9604
10272
|
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
9605
10273
|
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
|
|
10274
|
+
${sql.raw(usersColumnDdl)}
|
|
9619
10275
|
)
|
|
9620
10276
|
`);
|
|
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 [
|
|
10277
|
+
const legacyFkTables = [
|
|
9633
10278
|
"user_identities",
|
|
9634
10279
|
"refresh_tokens",
|
|
9635
10280
|
"password_reset_tokens",
|
|
9636
10281
|
"magic_link_tokens",
|
|
9637
10282
|
"mfa_factors",
|
|
9638
10283
|
"recovery_codes"
|
|
9639
|
-
]
|
|
10284
|
+
];
|
|
10285
|
+
const legacyFkTableList = legacyFkTables.map((t) => `'${t}'`).join(", ");
|
|
10286
|
+
const legacyUserIdPresent = await db.execute(sql`
|
|
10287
|
+
SELECT 1
|
|
10288
|
+
FROM information_schema.columns
|
|
10289
|
+
WHERE table_schema = ${authSchema}
|
|
10290
|
+
AND table_name IN (${sql.raw(legacyFkTableList)})
|
|
10291
|
+
AND column_name = 'user_id'
|
|
10292
|
+
LIMIT 1
|
|
10293
|
+
`);
|
|
10294
|
+
if (legacyUserIdPresent.rows.length > 0) await db.execute(sql`
|
|
10295
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
10296
|
+
BEGIN
|
|
10297
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
10298
|
+
NEW.uid := NEW.user_id;
|
|
10299
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
10300
|
+
NEW.user_id := NEW.uid;
|
|
10301
|
+
END IF;
|
|
10302
|
+
RETURN NEW;
|
|
10303
|
+
END $$ LANGUAGE plpgsql
|
|
10304
|
+
`);
|
|
10305
|
+
for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
|
|
9640
10306
|
const qualified = `"${authSchema}"."${authTable}"`;
|
|
9641
10307
|
await db.execute(sql`
|
|
9642
10308
|
DO $$
|
|
@@ -9757,54 +10423,50 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9757
10423
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
9758
10424
|
)
|
|
9759
10425
|
`);
|
|
9760
|
-
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
|
|
9761
10426
|
await db.transaction(async (tx) => {
|
|
9762
10427
|
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
|
-
`);
|
|
10428
|
+
for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
|
|
9784
10429
|
});
|
|
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`
|
|
10430
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
10431
|
+
if (spec.column === "email") continue;
|
|
10432
|
+
await db.execute(sql`
|
|
9799
10433
|
ALTER TABLE ${sql.raw(usersTableName)}
|
|
9800
|
-
ADD COLUMN IF NOT EXISTS ${sql.raw(
|
|
10434
|
+
ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
|
|
9801
10435
|
`);
|
|
9802
|
-
|
|
9803
|
-
|
|
10436
|
+
}
|
|
10437
|
+
const usersColumnRows = (await db.execute(sql`
|
|
10438
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
9804
10439
|
FROM information_schema.columns
|
|
9805
10440
|
WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
|
|
9806
|
-
`);
|
|
9807
|
-
const usersColumnTypes = new Map(
|
|
10441
|
+
`)).rows;
|
|
10442
|
+
const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
|
|
10443
|
+
const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
|
|
10444
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
10445
|
+
const state = usersColumnState.get(spec.column);
|
|
10446
|
+
if (!state) continue;
|
|
10447
|
+
if (spec.default !== void 0 && state.column_default === null) {
|
|
10448
|
+
await db.execute(sql`
|
|
10449
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
10450
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
|
|
10451
|
+
`);
|
|
10452
|
+
logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
|
|
10453
|
+
}
|
|
10454
|
+
if (!spec.notNull || state.is_nullable !== "YES") continue;
|
|
10455
|
+
if (spec.default !== void 0) await db.execute(sql`
|
|
10456
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
10457
|
+
SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
|
|
10458
|
+
WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
|
|
10459
|
+
`);
|
|
10460
|
+
try {
|
|
10461
|
+
await db.execute(sql`
|
|
10462
|
+
ALTER TABLE ${sql.raw(usersTableName)}
|
|
10463
|
+
ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
|
|
10464
|
+
`);
|
|
10465
|
+
logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
|
|
10466
|
+
} catch (err) {
|
|
10467
|
+
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)));
|
|
10468
|
+
}
|
|
10469
|
+
}
|
|
9808
10470
|
for (const column of [
|
|
9809
10471
|
"email",
|
|
9810
10472
|
"display_name",
|
|
@@ -9875,7 +10537,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9875
10537
|
FROM information_schema.tables
|
|
9876
10538
|
WHERE table_name = 'refresh_tokens'
|
|
9877
10539
|
`)).rows;
|
|
9878
|
-
logger.
|
|
10540
|
+
logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
|
|
9879
10541
|
for (const { table_schema } of found) {
|
|
9880
10542
|
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
9881
10543
|
try {
|
|
@@ -9883,6 +10545,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9883
10545
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
9884
10546
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
9885
10547
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
10548
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
|
|
9886
10549
|
await db.execute(sql`
|
|
9887
10550
|
UPDATE ${sql.raw(qualified)}
|
|
9888
10551
|
SET session_id = gen_random_uuid()::text
|
|
@@ -9902,7 +10565,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9902
10565
|
ON ${sql.raw(qualified)}(session_id)
|
|
9903
10566
|
`);
|
|
9904
10567
|
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
9905
|
-
logger.
|
|
10568
|
+
logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
|
|
9906
10569
|
} catch (perTableError) {
|
|
9907
10570
|
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
9908
10571
|
}
|
|
@@ -9945,6 +10608,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9945
10608
|
secret_encrypted TEXT NOT NULL,
|
|
9946
10609
|
friendly_name TEXT,
|
|
9947
10610
|
verified BOOLEAN DEFAULT FALSE,
|
|
10611
|
+
last_used_counter BIGINT,
|
|
9948
10612
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
9949
10613
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
9950
10614
|
)
|
|
@@ -9960,6 +10624,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9960
10624
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
9961
10625
|
verified_at TIMESTAMP WITH TIME ZONE,
|
|
9962
10626
|
ip_address TEXT,
|
|
10627
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
9963
10628
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
|
9964
10629
|
)
|
|
9965
10630
|
`);
|
|
@@ -9967,6 +10632,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9967
10632
|
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
|
|
9968
10633
|
ON ${sql.raw(mfaChallengesTableName)}(factor_id)
|
|
9969
10634
|
`);
|
|
10635
|
+
try {
|
|
10636
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
|
|
10637
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
|
|
10638
|
+
} catch (mfaMigrationError) {
|
|
10639
|
+
logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
|
|
10640
|
+
}
|
|
9970
10641
|
await db.execute(sql`
|
|
9971
10642
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
9972
10643
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
@@ -9986,10 +10657,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
9986
10657
|
[authSchema, "user_identities"],
|
|
9987
10658
|
[authSchema, "refresh_tokens"],
|
|
9988
10659
|
[authSchema, "password_reset_tokens"],
|
|
10660
|
+
[authSchema, "magic_link_tokens"],
|
|
9989
10661
|
[authSchema, "app_config"],
|
|
9990
10662
|
[authSchema, "mfa_factors"],
|
|
9991
10663
|
[authSchema, "mfa_challenges"],
|
|
9992
|
-
[authSchema, "recovery_codes"]
|
|
10664
|
+
[authSchema, "recovery_codes"],
|
|
10665
|
+
[authSchema, "schema_meta"]
|
|
9993
10666
|
];
|
|
9994
10667
|
for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
|
|
9995
10668
|
SELECT 1
|
|
@@ -10009,7 +10682,10 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
10009
10682
|
logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
|
|
10010
10683
|
}
|
|
10011
10684
|
await stampAuthSchemaVersion(db, authSchema);
|
|
10012
|
-
|
|
10685
|
+
await revokeInternalTableAccess(async (text) => {
|
|
10686
|
+
await db.execute(sql.raw(text));
|
|
10687
|
+
}, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
|
|
10688
|
+
logger.debug("✅ Auth tables ready");
|
|
10013
10689
|
} catch (error) {
|
|
10014
10690
|
if (error instanceof AuthSchemaVersionError) throw error;
|
|
10015
10691
|
logger.error("❌ Failed to create auth tables", { error });
|
|
@@ -10282,7 +10958,7 @@ var UserService = class {
|
|
|
10282
10958
|
const conditions = [];
|
|
10283
10959
|
if (roleId) conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
|
|
10284
10960
|
if (search) {
|
|
10285
|
-
const pattern = `%${search}%`;
|
|
10961
|
+
const pattern = `%${escapeLikePattern(search)}%`;
|
|
10286
10962
|
conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
|
|
10287
10963
|
}
|
|
10288
10964
|
const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
|
|
@@ -10458,7 +11134,8 @@ var RefreshTokenService = class {
|
|
|
10458
11134
|
"sessionId",
|
|
10459
11135
|
"rotatedAt",
|
|
10460
11136
|
"revoked",
|
|
10461
|
-
"sessionStartedAt"
|
|
11137
|
+
"sessionStartedAt",
|
|
11138
|
+
"aal"
|
|
10462
11139
|
]) if (this.has(optional)) selection[optional] = this.col(optional);
|
|
10463
11140
|
return selection;
|
|
10464
11141
|
}
|
|
@@ -10472,6 +11149,7 @@ var RefreshTokenService = class {
|
|
|
10472
11149
|
};
|
|
10473
11150
|
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
10474
11151
|
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
11152
|
+
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
10475
11153
|
await this.db.insert(this.refreshTokensTable).values(values);
|
|
10476
11154
|
}
|
|
10477
11155
|
async findByHash(tokenHash) {
|
|
@@ -10949,6 +11627,9 @@ var PostgresAuthRepository = class {
|
|
|
10949
11627
|
async verifyMfaFactor(factorId) {
|
|
10950
11628
|
return this.getMfaService().verifyMfaFactor(factorId);
|
|
10951
11629
|
}
|
|
11630
|
+
async updateMfaFactorSecret(factorId, secretEncrypted) {
|
|
11631
|
+
return this.getMfaService().updateMfaFactorSecret(factorId, secretEncrypted);
|
|
11632
|
+
}
|
|
10952
11633
|
async deleteMfaFactor(factorId, uid) {
|
|
10953
11634
|
return this.getMfaService().deleteMfaFactor(factorId, uid);
|
|
10954
11635
|
}
|
|
@@ -10976,6 +11657,12 @@ var PostgresAuthRepository = class {
|
|
|
10976
11657
|
async hasVerifiedMfaFactors(uid) {
|
|
10977
11658
|
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
10978
11659
|
}
|
|
11660
|
+
async claimMfaFactorCounter(factorId, counter) {
|
|
11661
|
+
return this.getMfaService().claimMfaFactorCounter(factorId, counter);
|
|
11662
|
+
}
|
|
11663
|
+
async recordMfaChallengeAttempt(challengeId) {
|
|
11664
|
+
return this.getMfaService().recordMfaChallengeAttempt(challengeId);
|
|
11665
|
+
}
|
|
10979
11666
|
};
|
|
10980
11667
|
/**
|
|
10981
11668
|
* PostgreSQL implementation of MfaRepository.
|
|
@@ -11028,7 +11715,7 @@ var MfaService = class {
|
|
|
11028
11715
|
async getMfaFactorById(factorId) {
|
|
11029
11716
|
const tableName = this.qualify("mfa_factors");
|
|
11030
11717
|
const result = await this.db.execute(sql`
|
|
11031
|
-
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
11718
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
|
|
11032
11719
|
FROM ${sql.raw(tableName)}
|
|
11033
11720
|
WHERE id = ${factorId}
|
|
11034
11721
|
`);
|
|
@@ -11041,10 +11728,29 @@ var MfaService = class {
|
|
|
11041
11728
|
secretEncrypted: row.secret_encrypted,
|
|
11042
11729
|
friendlyName: row.friendly_name ?? void 0,
|
|
11043
11730
|
verified: row.verified,
|
|
11731
|
+
lastUsedCounter: row.last_used_counter === null || row.last_used_counter === void 0 ? null : Number(row.last_used_counter),
|
|
11044
11732
|
createdAt: new Date(row.created_at),
|
|
11045
11733
|
updatedAt: new Date(row.updated_at)
|
|
11046
11734
|
};
|
|
11047
11735
|
}
|
|
11736
|
+
/**
|
|
11737
|
+
* Spend a TOTP time step, once and only once.
|
|
11738
|
+
*
|
|
11739
|
+
* One statement: the `WHERE` is the check, the `UPDATE` is the act, and
|
|
11740
|
+
* `RETURNING` reports which of two concurrent requests carrying the same
|
|
11741
|
+
* six digits won. Reading the counter and then writing it would let both
|
|
11742
|
+
* pass — the exact replay this closes.
|
|
11743
|
+
*/
|
|
11744
|
+
async claimMfaFactorCounter(factorId, counter) {
|
|
11745
|
+
const tableName = this.qualify("mfa_factors");
|
|
11746
|
+
return (await this.db.execute(sql`
|
|
11747
|
+
UPDATE ${sql.raw(tableName)}
|
|
11748
|
+
SET last_used_counter = ${counter}, updated_at = NOW()
|
|
11749
|
+
WHERE id = ${factorId}
|
|
11750
|
+
AND (last_used_counter IS NULL OR last_used_counter < ${counter})
|
|
11751
|
+
RETURNING id
|
|
11752
|
+
`)).rows.length > 0;
|
|
11753
|
+
}
|
|
11048
11754
|
async verifyMfaFactor(factorId) {
|
|
11049
11755
|
const tableName = this.qualify("mfa_factors");
|
|
11050
11756
|
await this.db.execute(sql`
|
|
@@ -11053,6 +11759,14 @@ var MfaService = class {
|
|
|
11053
11759
|
WHERE id = ${factorId}
|
|
11054
11760
|
`);
|
|
11055
11761
|
}
|
|
11762
|
+
async updateMfaFactorSecret(factorId, secretEncrypted) {
|
|
11763
|
+
const tableName = this.qualify("mfa_factors");
|
|
11764
|
+
await this.db.execute(sql`
|
|
11765
|
+
UPDATE ${sql.raw(tableName)}
|
|
11766
|
+
SET secret_encrypted = ${secretEncrypted}, updated_at = NOW()
|
|
11767
|
+
WHERE id = ${factorId}
|
|
11768
|
+
`);
|
|
11769
|
+
}
|
|
11056
11770
|
async deleteMfaFactor(factorId, uid) {
|
|
11057
11771
|
const tableName = this.qualify("mfa_factors");
|
|
11058
11772
|
await this.db.execute(sql`
|
|
@@ -11079,7 +11793,7 @@ var MfaService = class {
|
|
|
11079
11793
|
async getMfaChallengeById(challengeId) {
|
|
11080
11794
|
const tableName = this.qualify("mfa_challenges");
|
|
11081
11795
|
const result = await this.db.execute(sql`
|
|
11082
|
-
SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
|
|
11796
|
+
SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
|
|
11083
11797
|
FROM ${sql.raw(tableName)}
|
|
11084
11798
|
WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
|
|
11085
11799
|
`);
|
|
@@ -11090,9 +11804,28 @@ var MfaService = class {
|
|
|
11090
11804
|
factorId: row.factor_id,
|
|
11091
11805
|
createdAt: new Date(row.created_at),
|
|
11092
11806
|
verifiedAt: row.verified_at ? new Date(row.verified_at) : void 0,
|
|
11093
|
-
ipAddress: row.ip_address ?? void 0
|
|
11807
|
+
ipAddress: row.ip_address ?? void 0,
|
|
11808
|
+
attempts: Number(row.attempts ?? 0)
|
|
11094
11809
|
};
|
|
11095
11810
|
}
|
|
11811
|
+
/**
|
|
11812
|
+
* Count one failed guess against a challenge and report the new total.
|
|
11813
|
+
*
|
|
11814
|
+
* Incremented in the database rather than in the route so that guesses
|
|
11815
|
+
* arriving in parallel — the shape any real brute-force takes — cannot
|
|
11816
|
+
* share a single increment.
|
|
11817
|
+
*/
|
|
11818
|
+
async recordMfaChallengeAttempt(challengeId) {
|
|
11819
|
+
const tableName = this.qualify("mfa_challenges");
|
|
11820
|
+
const result = await this.db.execute(sql`
|
|
11821
|
+
UPDATE ${sql.raw(tableName)}
|
|
11822
|
+
SET attempts = attempts + 1
|
|
11823
|
+
WHERE id = ${challengeId}
|
|
11824
|
+
RETURNING attempts
|
|
11825
|
+
`);
|
|
11826
|
+
if (result.rows.length === 0) return 0;
|
|
11827
|
+
return Number(result.rows[0].attempts);
|
|
11828
|
+
}
|
|
11096
11829
|
async verifyMfaChallenge(challengeId) {
|
|
11097
11830
|
const tableName = this.qualify("mfa_challenges");
|
|
11098
11831
|
await this.db.execute(sql`
|
|
@@ -11313,7 +12046,7 @@ function findChangedFields(oldValues, newValues) {
|
|
|
11313
12046
|
* pattern as `ensureAuthTablesExist`.
|
|
11314
12047
|
*/
|
|
11315
12048
|
async function ensureHistoryTableExists(db) {
|
|
11316
|
-
logger.
|
|
12049
|
+
logger.debug("🔍 Checking row history table...");
|
|
11317
12050
|
try {
|
|
11318
12051
|
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
11319
12052
|
await db.execute(sql`
|
|
@@ -11337,7 +12070,8 @@ async function ensureHistoryTableExists(db) {
|
|
|
11337
12070
|
CREATE INDEX IF NOT EXISTS idx_history_time
|
|
11338
12071
|
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
11339
12072
|
`);
|
|
11340
|
-
|
|
12073
|
+
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
12074
|
+
logger.debug("✅ Entity history table ready");
|
|
11341
12075
|
} catch (error) {
|
|
11342
12076
|
logger.error("❌ Failed to create row history table", { error });
|
|
11343
12077
|
logger.warn("⚠️ Continuing without creating history table.");
|
|
@@ -11651,6 +12385,7 @@ function idKindFor(col, propType) {
|
|
|
11651
12385
|
}
|
|
11652
12386
|
function buildProperties(meta, enumMap) {
|
|
11653
12387
|
const properties = {};
|
|
12388
|
+
const takenKeys = /* @__PURE__ */ new Set();
|
|
11654
12389
|
for (const col of meta.columns) {
|
|
11655
12390
|
const isPk = meta.pks.includes(col.column_name);
|
|
11656
12391
|
if (meta.fks.some((fk) => fk.column_name === col.column_name) && !isPk) continue;
|
|
@@ -11663,7 +12398,8 @@ function buildProperties(meta, enumMap) {
|
|
|
11663
12398
|
columnName: col.column_name,
|
|
11664
12399
|
type: propType
|
|
11665
12400
|
};
|
|
11666
|
-
const key = col.column_name;
|
|
12401
|
+
const key = firstFreeKey([toWireKey(col.column_name), col.column_name], takenKeys);
|
|
12402
|
+
takenKeys.add(key);
|
|
11667
12403
|
if (isPk) property.isId = idKindFor(col, propType);
|
|
11668
12404
|
else if (col.is_nullable === "NO" && col.column_default === null) property.validation = { required: true };
|
|
11669
12405
|
if (isEnum && enumValues) property.enum = enumValues.map((value) => ({
|
|
@@ -11700,7 +12436,7 @@ function buildRelations(meta, slugByTable, collectionBySlug) {
|
|
|
11700
12436
|
for (const fk of meta.fks) {
|
|
11701
12437
|
const targetSlug = slugByTable.get(fk.foreign_table_name);
|
|
11702
12438
|
if (!targetSlug) continue;
|
|
11703
|
-
let key = fk.column_name.replace(/_id$/, "");
|
|
12439
|
+
let key = toWireKey(fk.column_name.replace(/_id$/, ""));
|
|
11704
12440
|
if (meta.pks.includes(fk.column_name) && key === fk.column_name) key = fk.foreign_table_name;
|
|
11705
12441
|
relations[key] = {
|
|
11706
12442
|
name: humanize(key),
|
|
@@ -11957,6 +12693,43 @@ function resolveDriftCheckName(col, registeredTableNames) {
|
|
|
11957
12693
|
return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
|
|
11958
12694
|
}
|
|
11959
12695
|
/**
|
|
12696
|
+
* Is this the local database `rebase init` scaffolds — i.e. the one case where
|
|
12697
|
+
* "you are connected as a superuser" is not news?
|
|
12698
|
+
*
|
|
12699
|
+
* The scaffold's own `docker-compose.yml` sets `POSTGRES_USER: rebase_app`,
|
|
12700
|
+
* which makes that role the cluster superuser, so the superuser advisory below
|
|
12701
|
+
* was the only WARN a brand-new project ever saw and it was about a decision
|
|
12702
|
+
* the tool had made for the developer.
|
|
12703
|
+
*
|
|
12704
|
+
* Of the two available fixes — provision a non-superuser table-owner role in
|
|
12705
|
+
* the scaffold, or recognise the local shape and stay quiet — this is the
|
|
12706
|
+
* second, because the first breaks the scaffold it is meant to improve: a
|
|
12707
|
+
* non-superuser owner cannot `CREATE EXTENSION` (search collections need
|
|
12708
|
+
* `pg_trgm`/`unaccent`, applied by `rebase db push` and again by the boot
|
|
12709
|
+
* schema-ensure), so the very first `pnpm run db:push` on a scaffolded project
|
|
12710
|
+
* with a search block would fail. Trading a working first run for a quieter log
|
|
12711
|
+
* line is the wrong trade.
|
|
12712
|
+
*
|
|
12713
|
+
* The condition is deliberately narrow — a *non-production* process talking to
|
|
12714
|
+
* a database on the loopback interface. A genuine production superuser
|
|
12715
|
+
* connection still warns, and so does a non-production process pointed at a
|
|
12716
|
+
* remote database (the usual "my dev machine writes to staging" mistake, where
|
|
12717
|
+
* the advisory is exactly right). NODE_ENV alone would not do: the scaffold
|
|
12718
|
+
* ships `NODE_ENV=development` and some deployments inherit it.
|
|
12719
|
+
*/
|
|
12720
|
+
function isScaffoldedLocalDatabase(connectionString) {
|
|
12721
|
+
if (process.env.NODE_ENV === "production") return false;
|
|
12722
|
+
if (!connectionString) return false;
|
|
12723
|
+
let host;
|
|
12724
|
+
try {
|
|
12725
|
+
host = new URL(connectionString).hostname;
|
|
12726
|
+
} catch {
|
|
12727
|
+
return false;
|
|
12728
|
+
}
|
|
12729
|
+
const bare = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
12730
|
+
return bare === "localhost" || bare === "::1" || bare === "0.0.0.0" || bare === "" || /^127\./.test(bare) || bare.endsWith(".localhost");
|
|
12731
|
+
}
|
|
12732
|
+
/**
|
|
11960
12733
|
* Default PostgreSQL bootstrapper.
|
|
11961
12734
|
*
|
|
11962
12735
|
* Use it to register Postgres with `initializeRebaseBackend()`:
|
|
@@ -12058,21 +12831,26 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12058
12831
|
const runSql = async (text) => {
|
|
12059
12832
|
return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
|
|
12060
12833
|
};
|
|
12834
|
+
await warnOnRoleSchemaCollision(runSql);
|
|
12061
12835
|
const posture = await detectConnectionPosture(runSql);
|
|
12062
12836
|
if (posture.privileged) {
|
|
12063
12837
|
await ensureAppRole(runSql, [
|
|
12064
12838
|
"public",
|
|
12065
12839
|
"rebase",
|
|
12066
|
-
"auth",
|
|
12067
12840
|
...registry.getCollections().map((c) => c.schema).filter((s) => typeof s === "string")
|
|
12068
12841
|
]);
|
|
12069
12842
|
driver.rlsUserRole = REBASE_USER_ROLE;
|
|
12070
12843
|
realtimeService.rlsUserRole = REBASE_USER_ROLE;
|
|
12071
12844
|
logger.info(`🔐 RLS enforcement active: authenticated requests run as "${REBASE_USER_ROLE}" (connection "${posture.role}" bypasses RLS: ${posture.superuser ? "superuser" : posture.bypassRLS ? "BYPASSRLS" : "table owner"})`);
|
|
12072
|
-
if (posture.superuser || posture.bypassRLS)
|
|
12845
|
+
if (posture.superuser || posture.bypassRLS) {
|
|
12846
|
+
const message = `The database connection runs as ${posture.superuser ? "a superuser" : "a BYPASSRLS role"} ("${posture.role}"). User requests are isolated via SET LOCAL ROLE, but connect as a non-superuser table-owner role in production so the server/owner context is least-privilege.`;
|
|
12847
|
+
if (isScaffoldedLocalDatabase(pgConfig.connectionString)) logger.debug(`🔐 ${message}`);
|
|
12848
|
+
else logger.warn(`⚠️ ${message}`);
|
|
12849
|
+
}
|
|
12073
12850
|
} else logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
|
|
12074
12851
|
await validatePolicyPgRoles(runSql, registry.getCollections(), driver.rlsUserRole ?? posture.role);
|
|
12075
12852
|
warnOnAnonymousGrants(registry.getCollections());
|
|
12853
|
+
warnOnLegacyRlsFunctions(registry.getCollections());
|
|
12076
12854
|
}
|
|
12077
12855
|
if (driver.branchService) try {
|
|
12078
12856
|
await driver.branchService.ensureBranchMetadataTable();
|
|
@@ -12288,12 +13066,12 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12288
13066
|
*/
|
|
12289
13067
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
12290
13068
|
const internals = driverResult.internals;
|
|
12291
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
13069
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-CbvaGuVn.js");
|
|
12292
13070
|
const plan = await ensureCollectionTables({ async query(text) {
|
|
12293
13071
|
const result = await internals.db.execute(sql.raw(text));
|
|
12294
13072
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
12295
13073
|
} }, 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}`);
|
|
13074
|
+
for (const failure of plan.failures) logger.warn(failure.kind === "comment-column" ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, but a later change to the \`search\` block will not be detected: ${failure.error}` : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
|
|
12297
13075
|
return { applied: plan.actions.length - plan.failures.length };
|
|
12298
13076
|
},
|
|
12299
13077
|
/**
|
|
@@ -12313,13 +13091,27 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12313
13091
|
*/
|
|
12314
13092
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
12315
13093
|
const internals = driverResult.internals;
|
|
12316
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
13094
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-8vuu-n4r.js");
|
|
12317
13095
|
const outcome = await ensureCollectionPolicies({ async query(text) {
|
|
12318
13096
|
const result = await internals.db.execute(sql.raw(text));
|
|
12319
13097
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
12320
13098
|
} }, collections, log);
|
|
12321
13099
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
12322
|
-
for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" —
|
|
13100
|
+
for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`);
|
|
13101
|
+
const unrevoked = outcome.unsecured.filter((u) => !u.grantWithdrawn);
|
|
13102
|
+
for (const u of outcome.unsecured.filter((u) => u.grantWithdrawn)) logger.error(`🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is unreachable rather than unprotected. Reads and writes to that collection will fail until RLS can be enabled.`);
|
|
13103
|
+
if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
|
|
13104
|
+
try {
|
|
13105
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-69hYT8nr.js").then((n) => n.n);
|
|
13106
|
+
await dropLegacyAuthSchema(async (text) => {
|
|
13107
|
+
return (await internals.db.execute(sql.raw(text))).rows ?? [];
|
|
13108
|
+
}, {
|
|
13109
|
+
info: (m) => logger.info(m),
|
|
13110
|
+
warn: (m) => logger.warn(m)
|
|
13111
|
+
});
|
|
13112
|
+
} catch (err) {
|
|
13113
|
+
logger.info("Left the legacy `auth` schema in place: " + (err instanceof Error ? err.message : String(err)));
|
|
13114
|
+
}
|
|
12323
13115
|
return { applied: outcome.policiesApplied };
|
|
12324
13116
|
},
|
|
12325
13117
|
getAdmin(driverResult) {
|
|
@@ -12327,7 +13119,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12327
13119
|
},
|
|
12328
13120
|
mountRoutes(app, basePath, driverResult) {},
|
|
12329
13121
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
12330
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
13122
|
+
const { createPostgresWebSocket } = await import("./websocket-C8ZqVBiV.js").then((n) => n.n);
|
|
12331
13123
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
12332
13124
|
}
|
|
12333
13125
|
};
|
|
@@ -12367,6 +13159,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
12367
13159
|
};
|
|
12368
13160
|
}
|
|
12369
13161
|
//#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 };
|
|
13162
|
+
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
12371
13163
|
|
|
12372
13164
|
//# sourceMappingURL=index.es.js.map
|