@rebasepro/server-postgres 0.19.1 → 0.19.2-canary.g08eed46
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/{BranchService-ByLfM_Tt.js → BranchService-W3DMfcZZ.js} +2 -2
- package/dist/{BranchService-ByLfM_Tt.js.map → BranchService-W3DMfcZZ.js.map} +1 -1
- package/dist/PostgresBackendDriver.d.ts +81 -7
- package/dist/PostgresBootstrapper.d.ts +10 -0
- package/dist/{backup-cli-oM9WQVlV.js → backup-cli-DSpQyqcG.js} +2 -2
- package/dist/{backup-cli-oM9WQVlV.js.map → backup-cli-DSpQyqcG.js.map} +1 -1
- package/dist/{backup-service-Br3tqwsy.js → backup-service-DA7a6SUV.js} +2 -2
- package/dist/{backup-service-Br3tqwsy.js.map → backup-service-DA7a6SUV.js.map} +1 -1
- package/dist/{cli-errors-DxN4yzdH.js → cli-errors-C3g_kHBw.js} +137 -18
- package/dist/cli-errors-C3g_kHBw.js.map +1 -0
- package/dist/cli-errors.d.ts +19 -0
- package/dist/cli-helpers.d.ts +47 -0
- package/dist/cli.js +233 -19
- package/dist/cli.js.map +1 -1
- package/dist/{collection-index-BHgT_TKd.js → collection-index-DdnsxiJ_.js} +14 -4
- package/dist/{collection-index-BHgT_TKd.js.map → collection-index-DdnsxiJ_.js.map} +1 -1
- package/dist/collections/validate-relations.d.ts +7 -0
- package/dist/column-plan-helpers-CpILzHJS.js +145 -0
- package/dist/column-plan-helpers-CpILzHJS.js.map +1 -0
- package/dist/{doctor-D7HWmGzs.js → doctor-D5SrGJ5P.js} +175 -61
- package/dist/doctor-D5SrGJ5P.js.map +1 -0
- package/dist/{ensure-collection-policies-2c2NV8zX.js → ensure-collection-policies-CHO0moXQ.js} +5 -5
- package/dist/{ensure-collection-policies-2c2NV8zX.js.map → ensure-collection-policies-CHO0moXQ.js.map} +1 -1
- package/dist/{ensure-collection-tables-D4wJzM4y.js → ensure-collection-tables-Bkvehxdm.js} +318 -312
- package/dist/ensure-collection-tables-Bkvehxdm.js.map +1 -0
- package/dist/{ensure-tables-CIojK-Rx.js → ensure-tables-CYMtuuOd.js} +2 -2
- package/dist/{ensure-tables-CIojK-Rx.js.map → ensure-tables-CYMtuuOd.js.map} +1 -1
- package/dist/generate-drizzle-schema-logic-Dfyf_MRu.js +228 -0
- package/dist/generate-drizzle-schema-logic-Dfyf_MRu.js.map +1 -0
- package/dist/{generate-drizzle-schema-CwEgVyjO.js → generate-drizzle-schema-vSK-VdYT.js} +3 -3
- package/dist/generate-drizzle-schema-vSK-VdYT.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-CR2xcS7e.js +475 -0
- package/dist/generate-postgres-ddl-logic-CR2xcS7e.js.map +1 -0
- package/dist/{generated-schema-staleness-DQdfJl2A.js → generated-schema-staleness-DRQe2BpC.js} +81 -3
- package/dist/{generated-schema-staleness-DQdfJl2A.js.map → generated-schema-staleness-DRQe2BpC.js.map} +1 -1
- package/dist/history/HistoryService.d.ts +23 -4
- package/dist/index.es.js +1991 -580
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -1
- package/dist/plan-schema-Hgl62S-w.js +1531 -0
- package/dist/plan-schema-Hgl62S-w.js.map +1 -0
- package/dist/{policy-drift-kNJ6X2Uj.js → policy-drift-B0GDRh7_.js} +2 -2
- package/dist/{policy-drift-kNJ6X2Uj.js.map → policy-drift-B0GDRh7_.js.map} +1 -1
- package/dist/{rls-bootstrap-sql-DLDnUjtx.js → rls-bootstrap-sql-BlzsOUtz.js} +111 -8
- package/dist/{rls-bootstrap-sql-DLDnUjtx.js.map → rls-bootstrap-sql-BlzsOUtz.js.map} +1 -1
- package/dist/{rls-enforcement-DeYEjYLt.js → rls-enforcement-BV12vBwQ.js} +2 -1
- package/dist/rls-enforcement-BV12vBwQ.js.map +1 -0
- package/dist/schema/catalogue-schema.d.ts +97 -0
- package/dist/schema/collection-index.d.ts +9 -0
- package/dist/schema/column-plan-helpers.d.ts +125 -0
- package/dist/schema/config-relations.d.ts +32 -0
- package/dist/schema/doctor-cli.js +4 -4
- package/dist/schema/doctor.d.ts +9 -0
- package/dist/schema/dynamic-tables.d.ts +27 -6
- package/dist/schema/ensure-collection-tables.d.ts +31 -268
- package/dist/schema/generate-drizzle-schema-logic.d.ts +26 -8
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +84 -168
- package/dist/schema/generate-postgres-ddl.js +8 -2
- package/dist/schema/generate-postgres-ddl.js.map +1 -1
- package/dist/schema/generated-column-conflicts.d.ts +84 -0
- package/dist/schema/generated-schema-diff.d.ts +56 -0
- package/dist/schema/generated-schema-staleness.d.ts +33 -0
- package/dist/schema/introspect-db-logic.d.ts +2 -0
- package/dist/schema/plan/diff-plan.d.ts +220 -0
- package/dist/schema/plan/plan-schema.d.ts +84 -0
- package/dist/schema/plan/render-ddl.d.ts +62 -0
- package/dist/schema/plan/render-drizzle.d.ts +31 -0
- package/dist/schema/plan/types.d.ts +410 -0
- package/dist/schema/plan/updated-at-trigger.d.ts +22 -0
- package/dist/schema/relation-names.d.ts +28 -0
- package/dist/schema/search-column.d.ts +9 -0
- package/dist/security/rls-enforcement.d.ts +17 -0
- package/dist/services/FetchService.d.ts +176 -36
- package/dist/services/PersistService.d.ts +17 -4
- package/dist/services/RelationService.d.ts +11 -3
- package/dist/services/collection-helpers.d.ts +16 -2
- package/dist/services/dataService.d.ts +29 -2
- package/dist/services/field-op-sql.d.ts +16 -0
- package/dist/services/field-viewer.d.ts +19 -0
- package/dist/services/realtimeService.d.ts +35 -1
- package/dist/services/row-pipeline.d.ts +29 -5
- package/dist/services/soft-delete.d.ts +73 -0
- package/dist/utils/pg-error-utils.d.ts +41 -1
- package/dist/{vector-index-DkWxm2de.js → vector-index-CMJKb3C9.js} +3 -14
- package/dist/{vector-index-DkWxm2de.js.map → vector-index-CMJKb3C9.js.map} +1 -1
- package/package.json +18 -10
- package/dist/cli-errors-DxN4yzdH.js.map +0 -1
- package/dist/doctor-D7HWmGzs.js.map +0 -1
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +0 -1
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +0 -528
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +0 -1
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +0 -1312
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +0 -1
- package/dist/rls-enforcement-DeYEjYLt.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -2,28 +2,92 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
__createRequire(import.meta.url);
|
|
3
3
|
import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
|
|
4
4
|
import { a as createDirectDatabaseConnection, c as guardPoolAgainstDirtyRelease, l as pinSearchPath, o as createPostgresDatabaseConnection, r as cappedPoolMax, s as createReadReplicaConnection, t as DatabasePoolManager, u as poolMaxCeiling } from "./databasePoolManager-Bj5FbeAs.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-
|
|
8
|
-
import { c as warnOnLegacyRlsFunctions, i as ensureAppRole, l as warnOnRoleSchemaCollision, n as applyAuthContext, o as validatePolicyPgRoles, r as detectConnectionPosture, s as warnOnAnonymousGrants, t as REBASE_USER_ROLE } from "./rls-enforcement-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { t as
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
5
|
+
import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-Hgl62S-w.js";
|
|
6
|
+
import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-C3g_kHBw.js";
|
|
7
|
+
import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-W3DMfcZZ.js";
|
|
8
|
+
import { c as warnOnLegacyRlsFunctions, i as ensureAppRole, l as warnOnRoleSchemaCollision, n as applyAuthContext, o as validatePolicyPgRoles, r as detectConnectionPosture, s as warnOnAnonymousGrants, t as REBASE_USER_ROLE } from "./rls-enforcement-BV12vBwQ.js";
|
|
9
|
+
import { l as resolveColumnName } from "./column-plan-helpers-CpILzHJS.js";
|
|
10
|
+
import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
|
|
11
|
+
import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-CR2xcS7e.js";
|
|
12
|
+
import { t as generateSchema } from "./generate-drizzle-schema-logic-Dfyf_MRu.js";
|
|
13
|
+
import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Bkvehxdm.js";
|
|
14
|
+
import { t as main } from "./generate-drizzle-schema-vSK-VdYT.js";
|
|
15
|
+
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 getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-DA7a6SUV.js";
|
|
16
|
+
import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-CYMtuuOd.js";
|
|
14
17
|
import { c as identifyJoinTables, d as singularize, h as humanize, i as buildTablesMap, m as mapPgType, r as buildEnumMap, s as getIconForTable } from "./introspect-db-logic-C6LQdTxj.js";
|
|
15
18
|
import { Client } from "pg";
|
|
16
|
-
import { ApiError, assertWriteRequestValid, createDdlBootstrapper, createEmailService, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveRequireAuth, safeCompare } from "@rebasepro/server";
|
|
17
|
-
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
|
|
18
|
-
import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, ListLimitError, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, parseRelationAggregateSort, resolveClientListLimit } from "@rebasepro/types";
|
|
19
|
-
import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, fieldKeyForColumn, findRelation, getColumnName, getDeclaredPrimaryKeys, getTableName as getTableName$1, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, resolveCollectionRelations, revokeInternalTableSql, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
|
|
19
|
+
import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
|
|
20
|
+
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
|
|
21
|
+
import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, ListLimitError, MAX_INCLUDE_DEPTH, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, parseRelationAggregateSort, resolveClientListLimit } from "@rebasepro/types";
|
|
22
|
+
import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, revokeInternalTableSql, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
|
|
20
23
|
import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, mergeDeep, toSnakeCase, toWireKey } from "@rebasepro/utils";
|
|
24
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
21
25
|
import { PgArray, PgNumeric, 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";
|
|
22
26
|
import fs from "fs";
|
|
23
27
|
import { WebSocket, WebSocketServer } from "ws";
|
|
24
28
|
import { EventEmitter } from "events";
|
|
25
29
|
import { randomUUID } from "crypto";
|
|
26
30
|
import { inspect } from "util";
|
|
31
|
+
//#region src/services/field-viewer.ts
|
|
32
|
+
/**
|
|
33
|
+
* Who the current read is *for*, available to the row walk without threading it
|
|
34
|
+
* through every read signature.
|
|
35
|
+
*
|
|
36
|
+
* Per-field `access.read` has to be applied wherever a row leaves this driver,
|
|
37
|
+
* and that is not one place: a REST list, a single get, a relation target
|
|
38
|
+
* inlined into a parent, the same target rendered as an admin ref, a realtime
|
|
39
|
+
* frame, a history entry. The row pipeline is the one function they all share —
|
|
40
|
+
* and it is reached through `FetchService`, `RelationService` and `DataService`,
|
|
41
|
+
* none of which carry a user. Threading one through would be a parameter added
|
|
42
|
+
* to a dozen signatures and forgotten on the thirteenth, which is precisely how
|
|
43
|
+
* `stripExcluded` came to be missing from the relation-ref branch for a year.
|
|
44
|
+
*
|
|
45
|
+
* So the viewer is ambient, established at the two places that already
|
|
46
|
+
* establish the *database's* auth context for a read — `applyAuthContext` is
|
|
47
|
+
* called on the same line in both:
|
|
48
|
+
*
|
|
49
|
+
* - `AuthenticatedPostgresBackendDriver.withTransaction`, which every REST,
|
|
50
|
+
* SDK and WebSocket read runs inside;
|
|
51
|
+
* - `RealtimeService`'s RLS-bound refetches, which build the frames a
|
|
52
|
+
* subscriber receives.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately `run()` and not `enterWith()`. `enterWith` persists past the call
|
|
55
|
+
* that made it, so a `rebase.dataAsAdmin` read inside a user's request — a
|
|
56
|
+
* `beforeSave` hook, an `afterRead` hook — would leave the admin viewer in place
|
|
57
|
+
* for the rest of that request and strip nothing from what the user is served.
|
|
58
|
+
*
|
|
59
|
+
* @module
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* `undefined` inside the store is meaningful and is not the same as no store at
|
|
63
|
+
* all: {@link FieldViewer} reads absence as the trusted server plane. Nothing
|
|
64
|
+
* currently stores `undefined`, but the store's type says the difference is
|
|
65
|
+
* carried rather than flattened.
|
|
66
|
+
*/
|
|
67
|
+
var storage = new AsyncLocalStorage();
|
|
68
|
+
/**
|
|
69
|
+
* Run `fn` with `viewer` as the caller every row strip judges against.
|
|
70
|
+
*
|
|
71
|
+
* Nested calls shadow: a `dataAsAdmin` read inside a user request runs under the
|
|
72
|
+
* service identity for its duration and the user's viewer is restored after it,
|
|
73
|
+
* which is the behaviour both halves need and the reason this is not a variable.
|
|
74
|
+
*/
|
|
75
|
+
function withFieldViewer(viewer, fn) {
|
|
76
|
+
return storage.run(viewer, fn);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The caller the current read is for, or `undefined` on the trusted server plane
|
|
80
|
+
* — an in-process `rebase.data` call on the base driver, a migration, the auth
|
|
81
|
+
* adapter reading a password hash to verify it.
|
|
82
|
+
*
|
|
83
|
+
* Absence cannot be reached from outside: every API ingress scopes its driver
|
|
84
|
+
* with `withAuth`, including an unauthenticated one (`roles: ["anon"]`), and
|
|
85
|
+
* that is what establishes the viewer.
|
|
86
|
+
*/
|
|
87
|
+
function currentFieldViewer() {
|
|
88
|
+
return storage.getStore();
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
27
91
|
//#region src/services/collection-helpers.ts
|
|
28
92
|
/** Safely extract Drizzle column metadata from a column object. */
|
|
29
93
|
function getColumnMeta(col) {
|
|
@@ -55,6 +119,25 @@ function idCanAddressTable(id, table, idInfoArray) {
|
|
|
55
119
|
};
|
|
56
120
|
}));
|
|
57
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The columns of a table's composite primary-key constraint, or `[]`.
|
|
124
|
+
*
|
|
125
|
+
* Fails open for anything that is not a real Drizzle table — a test double, a
|
|
126
|
+
* hand-built registry entry — because "no key" is a legitimate answer here and
|
|
127
|
+
* throwing would take out every caller that only wanted to ask.
|
|
128
|
+
*/
|
|
129
|
+
function compositePrimaryKeyColumns(table) {
|
|
130
|
+
try {
|
|
131
|
+
return getTableConfig(table).primaryKeys?.[0]?.columns ?? [];
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** The Drizzle key a column is registered under, falling back to its name. */
|
|
137
|
+
function columnFieldName(table, column) {
|
|
138
|
+
for (const [key, candidate] of Object.entries(getTableColumns(table))) if (candidate === column) return key;
|
|
139
|
+
return column.name;
|
|
140
|
+
}
|
|
58
141
|
function getCollectionByPath(collectionPath, registry) {
|
|
59
142
|
const collection = registry.getCollectionByPath(collectionPath);
|
|
60
143
|
if (!collection) {
|
|
@@ -66,6 +149,14 @@ function getCollectionByPath(collectionPath, registry) {
|
|
|
66
149
|
/**
|
|
67
150
|
* Reject a write naming something that is not a column of the table.
|
|
68
151
|
*
|
|
152
|
+
* "The table" is the one read back from `information_schema` at boot — see
|
|
153
|
+
* `catalogue-schema.ts` — so this asks the database, not a file. It used to ask
|
|
154
|
+
* the generated module, which made the commonest cause of this error a stale
|
|
155
|
+
* `schema.generated.ts`: a property added to a collection was created in the
|
|
156
|
+
* database by boot-ensure and still answered `400` on its first save, because
|
|
157
|
+
* the module had not been regenerated. That cause no longer exists, and the
|
|
158
|
+
* remedy below no longer names it.
|
|
159
|
+
*
|
|
69
160
|
* Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
|
|
70
161
|
* from `Object.keys(tableColumns)`, so a key the table does not carry is not
|
|
71
162
|
* rejected by anything — it is *left out of the statement*. The insert answers
|
|
@@ -93,7 +184,14 @@ function assertWritableColumns(values, table, collectionPath) {
|
|
|
93
184
|
if (!columns || Object.keys(columns).length === 0) return;
|
|
94
185
|
const unknown = Object.keys(values).filter((key) => !(key in columns));
|
|
95
186
|
if (unknown.length === 0) return;
|
|
96
|
-
|
|
187
|
+
const byColumnName = /* @__PURE__ */ new Map();
|
|
188
|
+
for (const [key, column] of Object.entries(columns)) {
|
|
189
|
+
const name = column?.name;
|
|
190
|
+
if (typeof name === "string" && name !== key) byColumnName.set(name, key);
|
|
191
|
+
}
|
|
192
|
+
const misspelled = unknown.filter((key) => byColumnName.has(key));
|
|
193
|
+
if (misspelled.length === unknown.length) throw ApiError.badRequest(`'${collectionPath}' has no field ${misspelled.map((key) => `'${key}'`).join(", ")} — ${misspelled.length > 1 ? "those are column names" : "that is a column name"}. Write ${misspelled.map((key) => `'${byColumnName.get(key)}'`).join(", ")} instead: a field is addressed by its property key, and the column behind it is an implementation detail.`, "VALIDATION_UNKNOWN_FIELDS");
|
|
194
|
+
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. If you just added the propert` + (unknown.length > 1 ? "ies" : "y") + ", the database does not have the column yet: apply the schema (`rebase db push`, or redeploy so boot creates it).", "VALIDATION_UNKNOWN_FIELDS");
|
|
97
195
|
}
|
|
98
196
|
/**
|
|
99
197
|
* A relation whose names do not resolve against the registered schema.
|
|
@@ -122,10 +220,16 @@ function getTableForCollection(collection, registry) {
|
|
|
122
220
|
* The key columns a collection's rows are addressed by.
|
|
123
221
|
*
|
|
124
222
|
* Three tiers, in order: properties marked `isId`, the primary keys of the
|
|
125
|
-
*
|
|
126
|
-
* visible to the browser, which is why a key known only to
|
|
223
|
+
* registered table, and finally a column literally named `id`. Only the first is
|
|
224
|
+
* visible to the browser, which is why a key known only to the table is reported
|
|
127
225
|
* at boot — see {@link warnOnKeysTheAdminCannotResolve}.
|
|
128
226
|
*
|
|
227
|
+
* The second tier reads the table the registry holds, which is built from
|
|
228
|
+
* `information_schema` — the database's own primary key, not a generated file's
|
|
229
|
+
* claim about it. Composite keys are declared as a table constraint rather than
|
|
230
|
+
* on each column (that is the only legal way to say it), so both shapes are
|
|
231
|
+
* asked for.
|
|
232
|
+
*
|
|
129
233
|
* Returns `[]` when nothing resolves, rather than throwing. It used to open by
|
|
130
234
|
* resolving the table, which throws when there is none — so the `isId` tier,
|
|
131
235
|
* which needs no table at all, was unreachable for exactly the collections
|
|
@@ -162,6 +266,14 @@ function getPrimaryKeys(collection, registry) {
|
|
|
162
266
|
});
|
|
163
267
|
}
|
|
164
268
|
}
|
|
269
|
+
if (keys.length === 0) for (const column of compositePrimaryKeyColumns(table)) {
|
|
270
|
+
const meta = getColumnMeta(column);
|
|
271
|
+
keys.push({
|
|
272
|
+
fieldName: columnFieldName(table, column),
|
|
273
|
+
type: column.dataType === "number" || meta.columnType === "PgSerial" || meta.columnType === "PgInteger" ? "number" : "string",
|
|
274
|
+
isUUID: meta.columnType === "PgUUID"
|
|
275
|
+
});
|
|
276
|
+
}
|
|
165
277
|
if (keys.length === 0 && "id" in table) {
|
|
166
278
|
const idCol = table["id"];
|
|
167
279
|
const idMeta = getColumnMeta(idCol);
|
|
@@ -780,7 +892,10 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
780
892
|
if ("type" in cond) {
|
|
781
893
|
const subSQLs = cond.conditions.map((c) => this.buildLogicalConditions(c, table, collectionPath, options)).filter((sql) => sql !== null);
|
|
782
894
|
if (subSQLs.length === 0) return null;
|
|
783
|
-
|
|
895
|
+
if (cond.type === "or") return or(...subSQLs) ?? null;
|
|
896
|
+
const conjunction = and(...subSQLs) ?? null;
|
|
897
|
+
if (cond.type === "not") return conjunction ? not(conjunction) : null;
|
|
898
|
+
return conjunction;
|
|
784
899
|
} else {
|
|
785
900
|
const target = this.resolveFilterTarget(table, cond.column, collectionPath, options.unknownFields ?? defaultUnknownFilterFieldsMode, options);
|
|
786
901
|
if (!target) return null;
|
|
@@ -1596,8 +1711,10 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1596
1711
|
const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
|
|
1597
1712
|
if (ftsCondition) return [ftsCondition];
|
|
1598
1713
|
let declaredStringProperties = 0;
|
|
1714
|
+
const viewer = currentFieldViewer();
|
|
1599
1715
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1600
1716
|
const p = prop;
|
|
1717
|
+
if (!canReadField(prop, viewer)) continue;
|
|
1601
1718
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
1602
1719
|
declaredStringProperties++;
|
|
1603
1720
|
const fieldColumn = table[key];
|
|
@@ -2420,42 +2537,101 @@ function coerceDeclaredNumber(value, property) {
|
|
|
2420
2537
|
const parsed = parseFloat(value);
|
|
2421
2538
|
return isNaN(parsed) ? null : parsed;
|
|
2422
2539
|
}
|
|
2423
|
-
/**
|
|
2424
|
-
|
|
2540
|
+
/**
|
|
2541
|
+
* Serve a `date` column as the timestamp this API says it serves.
|
|
2542
|
+
*
|
|
2543
|
+
* The OpenAPI document this server publishes types a `date` property as
|
|
2544
|
+
* `string, format: date-time` — RFC 3339 — and node-postgres hands over the
|
|
2545
|
+
* Postgres literal: `"2026-08-24 01:37:57.647+02"`, with a space where the `T`
|
|
2546
|
+
* belongs and a two-digit offset. V8 parses that by accident; Safari's
|
|
2547
|
+
* `new Date()` does not have to, and the spec never promised it. A column whose
|
|
2548
|
+
* documented type only parses in some browsers is not a contract.
|
|
2549
|
+
*
|
|
2550
|
+
* A date-only column (`mode: "date"`, `format: "date"` in the spec) is already
|
|
2551
|
+
* RFC 3339 as `YYYY-MM-DD` and is left alone — widening it to a timestamp would
|
|
2552
|
+
* invent a time of day and a timezone the column does not have.
|
|
2553
|
+
*/
|
|
2554
|
+
function toRestDate(value) {
|
|
2555
|
+
if (value instanceof Date) return isNaN(value.getTime()) ? null : value.toISOString();
|
|
2556
|
+
if (value && typeof value === "object" && value.__type === "date") return value.value ?? null;
|
|
2557
|
+
if (typeof value !== "string" && typeof value !== "number") return value;
|
|
2558
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
|
2559
|
+
const date = new Date(value);
|
|
2560
|
+
return isNaN(date.getTime()) ? value : date.toISOString();
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* One scalar, as REST serves it: declared numbers as numbers, declared dates as
|
|
2564
|
+
* RFC 3339, everything else exactly as the database returned it.
|
|
2565
|
+
*/
|
|
2566
|
+
function toRestScalar(value, property) {
|
|
2567
|
+
if (property?.type === "date") return toRestDate(value);
|
|
2568
|
+
return coerceDeclaredNumber(value, property);
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2571
|
+
* Apply {@link toRestScalar} across a row, leaving undeclared columns alone.
|
|
2572
|
+
*
|
|
2573
|
+
* Exported because the include loader attaches related rows itself, and a
|
|
2574
|
+
* target rendered differently from its parent is the shape bug this whole file
|
|
2575
|
+
* exists to prevent — a date was a string at the top level and a
|
|
2576
|
+
* `{ __type: "date" }` envelope one level down, in the same response.
|
|
2577
|
+
*/
|
|
2578
|
+
function toRestValues(row, collection) {
|
|
2425
2579
|
const properties = collection.properties;
|
|
2426
2580
|
if (!properties) return row;
|
|
2427
2581
|
const out = {};
|
|
2428
|
-
for (const [key, value] of Object.entries(row)) out[key] =
|
|
2582
|
+
for (const [key, value] of Object.entries(row)) out[key] = toRestScalar(value, properties[key]);
|
|
2429
2583
|
return out;
|
|
2430
2584
|
}
|
|
2431
2585
|
/** Render one target row in the requested style. */
|
|
2432
2586
|
/**
|
|
2433
|
-
* Drop every column
|
|
2587
|
+
* Drop every column this caller may not read.
|
|
2434
2588
|
*
|
|
2435
2589
|
* Password hashes and verification tokens have to be readable server-side but
|
|
2436
2590
|
* must never reach a client — and "never" has to mean every exit from this
|
|
2437
2591
|
* pipeline, including relation targets, or a secret leaks through whichever
|
|
2438
2592
|
* path was overlooked. Keyed by both the property name and its column name,
|
|
2439
2593
|
* since a row can arrive keyed either way depending on the caller.
|
|
2594
|
+
*
|
|
2595
|
+
* `excludeFromApi` is the case with no roles in it: `effectiveAccess` expands
|
|
2596
|
+
* the flag to `read: []`, which no caller satisfies, so the flag needs no branch
|
|
2597
|
+
* of its own here and no longer has one. `access.read: ["hr"]` is the same walk
|
|
2598
|
+
* asking a different question of {@link currentFieldViewer}.
|
|
2599
|
+
*
|
|
2600
|
+
* The field is **deleted**, never nulled. A withheld value that arrives as
|
|
2601
|
+
* `null` is indistinguishable from a stored `null`, which turns a permission
|
|
2602
|
+
* boundary into a question a client can answer by counting nulls — and it makes
|
|
2603
|
+
* an `update` that echoes the row back overwrite the real value with the null it
|
|
2604
|
+
* was handed.
|
|
2605
|
+
*
|
|
2606
|
+
* `_matches` is filtered rather than deleted: it is the list of fields a text
|
|
2607
|
+
* search hit, and a field the caller cannot read must not appear in it even
|
|
2608
|
+
* though the array itself is theirs to see.
|
|
2440
2609
|
*/
|
|
2441
|
-
function
|
|
2610
|
+
function stripUnreadable(row, collection) {
|
|
2442
2611
|
const properties = collection.properties;
|
|
2443
2612
|
if (!properties) return row;
|
|
2613
|
+
const viewer = currentFieldViewer();
|
|
2614
|
+
const hidden = /* @__PURE__ */ new Set();
|
|
2444
2615
|
for (const [key, property] of Object.entries(properties)) {
|
|
2445
|
-
if (
|
|
2616
|
+
if (canReadField(property, viewer)) continue;
|
|
2617
|
+
hidden.add(key);
|
|
2446
2618
|
delete row[key];
|
|
2447
|
-
if (property.columnName)
|
|
2619
|
+
if (property.columnName) {
|
|
2620
|
+
hidden.add(property.columnName);
|
|
2621
|
+
delete row[property.columnName];
|
|
2622
|
+
}
|
|
2448
2623
|
}
|
|
2624
|
+
if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
|
|
2449
2625
|
return row;
|
|
2450
2626
|
}
|
|
2451
2627
|
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
2452
|
-
if (style === "inline") return
|
|
2628
|
+
if (style === "inline") return stripUnreadable(toRestValues({ ...targetRow }, targetCollection), targetCollection);
|
|
2453
2629
|
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
2454
2630
|
const path = targetCollection.slug;
|
|
2455
2631
|
return createRelationRefWithData(address, path, {
|
|
2456
2632
|
id: address,
|
|
2457
2633
|
path,
|
|
2458
|
-
values:
|
|
2634
|
+
values: stripUnreadable(normalizeDbValues(targetRow, targetCollection), targetCollection)
|
|
2459
2635
|
});
|
|
2460
2636
|
}
|
|
2461
2637
|
/**
|
|
@@ -2491,7 +2667,7 @@ function toFlatRow(row, collection, registry) {
|
|
|
2491
2667
|
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
2492
2668
|
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
2493
2669
|
}
|
|
2494
|
-
return
|
|
2670
|
+
return stripUnreadable(normalized, collection);
|
|
2495
2671
|
}
|
|
2496
2672
|
/**
|
|
2497
2673
|
* The row REST serves: every column under its own name, with the value Postgres
|
|
@@ -2499,9 +2675,9 @@ function toFlatRow(row, collection, registry) {
|
|
|
2499
2675
|
*
|
|
2500
2676
|
* Values are the ones the database returned, except where that contradicts the
|
|
2501
2677
|
* declared type: a `number` property is served as a number (see
|
|
2502
|
-
* {@link coerceDeclaredNumber})
|
|
2503
|
-
*
|
|
2504
|
-
*
|
|
2678
|
+
* {@link coerceDeclaredNumber}) and a `date` as RFC 3339, which is what this
|
|
2679
|
+
* server's own OpenAPI document says a date column is (see
|
|
2680
|
+
* {@link toRestDate}).
|
|
2505
2681
|
*
|
|
2506
2682
|
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
2507
2683
|
* the relations `include` asked for, so the row is the authority on which are
|
|
@@ -2514,9 +2690,9 @@ function toRestRow(row, collection, registry) {
|
|
|
2514
2690
|
const relation = findRelation(resolvedRelations, key);
|
|
2515
2691
|
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
|
|
2516
2692
|
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
2517
|
-
else flat[key] =
|
|
2693
|
+
else flat[key] = toRestScalar(value, collection.properties?.[key]);
|
|
2518
2694
|
}
|
|
2519
|
-
return
|
|
2695
|
+
return stripUnreadable(flat, collection);
|
|
2520
2696
|
}
|
|
2521
2697
|
//#endregion
|
|
2522
2698
|
//#region src/services/write-denial.ts
|
|
@@ -2690,6 +2866,73 @@ async function removeLinks(tx, binding, parentId, removed) {
|
|
|
2690
2866
|
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2691
2867
|
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");
|
|
2692
2868
|
}
|
|
2869
|
+
/**
|
|
2870
|
+
* The soft-delete field this collection uses, or `undefined` if it has none.
|
|
2871
|
+
*
|
|
2872
|
+
* Returns the field even when the property is missing, because the *boot* check
|
|
2873
|
+
* is what refuses that config; making this return `undefined` for it would turn
|
|
2874
|
+
* a misconfiguration into "soft delete quietly off", which is the failure mode
|
|
2875
|
+
* worth avoiding — deletes would silently start removing rows.
|
|
2876
|
+
*/
|
|
2877
|
+
function resolveSoftDelete(collection) {
|
|
2878
|
+
const declared = collection?.softDelete;
|
|
2879
|
+
if (!declared) return void 0;
|
|
2880
|
+
const field = typeof declared === "object" && declared.field || "deletedAt";
|
|
2881
|
+
return {
|
|
2882
|
+
field,
|
|
2883
|
+
columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* The drizzle column behind the soft-delete field, if the table has one.
|
|
2888
|
+
*
|
|
2889
|
+
* Matched on the column's own `name` rather than on the key, because a
|
|
2890
|
+
* generated schema keys columns by the property name while an introspected one
|
|
2891
|
+
* may key them by the column — and this has to work for both.
|
|
2892
|
+
*/
|
|
2893
|
+
function softDeleteColumn(collection, table) {
|
|
2894
|
+
const resolved = resolveSoftDelete(collection);
|
|
2895
|
+
if (!resolved) return void 0;
|
|
2896
|
+
const columns = table ? getTableColumns(table) : void 0;
|
|
2897
|
+
if (!columns) return void 0;
|
|
2898
|
+
const direct = columns[resolved.field] ?? columns[resolved.columnName];
|
|
2899
|
+
if (direct) return direct;
|
|
2900
|
+
for (const column of Object.values(columns)) if (column.name === resolved.columnName) return column;
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* `deleted_at IS NULL` — or nothing, when the caller asked for the deleted rows
|
|
2904
|
+
* or the collection has no soft delete at all.
|
|
2905
|
+
*
|
|
2906
|
+
* The one place the rule is written. Call it from every read; `undefined` means
|
|
2907
|
+
* "add no condition", which composes with `and(...)` unchanged.
|
|
2908
|
+
*/
|
|
2909
|
+
function softDeleteCondition(collection, table, withDeleted) {
|
|
2910
|
+
if (withDeleted === true) return void 0;
|
|
2911
|
+
const column = softDeleteColumn(collection, table);
|
|
2912
|
+
if (!column) return void 0;
|
|
2913
|
+
return withDeleted === "only" ? isNotNull(column) : isNull(column);
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Fold the soft-delete condition into a list of conditions that is being built.
|
|
2917
|
+
*
|
|
2918
|
+
* A convenience for the call sites that already collect `SQL[]`, so adding soft
|
|
2919
|
+
* delete to one of them is a single line rather than three.
|
|
2920
|
+
*/
|
|
2921
|
+
function withSoftDelete(conditions, collection, table, withDeleted) {
|
|
2922
|
+
const condition = softDeleteCondition(collection, table, withDeleted);
|
|
2923
|
+
if (condition) conditions.push(condition);
|
|
2924
|
+
return conditions;
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Combine an existing `where` with the soft-delete condition.
|
|
2928
|
+
*
|
|
2929
|
+
* For the call sites that hold one composed condition rather than a list.
|
|
2930
|
+
*/
|
|
2931
|
+
function andSoftDelete(where, collection, table, withDeleted) {
|
|
2932
|
+
const condition = softDeleteCondition(collection, table, withDeleted);
|
|
2933
|
+
if (!condition) return where;
|
|
2934
|
+
return where ? and(where, condition) : condition;
|
|
2935
|
+
}
|
|
2693
2936
|
//#endregion
|
|
2694
2937
|
//#region src/services/RelationService.ts
|
|
2695
2938
|
/**
|
|
@@ -2710,6 +2953,17 @@ function applyDynamicRelationQuery(query, ...args) {
|
|
|
2710
2953
|
return DrizzleConditionBuilder.buildRelationQuery(...args);
|
|
2711
2954
|
}
|
|
2712
2955
|
/**
|
|
2956
|
+
* The relation's own matching condition, AND-ed with an include's `where`.
|
|
2957
|
+
*
|
|
2958
|
+
* Written once because the batch loaders reach the same decision from six
|
|
2959
|
+
* places — one per relation kind, twice over for the to-one and to-many
|
|
2960
|
+
* variants — and a `where` dropped from one of them is a filter the caller
|
|
2961
|
+
* asked for that silently did not apply.
|
|
2962
|
+
*/
|
|
2963
|
+
function narrowed(base, narrow) {
|
|
2964
|
+
return narrow ? and(base, narrow) : base;
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2713
2967
|
* The registry has no table for a collection a relation just asked about.
|
|
2714
2968
|
*
|
|
2715
2969
|
* "Parent table not found" was the whole message, four times over, and it names
|
|
@@ -2746,7 +3000,7 @@ var RelationService = class {
|
|
|
2746
3000
|
*/
|
|
2747
3001
|
async toRelatedRow(targetRow, targetCollection, targetPks, options) {
|
|
2748
3002
|
const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
|
|
2749
|
-
|
|
3003
|
+
stripUnreadable(values, targetCollection);
|
|
2750
3004
|
return {
|
|
2751
3005
|
id: buildCompositeId$1(targetRow, targetPks),
|
|
2752
3006
|
path: targetCollection.slug,
|
|
@@ -2913,6 +3167,8 @@ var RelationService = class {
|
|
|
2913
3167
|
if (matchValue === void 0) return [];
|
|
2914
3168
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
2915
3169
|
const additionalFilters = [];
|
|
3170
|
+
const relatedSoftDelete = softDeleteCondition(targetCollection, targetTable);
|
|
3171
|
+
if (relatedSoftDelete) additionalFilters.push(relatedSoftDelete);
|
|
2916
3172
|
if (options.searchString) {
|
|
2917
3173
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
|
|
2918
3174
|
if (searchConditions.length === 0) return [];
|
|
@@ -2965,6 +3221,8 @@ var RelationService = class {
|
|
|
2965
3221
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
2966
3222
|
const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
|
|
2967
3223
|
if (matchValue === void 0) return 0;
|
|
3224
|
+
const countSoftDelete = softDeleteCondition(targetCollection, targetTable);
|
|
3225
|
+
if (countSoftDelete) additionalFilters = [...additionalFilters, countSoftDelete];
|
|
2968
3226
|
let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
2969
3227
|
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
|
|
2970
3228
|
const result = await query;
|
|
@@ -2990,9 +3248,14 @@ var RelationService = class {
|
|
|
2990
3248
|
return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
|
|
2991
3249
|
}
|
|
2992
3250
|
/**
|
|
2993
|
-
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
3251
|
+
* Batch fetch related rows for multiple parent rows to avoid N+1 queries.
|
|
3252
|
+
*
|
|
3253
|
+
* `narrow` is an extra condition on the *target* table — what a per-include
|
|
3254
|
+
* `where` compiles to. It is pushed into the query rather than applied to
|
|
3255
|
+
* the rows that come back, because filtering afterwards reads every related
|
|
3256
|
+
* row of every parent in order to discard most of them.
|
|
2994
3257
|
*/
|
|
2995
|
-
async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
|
|
3258
|
+
async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation, narrow) {
|
|
2996
3259
|
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
2997
3260
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
2998
3261
|
const targetCollection = relation.target();
|
|
@@ -3007,6 +3270,7 @@ var RelationService = class {
|
|
|
3007
3270
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
3008
3271
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
3009
3272
|
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
3273
|
+
const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
|
|
3010
3274
|
if (relation.kind === "via") {
|
|
3011
3275
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
3012
3276
|
let currentTable = parentTable;
|
|
@@ -3025,7 +3289,7 @@ var RelationService = class {
|
|
|
3025
3289
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
3026
3290
|
currentTable = joinTable;
|
|
3027
3291
|
}
|
|
3028
|
-
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
3292
|
+
query = query.where(narrowed(this.parentKeyCondition(parentTable, parentPks, parentIds), narrowTarget));
|
|
3029
3293
|
const results = await query;
|
|
3030
3294
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
3031
3295
|
const resultMap = /* @__PURE__ */ new Map();
|
|
@@ -3057,7 +3321,7 @@ var RelationService = class {
|
|
|
3057
3321
|
}
|
|
3058
3322
|
}
|
|
3059
3323
|
if (uniqueFkValues.length === 0) return /* @__PURE__ */ new Map();
|
|
3060
|
-
const targetResults = await this.db.select().from(targetTable).where(inArray(targetIdField, uniqueFkValues));
|
|
3324
|
+
const targetResults = await this.db.select().from(targetTable).where(narrowed(inArray(targetIdField, uniqueFkValues), narrowTarget));
|
|
3061
3325
|
const targetById = /* @__PURE__ */ new Map();
|
|
3062
3326
|
for (const row of targetResults) {
|
|
3063
3327
|
const tid = String(row[targetIdInfo.fieldName]);
|
|
@@ -3078,7 +3342,7 @@ var RelationService = class {
|
|
|
3078
3342
|
const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
|
|
3079
3343
|
if (matchValues.length === 0) return /* @__PURE__ */ new Map();
|
|
3080
3344
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
3081
|
-
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
3345
|
+
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, narrowTarget ? [narrowTarget] : []);
|
|
3082
3346
|
const results = await query;
|
|
3083
3347
|
const resultMap = /* @__PURE__ */ new Map();
|
|
3084
3348
|
for (const row of results) {
|
|
@@ -3096,7 +3360,7 @@ var RelationService = class {
|
|
|
3096
3360
|
* Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
|
|
3097
3361
|
* Uses a single SQL query with IN clause to avoid N+1.
|
|
3098
3362
|
*/
|
|
3099
|
-
async batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, _relationKey, relation) {
|
|
3363
|
+
async batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, _relationKey, relation, narrow) {
|
|
3100
3364
|
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
3101
3365
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
3102
3366
|
const targetCollection = relation.target();
|
|
@@ -3110,6 +3374,7 @@ var RelationService = class {
|
|
|
3110
3374
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
3111
3375
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
3112
3376
|
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
3377
|
+
const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
|
|
3113
3378
|
if (relation.kind === "via") {
|
|
3114
3379
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
3115
3380
|
let currentTable = parentTable;
|
|
@@ -3126,7 +3391,7 @@ var RelationService = class {
|
|
|
3126
3391
|
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
3127
3392
|
currentTable = joinTable;
|
|
3128
3393
|
}
|
|
3129
|
-
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
3394
|
+
query = query.where(narrowed(this.parentKeyCondition(parentTable, parentPks, parentIds), narrowTarget));
|
|
3130
3395
|
const results = await query;
|
|
3131
3396
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
3132
3397
|
const resultMap = /* @__PURE__ */ new Map();
|
|
@@ -3143,7 +3408,7 @@ var RelationService = class {
|
|
|
3143
3408
|
if (relation.kind === "manyToMany") {
|
|
3144
3409
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
3145
3410
|
const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relation.relationName}`);
|
|
3146
|
-
const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(inArray(sourceJunctionCol, parsedParentIds));
|
|
3411
|
+
const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(narrowed(inArray(sourceJunctionCol, parsedParentIds), narrowTarget));
|
|
3147
3412
|
const resultMap = /* @__PURE__ */ new Map();
|
|
3148
3413
|
const targetTableName = getTableName$1(targetCollection);
|
|
3149
3414
|
for (const row of results) {
|
|
@@ -3164,7 +3429,7 @@ var RelationService = class {
|
|
|
3164
3429
|
const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
|
|
3165
3430
|
if (matchValues.length === 0) return /* @__PURE__ */ new Map();
|
|
3166
3431
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
3167
|
-
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
|
|
3432
|
+
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, narrowTarget ? [narrowTarget] : []);
|
|
3168
3433
|
const results = await query;
|
|
3169
3434
|
const resultMap = /* @__PURE__ */ new Map();
|
|
3170
3435
|
for (const row of results) {
|
|
@@ -3262,6 +3527,28 @@ function assertWritableThrough(hop, path) {
|
|
|
3262
3527
|
//#endregion
|
|
3263
3528
|
//#region src/services/FetchService.ts
|
|
3264
3529
|
/**
|
|
3530
|
+
* Ascending comparison for two values of one column, with NULLs placed.
|
|
3531
|
+
*
|
|
3532
|
+
* Used only to order the rows *inside* one included relation, where the sort
|
|
3533
|
+
* happens after a batched load rather than in SQL — see
|
|
3534
|
+
* {@link FetchService.shapeRelatedRows}. The NULL placement is the same
|
|
3535
|
+
* question the `ORDER BY` answers, and it is answered the same way here so a
|
|
3536
|
+
* nested `orderBy` does not put its empty values at the opposite end from the
|
|
3537
|
+
* identical top-level one.
|
|
3538
|
+
*/
|
|
3539
|
+
function compareForSort(a, b, nullsLast) {
|
|
3540
|
+
const aNull = a === null || a === void 0;
|
|
3541
|
+
const bNull = b === null || b === void 0;
|
|
3542
|
+
if (aNull && bNull) return 0;
|
|
3543
|
+
if (aNull) return nullsLast ? 1 : -1;
|
|
3544
|
+
if (bNull) return nullsLast ? -1 : 1;
|
|
3545
|
+
const av = a instanceof Date ? a.getTime() : a;
|
|
3546
|
+
const bv = b instanceof Date ? b.getTime() : b;
|
|
3547
|
+
if (typeof av === "number" && typeof bv === "number") return av - bv;
|
|
3548
|
+
if (typeof av === "boolean" && typeof bv === "boolean") return (av ? 1 : 0) - (bv ? 1 : 0);
|
|
3549
|
+
return String(av).localeCompare(String(bv));
|
|
3550
|
+
}
|
|
3551
|
+
/**
|
|
3265
3552
|
* Service for handling all row read operations.
|
|
3266
3553
|
* Handles fetching, searching, counting, and filtering rows.
|
|
3267
3554
|
*/
|
|
@@ -3391,12 +3678,13 @@ var FetchService = class FetchService {
|
|
|
3391
3678
|
*/
|
|
3392
3679
|
resolveOrderKeys(table, keys, collection, searchString, collectionPath, cursorId) {
|
|
3393
3680
|
const resolved = [];
|
|
3394
|
-
for (const [field, direction] of keys) {
|
|
3681
|
+
for (const [field, direction, nulls] of keys) {
|
|
3395
3682
|
const aggregate = this.resolveAggregateOrderTarget(table, field, collection, collectionPath);
|
|
3396
3683
|
if (aggregate) {
|
|
3397
3684
|
resolved.push({
|
|
3398
3685
|
field,
|
|
3399
3686
|
direction,
|
|
3687
|
+
nulls,
|
|
3400
3688
|
target: aggregate,
|
|
3401
3689
|
...cursorId !== void 0 && { cursorTarget: this.resolveAggregateOrderTarget(table, field, collection, collectionPath, cursorId) }
|
|
3402
3690
|
});
|
|
@@ -3406,12 +3694,31 @@ var FetchService = class FetchService {
|
|
|
3406
3694
|
if (target) resolved.push({
|
|
3407
3695
|
field,
|
|
3408
3696
|
direction,
|
|
3697
|
+
nulls,
|
|
3409
3698
|
target
|
|
3410
3699
|
});
|
|
3411
3700
|
}
|
|
3412
3701
|
return resolved;
|
|
3413
3702
|
}
|
|
3414
3703
|
/**
|
|
3704
|
+
* Whether this key's NULLs sort *after* its real values.
|
|
3705
|
+
*
|
|
3706
|
+
* The default is Postgres's own — `NULLS LAST` ascending, `NULLS FIRST`
|
|
3707
|
+
* descending — and it was previously hardcoded in two places that had to
|
|
3708
|
+
* agree: the `ORDER BY` and the keyset comparison behind cursor paging. A
|
|
3709
|
+
* key that states a placement overrides it, in both, because they read the
|
|
3710
|
+
* answer from here.
|
|
3711
|
+
*
|
|
3712
|
+
* The default is also the thing a "newest first" list gets wrong: every row
|
|
3713
|
+
* with no date sorts to the very top, ahead of everything real, and the only
|
|
3714
|
+
* way out used to be an `is-not-null` filter that dropped those rows
|
|
3715
|
+
* entirely. `orderBy: [["published_at", "desc", "last"]]` is the fix.
|
|
3716
|
+
*/
|
|
3717
|
+
static nullsLast(key) {
|
|
3718
|
+
if (key.nulls) return key.nulls === "last";
|
|
3719
|
+
return key.direction === "asc";
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3415
3722
|
* The full `ORDER BY`: the caller's keys, then the id.
|
|
3416
3723
|
*
|
|
3417
3724
|
* The id is always last and always descending. It is not decoration — it is
|
|
@@ -3431,9 +3738,13 @@ var FetchService = class FetchService {
|
|
|
3431
3738
|
* relation reaches nothing from, which is precisely the "nobody waiting"
|
|
3432
3739
|
* end of a queue.
|
|
3433
3740
|
*/
|
|
3434
|
-
buildOrderExpressions(keys, idField) {
|
|
3435
|
-
const expressions = keys.map((
|
|
3436
|
-
|
|
3741
|
+
buildOrderExpressions(keys, idField, tieBreakOnId = true) {
|
|
3742
|
+
const expressions = keys.map((key) => {
|
|
3743
|
+
const nullsLast = FetchService.nullsLast(key);
|
|
3744
|
+
if (key.direction === "asc") return nullsLast ? sql`${key.target} ASC NULLS LAST` : sql`${key.target} ASC NULLS FIRST`;
|
|
3745
|
+
return nullsLast ? sql`${key.target} DESC NULLS LAST` : sql`${key.target} DESC NULLS FIRST`;
|
|
3746
|
+
});
|
|
3747
|
+
if (tieBreakOnId) expressions.push(desc(idField));
|
|
3437
3748
|
return expressions;
|
|
3438
3749
|
}
|
|
3439
3750
|
resolveOrderByField(table, orderBy, collection) {
|
|
@@ -3510,6 +3821,240 @@ var FetchService = class FetchService {
|
|
|
3510
3821
|
return null;
|
|
3511
3822
|
}
|
|
3512
3823
|
/**
|
|
3824
|
+
* The relations one level of an include tree names, resolved.
|
|
3825
|
+
*
|
|
3826
|
+
* A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
|
|
3827
|
+
* be dropped silently, which answers 200 with the field missing — and a
|
|
3828
|
+
* missing relation field is indistinguishable from a row that genuinely has
|
|
3829
|
+
* no related row, so a typo in an `include` looked like empty data.
|
|
3830
|
+
*/
|
|
3831
|
+
resolveIncludeLevel(collection, tree, wildcard) {
|
|
3832
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3833
|
+
const out = [];
|
|
3834
|
+
if (wildcard) for (const [key, relation] of Object.entries(resolvedRelations)) out.push({
|
|
3835
|
+
key,
|
|
3836
|
+
node: tree[key] ?? { children: {} },
|
|
3837
|
+
relation
|
|
3838
|
+
});
|
|
3839
|
+
for (const [key, node] of Object.entries(tree)) {
|
|
3840
|
+
if (wildcard && key in resolvedRelations) continue;
|
|
3841
|
+
const relation = resolvedRelations[key];
|
|
3842
|
+
if (!relation) {
|
|
3843
|
+
const known = Object.keys(resolvedRelations).sort();
|
|
3844
|
+
throw ApiError.badRequest(`Unknown relation '${key}' on collection '${collection.slug ?? collection.name}'` + (known.length > 0 ? `. Its relations are: ${known.join(", ")}` : ". It declares no relations."), "UNKNOWN_RELATION", {
|
|
3845
|
+
relation: key,
|
|
3846
|
+
collection: collection.slug ?? collection.name,
|
|
3847
|
+
...known.length > 0 && { validRelations: known }
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
out.push({
|
|
3851
|
+
key,
|
|
3852
|
+
node,
|
|
3853
|
+
relation
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
return out;
|
|
3857
|
+
}
|
|
3858
|
+
/**
|
|
3859
|
+
* The extra `WHERE` an include's own `where`/`logical` puts on the target.
|
|
3860
|
+
*
|
|
3861
|
+
* Pushed into the batch query rather than applied to the rows it returns:
|
|
3862
|
+
* filtering afterwards reads every related row of every parent to throw
|
|
3863
|
+
* most of them away, and on a to-many relation that is the whole table.
|
|
3864
|
+
*/
|
|
3865
|
+
includeNarrowing(node, targetCollection) {
|
|
3866
|
+
if (!node.where && !node.logical) return void 0;
|
|
3867
|
+
const targetPath = targetCollection.slug;
|
|
3868
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
3869
|
+
const conditions = [];
|
|
3870
|
+
if (node.where) conditions.push(...this.buildFilterConditions(node.where, targetTable, targetPath));
|
|
3871
|
+
if (node.logical) {
|
|
3872
|
+
const logical = DrizzleConditionBuilder.buildLogicalConditions(node.logical, targetTable, targetPath, this.filterContext(targetPath, targetTable));
|
|
3873
|
+
if (logical) conditions.push(logical);
|
|
3874
|
+
}
|
|
3875
|
+
return conditions.length > 0 ? DrizzleConditionBuilder.combineConditionsWithAnd(conditions) : void 0;
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Sort, cap and project the rows of ONE to-many relation, per parent.
|
|
3879
|
+
*
|
|
3880
|
+
* `orderBy` and `limit` are applied here rather than in SQL because a limit
|
|
3881
|
+
* on a batched relation load is *per parent*, and expressing that needs a
|
|
3882
|
+
* LATERAL or a window function per relation kind — four of them, each with
|
|
3883
|
+
* its own join shape. The rows were already narrowed by
|
|
3884
|
+
* {@link includeNarrowing} in SQL, so what this sorts and cuts is the set
|
|
3885
|
+
* the caller asked for and not the table.
|
|
3886
|
+
*/
|
|
3887
|
+
shapeRelatedRows(rows, node, targetCollection) {
|
|
3888
|
+
let out = rows;
|
|
3889
|
+
if (node.orderBy && node.orderBy.length > 0) {
|
|
3890
|
+
const keys = node.orderBy;
|
|
3891
|
+
out = [...out].sort((a, b) => {
|
|
3892
|
+
for (const [field, direction, nulls] of keys) {
|
|
3893
|
+
const cmp = compareForSort(a[field], b[field], FetchService.nullsLast({
|
|
3894
|
+
direction,
|
|
3895
|
+
nulls
|
|
3896
|
+
}));
|
|
3897
|
+
if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
|
|
3898
|
+
}
|
|
3899
|
+
return 0;
|
|
3900
|
+
});
|
|
3901
|
+
}
|
|
3902
|
+
if (node.limit !== void 0) out = out.slice(0, node.limit);
|
|
3903
|
+
if (node.fields && node.fields.length > 0) {
|
|
3904
|
+
const keep = new Set(node.fields);
|
|
3905
|
+
for (const pk of getPrimaryKeys(targetCollection, this.registry)) keep.add(pk.fieldName);
|
|
3906
|
+
out = out.map((row) => {
|
|
3907
|
+
const projected = {};
|
|
3908
|
+
for (const [key, value] of Object.entries(row)) if (keep.has(key)) projected[key] = value;
|
|
3909
|
+
return projected;
|
|
3910
|
+
});
|
|
3911
|
+
}
|
|
3912
|
+
return out;
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* Load an include tree onto `rows`, in place, one batched query per
|
|
3916
|
+
* relation per level.
|
|
3917
|
+
*
|
|
3918
|
+
* Recurses into nested includes with the rows it just loaded, so
|
|
3919
|
+
* `comments.author` is two queries for a page of posts rather than one per
|
|
3920
|
+
* comment. The depth bound lives on the normalizer
|
|
3921
|
+
* ({@link MAX_INCLUDE_DEPTH}); this asserts it again because the tree can
|
|
3922
|
+
* also be built in-process, where nothing normalized it.
|
|
3923
|
+
*/
|
|
3924
|
+
async loadIncludes(rows, collection, collectionPath, include, depth = 1) {
|
|
3925
|
+
if (rows.length === 0 || depth > MAX_INCLUDE_DEPTH) return;
|
|
3926
|
+
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
3927
|
+
if (idInfoArray.length === 0) return;
|
|
3928
|
+
const addressOf = (row) => {
|
|
3929
|
+
const address = buildCompositeId$1(row, idInfoArray);
|
|
3930
|
+
return address && address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "") ? address : void 0;
|
|
3931
|
+
};
|
|
3932
|
+
const addressable = rows.filter((row) => addressOf(row) !== void 0);
|
|
3933
|
+
if (addressable.length === 0) return;
|
|
3934
|
+
const rowIds = addressable.map((row) => addressOf(row));
|
|
3935
|
+
const levels = this.resolveIncludeLevel(collection, include.tree, include.wildcard);
|
|
3936
|
+
for (const { key, node, relation } of levels) {
|
|
3937
|
+
const targetCollection = relation.target();
|
|
3938
|
+
const narrow = this.includeNarrowing(node, targetCollection);
|
|
3939
|
+
const loaded = [];
|
|
3940
|
+
try {
|
|
3941
|
+
if (relation.cardinality === "one") {
|
|
3942
|
+
const results = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation, narrow);
|
|
3943
|
+
for (const row of addressable) {
|
|
3944
|
+
const related = results.get(String(addressOf(row)));
|
|
3945
|
+
if (!related) {
|
|
3946
|
+
row[key] = null;
|
|
3947
|
+
continue;
|
|
3948
|
+
}
|
|
3949
|
+
const [shaped] = this.shapeRelatedRows([toRestValues({ ...related.values }, targetCollection)], node, targetCollection);
|
|
3950
|
+
row[key] = shaped;
|
|
3951
|
+
loaded.push(shaped);
|
|
3952
|
+
}
|
|
3953
|
+
} else {
|
|
3954
|
+
const results = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation, narrow);
|
|
3955
|
+
for (const row of addressable) {
|
|
3956
|
+
const related = results.get(String(addressOf(row))) ?? [];
|
|
3957
|
+
const shaped = this.shapeRelatedRows(related.map((e) => toRestValues({ ...e.values }, targetCollection)), node, targetCollection);
|
|
3958
|
+
row[key] = shaped;
|
|
3959
|
+
loaded.push(...shaped);
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
} catch (e) {
|
|
3963
|
+
if (e instanceof ApiError) throw e;
|
|
3964
|
+
if (reachedDatabase(e)) throw e;
|
|
3965
|
+
logger.warn(`[include] Failed to load relation '${key}' on ${collectionPath}`, { error: e });
|
|
3966
|
+
continue;
|
|
3967
|
+
}
|
|
3968
|
+
const children = Object.keys(node.children).length > 0 ? {
|
|
3969
|
+
wildcard: false,
|
|
3970
|
+
tree: node.children
|
|
3971
|
+
} : void 0;
|
|
3972
|
+
if (children && loaded.length > 0) await this.loadIncludes(loaded, targetCollection, targetCollection.slug, children, depth + 1);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
/**
|
|
3976
|
+
* The SELECT list for a read: the visible columns, narrowed to `fields`.
|
|
3977
|
+
*
|
|
3978
|
+
* `?fields=` used to be a trim of the *response* — every column read out of
|
|
3979
|
+
* the database and most of them thrown away in JavaScript. It is a
|
|
3980
|
+
* projection now, so asking for two columns of a wide row reads two
|
|
3981
|
+
* columns; that is also what makes `distinct` mean anything.
|
|
3982
|
+
*
|
|
3983
|
+
* Two things survive a narrowing regardless:
|
|
3984
|
+
*
|
|
3985
|
+
* - the **primary key**, because a row nobody can address cannot be
|
|
3986
|
+
* updated, deleted, or paged past — and `meta.nextCursor` is derived from
|
|
3987
|
+
* it, so a projection without it would silently disable seeking. The one
|
|
3988
|
+
* exception is `distinct`, below;
|
|
3989
|
+
* - the exclusions. `excludeFromApi` and the generated search columns are
|
|
3990
|
+
* removed *after* the narrowing, so naming one in `fields` does not
|
|
3991
|
+
* un-hide it. That was the shape of the `?searchString=` leak: a path
|
|
3992
|
+
* that skipped the projection returned a password hash the plain read
|
|
3993
|
+
* correctly withheld.
|
|
3994
|
+
*
|
|
3995
|
+
* An unknown column name is a 400 rather than a silent omission: a caller
|
|
3996
|
+
* who mistypes `?fields=titel` otherwise gets rows without titles and no
|
|
3997
|
+
* hint why.
|
|
3998
|
+
*
|
|
3999
|
+
* **`distinct` drops the primary key.** Keeping it is what the refusal of
|
|
4000
|
+
* `distinct` beside a search or vector query already describes: a value
|
|
4001
|
+
* that differs on every row makes the whole row distinct by construction,
|
|
4002
|
+
* so the query "would answer 200 having done nothing". A surrogate key does
|
|
4003
|
+
* that more reliably than any score — `?fields=status&distinct=true` came
|
|
4004
|
+
* back with one row per row, every one carrying its `id`. So a distinct
|
|
4005
|
+
* read is a read of the named columns and nothing else. It addresses no
|
|
4006
|
+
* rows, which is the honest shape for one: `cursorFor` finds no key on the
|
|
4007
|
+
* row and issues no cursor, and the result is a set of values rather than a
|
|
4008
|
+
* set of rows to update or delete.
|
|
4009
|
+
*/
|
|
4010
|
+
columnProjection(table, collection, fields, idInfoArray, distinct) {
|
|
4011
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
4012
|
+
if (!fields || fields.length === 0) return visible;
|
|
4013
|
+
let tableColumns;
|
|
4014
|
+
try {
|
|
4015
|
+
tableColumns = getTableColumns(table);
|
|
4016
|
+
} catch {
|
|
4017
|
+
return visible;
|
|
4018
|
+
}
|
|
4019
|
+
const available = visible ?? tableColumns;
|
|
4020
|
+
const keep = new Set(fields);
|
|
4021
|
+
if (!distinct) for (const pk of idInfoArray) keep.add(pk.fieldName);
|
|
4022
|
+
const projection = {};
|
|
4023
|
+
for (const name of keep) {
|
|
4024
|
+
const column = available[name];
|
|
4025
|
+
if (column !== void 0) {
|
|
4026
|
+
projection[name] = column;
|
|
4027
|
+
continue;
|
|
4028
|
+
}
|
|
4029
|
+
if (tableColumns[name] !== void 0) continue;
|
|
4030
|
+
throw ApiError.badRequest(`Unknown field '${name}' in \`fields\` on collection '${collection.slug ?? collection.name}'. Valid fields: ` + Object.keys(available).sort().join(", "), "UNKNOWN_FIELD", {
|
|
4031
|
+
field: name,
|
|
4032
|
+
collection: collection.slug ?? collection.name
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
return projection;
|
|
4036
|
+
}
|
|
4037
|
+
/**
|
|
4038
|
+
* The opaque cursor that continues a listing after `row`. See
|
|
4039
|
+
* {@link RestFetchService.cursorFor}.
|
|
4040
|
+
*
|
|
4041
|
+
* Here rather than at the route because deriving it needs the collection's
|
|
4042
|
+
* primary key, which may be named anything and span several columns — the
|
|
4043
|
+
* driver's knowledge, not the HTTP layer's.
|
|
4044
|
+
*/
|
|
4045
|
+
cursorFor(collectionPath, row, orderBy) {
|
|
4046
|
+
if (orderBy?.some(([field]) => field === FetchService.SCORE_FIELD)) return void 0;
|
|
4047
|
+
try {
|
|
4048
|
+
const pks = getPrimaryKeys(getCollectionByPath(collectionPath, this.registry), this.registry);
|
|
4049
|
+
if (pks.length === 0) return void 0;
|
|
4050
|
+
const address = buildCompositeId$1(row, pks);
|
|
4051
|
+
if (!address || !address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "")) return void 0;
|
|
4052
|
+
return encodeCursor(orderBy, row, row[pks[0].fieldName]);
|
|
4053
|
+
} catch {
|
|
4054
|
+
return;
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
/**
|
|
3513
4058
|
* Post-fetch joinPath relations for a single flat row.
|
|
3514
4059
|
* joinPath relations cannot be expressed via Drizzle's `with` config,
|
|
3515
4060
|
* so they must be loaded separately after the primary query.
|
|
@@ -3531,81 +4076,6 @@ var FetchService = class FetchService {
|
|
|
3531
4076
|
await Promise.all(promises);
|
|
3532
4077
|
}
|
|
3533
4078
|
/**
|
|
3534
|
-
* Resolves joinPath relations for raw REST rows and directly injects them.
|
|
3535
|
-
* Uses RelationService to query the database and maps results back to the flattened objects.
|
|
3536
|
-
*/
|
|
3537
|
-
async resolveJoinPathRelationsBatchRest(rows, collection, collectionPath, idInfoArray, include) {
|
|
3538
|
-
if (rows.length === 0) return;
|
|
3539
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3540
|
-
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
3541
|
-
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
3542
|
-
const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
|
|
3543
|
-
if (joinPathRelations.length === 0) return;
|
|
3544
|
-
const parentIdOf = (row) => {
|
|
3545
|
-
const address = buildCompositeId$1(row, idInfoArray);
|
|
3546
|
-
return address && address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "") ? address : void 0;
|
|
3547
|
-
};
|
|
3548
|
-
for (const [key, relation] of joinPathRelations) try {
|
|
3549
|
-
const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
|
|
3550
|
-
if (addressable.length === 0) continue;
|
|
3551
|
-
const rowIds = addressable.map((r) => parentIdOf(r));
|
|
3552
|
-
if (relation.cardinality === "one") {
|
|
3553
|
-
const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
3554
|
-
for (const row of addressable) {
|
|
3555
|
-
const relatedRow = resultMap.get(String(parentIdOf(row)));
|
|
3556
|
-
row[key] = relatedRow ? { ...relatedRow.values } : null;
|
|
3557
|
-
}
|
|
3558
|
-
} else if (relation.cardinality === "many") {
|
|
3559
|
-
const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
3560
|
-
for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
|
|
3561
|
-
}
|
|
3562
|
-
} catch (e) {
|
|
3563
|
-
if (reachedDatabase(e)) throw e;
|
|
3564
|
-
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
3565
|
-
}
|
|
3566
|
-
}
|
|
3567
|
-
/**
|
|
3568
|
-
* Build db.query-compatible options from standard fetch options.
|
|
3569
|
-
* Handles filter, search, orderBy, limit, and cursor-based pagination.
|
|
3570
|
-
*/
|
|
3571
|
-
buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
|
|
3572
|
-
const queryOpts = {};
|
|
3573
|
-
const hidden = hiddenColumnsOption(getTableColumns(table), this.registry.getCollectionByPath(collectionPath) ?? void 0);
|
|
3574
|
-
if (hidden) queryOpts.columns = hidden;
|
|
3575
|
-
if (withConfig) queryOpts.with = withConfig;
|
|
3576
|
-
const allConditions = [];
|
|
3577
|
-
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3578
|
-
if (options.searchString) {
|
|
3579
|
-
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3580
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3581
|
-
if (searchConditions.length === 0) {
|
|
3582
|
-
queryOpts.where = and(eq(idField, -99999999));
|
|
3583
|
-
return queryOpts;
|
|
3584
|
-
}
|
|
3585
|
-
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3586
|
-
}
|
|
3587
|
-
if (options.filter) {
|
|
3588
|
-
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3589
|
-
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3590
|
-
}
|
|
3591
|
-
if (options.logical) {
|
|
3592
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3593
|
-
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3594
|
-
}
|
|
3595
|
-
if (options.startAfter) {
|
|
3596
|
-
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
3597
|
-
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
3598
|
-
}
|
|
3599
|
-
if (allConditions.length > 0) queryOpts.where = and(...allConditions);
|
|
3600
|
-
const orderKeys = normalizeDriverOrderBy(options.orderBy, options.order);
|
|
3601
|
-
const resolvedOrder = orderKeys ? this.resolveOrderKeys(table, orderKeys, getCollectionByPath(collectionPath, this.registry), options.searchString) : [];
|
|
3602
|
-
queryOpts.orderBy = this.buildOrderExpressions(resolvedOrder, idField);
|
|
3603
|
-
const limitValue = options.searchString ? options.limit || 50 : options.limit;
|
|
3604
|
-
if (limitValue) queryOpts.limit = limitValue;
|
|
3605
|
-
if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
|
|
3606
|
-
return queryOpts;
|
|
3607
|
-
}
|
|
3608
|
-
/**
|
|
3609
4079
|
* Extract cursor pagination conditions from startAfter options.
|
|
3610
4080
|
*
|
|
3611
4081
|
* "Every row that sorts after this one", written out as a comparison over
|
|
@@ -3649,12 +4119,15 @@ var FetchService = class FetchService {
|
|
|
3649
4119
|
buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
|
|
3650
4120
|
if (index >= keys.length) return lt(idField, cursorId);
|
|
3651
4121
|
const { direction, cursorTarget } = keys[index];
|
|
4122
|
+
const nullsLast = FetchService.nullsLast(keys[index]);
|
|
3652
4123
|
const target = keys[index].target;
|
|
3653
4124
|
const value = values[index];
|
|
3654
4125
|
const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
4126
|
+
const after = (a, b) => direction === "asc" ? gt(a, b) : lt(a, b);
|
|
4127
|
+
const afterSql = (a, b) => direction === "asc" ? sql`${a} > ${b}` : sql`${a} < ${b}`;
|
|
4128
|
+
if (cursorTarget) return nullsLast ? or(and(isNull(cursorTarget), isNull(target), rest), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), isNull(target), and(sql`${target} = ${cursorTarget}`, rest)))) : or(and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), and(sql`${target} = ${cursorTarget}`, rest))));
|
|
4129
|
+
if (value === null) return nullsLast ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
|
|
4130
|
+
return nullsLast ? or(after(target, value), isNull(target), and(eq(target, value), rest)) : or(after(target, value), and(eq(target, value), rest));
|
|
3658
4131
|
}
|
|
3659
4132
|
/**
|
|
3660
4133
|
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
@@ -3698,7 +4171,7 @@ var FetchService = class FetchService {
|
|
|
3698
4171
|
/**
|
|
3699
4172
|
* Fetch a single row by ID
|
|
3700
4173
|
*/
|
|
3701
|
-
async fetchOne(collectionPath, id, databaseId) {
|
|
4174
|
+
async fetchOne(collectionPath, id, databaseId, withDeleted) {
|
|
3702
4175
|
if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
|
|
3703
4176
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3704
4177
|
const table = getTableForCollection(collection, this.registry);
|
|
@@ -3714,7 +4187,7 @@ var FetchService = class FetchService {
|
|
|
3714
4187
|
const withConfig = this.buildWithConfig(collection);
|
|
3715
4188
|
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
3716
4189
|
const row = await qb.findFirst({
|
|
3717
|
-
where: eq(idField, parsedId),
|
|
4190
|
+
where: andSoftDelete(eq(idField, parsedId), collection, table, withDeleted),
|
|
3718
4191
|
with: withConfig,
|
|
3719
4192
|
...hidden ? { columns: hidden } : {}
|
|
3720
4193
|
});
|
|
@@ -3731,7 +4204,7 @@ var FetchService = class FetchService {
|
|
|
3731
4204
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
3732
4205
|
}
|
|
3733
4206
|
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
3734
|
-
const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
|
|
4207
|
+
const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
|
|
3735
4208
|
if (result.length === 0) return void 0;
|
|
3736
4209
|
const raw = result[0];
|
|
3737
4210
|
const values = await parseDataFromServer(raw, collection, this.db, this.registry);
|
|
@@ -3764,84 +4237,13 @@ var FetchService = class FetchService {
|
|
|
3764
4237
|
* Unified method to fetch rows with optional search functionality
|
|
3765
4238
|
*/
|
|
3766
4239
|
async fetchRowsWithConditions(collectionPath, options = {}) {
|
|
3767
|
-
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
|
|
3768
4240
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3769
4241
|
const table = getTableForCollection(collection, this.registry);
|
|
3770
4242
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
3771
4243
|
const idInfo = idInfoArray[0];
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
const qb = this.getQueryBuilder(tableName);
|
|
3776
|
-
const withConfig = this.buildWithConfig(collection);
|
|
3777
|
-
const hasRelations = withConfig && Object.keys(withConfig).length > 0;
|
|
3778
|
-
if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
|
|
3779
|
-
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
|
|
3780
|
-
return (await qb.findMany(queryOpts)).map((row) => toFlatRow(row, collection, this.registry));
|
|
3781
|
-
} catch (e) {
|
|
3782
|
-
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
3783
|
-
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
3784
|
-
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
3785
|
-
}
|
|
3786
|
-
if (reachedDatabase(e)) throw e;
|
|
3787
|
-
logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
3788
|
-
}
|
|
3789
|
-
let vectorMeta;
|
|
3790
|
-
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3791
|
-
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
3792
|
-
const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
|
|
3793
|
-
const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
|
|
3794
|
-
let query = vectorMeta ? this.db.select({
|
|
3795
|
-
table_row: visible ?? table,
|
|
3796
|
-
_distance: vectorMeta.distanceSelect
|
|
3797
|
-
}).from(table).$dynamic() : rankSelect ? this.db.select({
|
|
3798
|
-
table_row: visible ?? table,
|
|
3799
|
-
_score: rankSelect,
|
|
3800
|
-
...matchesSelect ? { _matches: matchesSelect } : {}
|
|
3801
|
-
}).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
|
|
3802
|
-
const allConditions = [];
|
|
3803
|
-
if (scopeCondition) allConditions.push(scopeCondition);
|
|
3804
|
-
if (options.searchString) {
|
|
3805
|
-
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3806
|
-
if (searchConditions.length === 0) return [];
|
|
3807
|
-
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3808
|
-
}
|
|
3809
|
-
if (options.filter) {
|
|
3810
|
-
const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
|
|
3811
|
-
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
3812
|
-
}
|
|
3813
|
-
if (options.logical) {
|
|
3814
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3815
|
-
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3816
|
-
}
|
|
3817
|
-
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
3818
|
-
if (allConditions.length > 0) {
|
|
3819
|
-
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3820
|
-
if (finalCondition) query = query.where(finalCondition);
|
|
3821
|
-
}
|
|
3822
|
-
const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
|
|
3823
|
-
query = query.orderBy(...orderExpressions);
|
|
3824
|
-
if (options.startAfter) {
|
|
3825
|
-
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
3826
|
-
if (cursorConditions.length > 0) {
|
|
3827
|
-
allConditions.push(...cursorConditions);
|
|
3828
|
-
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3829
|
-
if (finalCondition) query = query.where(finalCondition);
|
|
3830
|
-
}
|
|
3831
|
-
}
|
|
3832
|
-
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
3833
|
-
if (limitValue) query = query.limit(limitValue);
|
|
3834
|
-
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
3835
|
-
const rawResults = await query;
|
|
3836
|
-
const results = vectorMeta ? rawResults.map((r) => ({
|
|
3837
|
-
...r.table_row,
|
|
3838
|
-
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
3839
|
-
})) : rankSelect ? rawResults.map((r) => ({
|
|
3840
|
-
...r.table_row,
|
|
3841
|
-
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
3842
|
-
...matchesSelect ? { _matches: r._matches ?? [] } : {}
|
|
3843
|
-
})) : rawResults;
|
|
3844
|
-
return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
4244
|
+
if (!table[idInfo.fieldName]) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
4245
|
+
const results = await this.fetchRowsWithConditionsRaw(collectionPath, options);
|
|
4246
|
+
return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, normalizeInclude(options.include), idInfoArray);
|
|
3845
4247
|
}
|
|
3846
4248
|
/**
|
|
3847
4249
|
* Fallback path used when db.query is unavailable.
|
|
@@ -3852,54 +4254,11 @@ var FetchService = class FetchService {
|
|
|
3852
4254
|
*
|
|
3853
4255
|
* Process raw database results into flat rows with relations.
|
|
3854
4256
|
*/
|
|
3855
|
-
async processRowResults(results, collection, collectionPath, idInfo, _databaseId,
|
|
4257
|
+
async processRowResults(results, collection, collectionPath, idInfo, _databaseId, include, _idInfoArray) {
|
|
3856
4258
|
if (results.length === 0) return [];
|
|
3857
|
-
const
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
values: await parseDataFromServer(rawRow, collection)
|
|
3861
|
-
};
|
|
3862
|
-
}));
|
|
3863
|
-
if (!skipRelations) {
|
|
3864
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3865
|
-
const propertyKeys = new Set(Object.keys(collection.properties));
|
|
3866
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
3867
|
-
if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
|
|
3868
|
-
const rowsMissingRelation = parsedRows.filter((item) => {
|
|
3869
|
-
const val = item.values[key];
|
|
3870
|
-
if (val == null) return true;
|
|
3871
|
-
if (typeof val === "object" && !Array.isArray(val) && val.__type === "relation" && val.data == null) return true;
|
|
3872
|
-
return false;
|
|
3873
|
-
});
|
|
3874
|
-
if (rowsMissingRelation.length === 0) continue;
|
|
3875
|
-
try {
|
|
3876
|
-
const rowIds = rowsMissingRelation.map((item) => item.rawRow[idInfo.fieldName]);
|
|
3877
|
-
const relationResults = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
3878
|
-
rowsMissingRelation.forEach((item) => {
|
|
3879
|
-
const id = item.rawRow[idInfo.fieldName];
|
|
3880
|
-
const relatedRow = relationResults.get(String(id));
|
|
3881
|
-
if (relatedRow) item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
|
|
3882
|
-
});
|
|
3883
|
-
} catch (e) {
|
|
3884
|
-
if (reachedDatabase(e)) throw e;
|
|
3885
|
-
logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
|
|
3886
|
-
}
|
|
3887
|
-
}
|
|
3888
|
-
const manyRelations = Object.entries(resolvedRelations).filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many");
|
|
3889
|
-
for (const [key, relation] of manyRelations) try {
|
|
3890
|
-
const rowIds = parsedRows.map((item) => item.rawRow[idInfo.fieldName]);
|
|
3891
|
-
const relationResults = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
|
|
3892
|
-
parsedRows.forEach((item) => {
|
|
3893
|
-
const id = String(item.rawRow[idInfo.fieldName]);
|
|
3894
|
-
const relatedRows = relationResults.get(id) || [];
|
|
3895
|
-
item.values[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
|
|
3896
|
-
});
|
|
3897
|
-
} catch (e) {
|
|
3898
|
-
if (reachedDatabase(e)) throw e;
|
|
3899
|
-
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
3900
|
-
}
|
|
3901
|
-
}
|
|
3902
|
-
return parsedRows.map((item) => item.values);
|
|
4259
|
+
const values = await Promise.all(results.map(async (rawRow) => await parseDataFromServer(rawRow, collection)));
|
|
4260
|
+
if (include) await this.loadIncludes(values, collection, collectionPath, include);
|
|
4261
|
+
return values;
|
|
3903
4262
|
}
|
|
3904
4263
|
/**
|
|
3905
4264
|
* Fetch a collection of rows
|
|
@@ -3932,6 +4291,7 @@ var FetchService = class FetchService {
|
|
|
3932
4291
|
let query = this.db.select({ count: count() }).from(table).$dynamic();
|
|
3933
4292
|
const allConditions = [];
|
|
3934
4293
|
if (hop) allConditions.push(this.buildRelationScope(hop));
|
|
4294
|
+
withSoftDelete(allConditions, collection, table, options.withDeleted);
|
|
3935
4295
|
if (options.searchString) {
|
|
3936
4296
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3937
4297
|
if (searchConditions.length === 0) return 0;
|
|
@@ -4012,6 +4372,7 @@ var FetchService = class FetchService {
|
|
|
4012
4372
|
for (const group of groupColumns) selection[group.field] = sql`${group.column}`;
|
|
4013
4373
|
let query = this.db.select(selection).from(table).$dynamic();
|
|
4014
4374
|
const conditions = [];
|
|
4375
|
+
withSoftDelete(conditions, collection, table, options.withDeleted);
|
|
4015
4376
|
if (options.searchString) {
|
|
4016
4377
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
4017
4378
|
if (searchConditions.length === 0) return [];
|
|
@@ -4066,12 +4427,24 @@ var FetchService = class FetchService {
|
|
|
4066
4427
|
return this.relationService;
|
|
4067
4428
|
}
|
|
4068
4429
|
/**
|
|
4069
|
-
* Fetch a collection of rows with
|
|
4070
|
-
*
|
|
4071
|
-
*
|
|
4072
|
-
* When `include` is absent, no relation queries are made (fast path).
|
|
4430
|
+
* Fetch a collection of rows, with the relations `include` names.
|
|
4431
|
+
*
|
|
4432
|
+
* ## Why this no longer uses `db.query.findMany({ with })`
|
|
4073
4433
|
*
|
|
4074
|
-
*
|
|
4434
|
+
* It used to, whenever a `db.query` builder existed for the table — which
|
|
4435
|
+
* is Drizzle's relational API, and which compiles a to-many relation into a
|
|
4436
|
+
* lateral join. This file's own comment on the *other* read path says what
|
|
4437
|
+
* that costs: "catastrophically slow for large collections (7s+ for 350
|
|
4438
|
+
* rows)". So the admin fetch avoided it and REST took it, and the fast,
|
|
4439
|
+
* batched loader that the admin used was unreachable from the HTTP API.
|
|
4440
|
+
*
|
|
4441
|
+
* There is one loader now, {@link loadIncludes}, and every read reaches it:
|
|
4442
|
+
* one query per relation per level, never one per row. That also makes this
|
|
4443
|
+
* method and the realtime refetch the same code, which is what stops
|
|
4444
|
+
* `find()` and `listen()` returning different shapes for one query.
|
|
4445
|
+
*
|
|
4446
|
+
* @param include - see {@link IncludeSpec}: names, dotted paths, `["*"]`,
|
|
4447
|
+
* or the parametrised tree. An unknown name is a 400 `UNKNOWN_RELATION`.
|
|
4075
4448
|
*/
|
|
4076
4449
|
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
4077
4450
|
if (isNestedPath(collectionPath)) {
|
|
@@ -4081,67 +4454,24 @@ var FetchService = class FetchService {
|
|
|
4081
4454
|
relatedTo: hop
|
|
4082
4455
|
}, include);
|
|
4083
4456
|
}
|
|
4084
|
-
const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
|
|
4085
4457
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4086
|
-
const
|
|
4087
|
-
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4088
|
-
const idInfo = idInfoArray[0];
|
|
4089
|
-
const idField = table[idInfo.fieldName];
|
|
4090
|
-
const tableName = getTableName(table);
|
|
4091
|
-
const qb = this.getQueryBuilder(tableName);
|
|
4092
|
-
if (qb && !options.searchString && !options.vectorSearch) try {
|
|
4093
|
-
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
4094
|
-
const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
|
|
4095
|
-
const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
|
|
4096
|
-
await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
|
|
4097
|
-
return restRows;
|
|
4098
|
-
} catch (e) {
|
|
4099
|
-
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
4100
|
-
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
4101
|
-
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
4102
|
-
}
|
|
4103
|
-
if (reachedDatabase(e)) throw e;
|
|
4104
|
-
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
4105
|
-
}
|
|
4458
|
+
const normalizedInclude = normalizeInclude(include);
|
|
4106
4459
|
const rows = (await this.fetchRowsWithConditionsRaw(collectionPath, options)).map((row) => toRestRow(row, collection, this.registry));
|
|
4107
|
-
if (
|
|
4108
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
4109
|
-
const propertyKeys = new Set(Object.keys(collection.properties || {}));
|
|
4110
|
-
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
4111
|
-
const rowIds = rows.map((e) => e[idInfo.fieldName]);
|
|
4112
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
4113
|
-
if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
|
|
4114
|
-
try {
|
|
4115
|
-
const batchResults = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
|
|
4116
|
-
for (const row of rows) {
|
|
4117
|
-
const eid = row[idInfo.fieldName];
|
|
4118
|
-
const related = batchResults.get(String(eid));
|
|
4119
|
-
if (related) row[key] = { ...related.values };
|
|
4120
|
-
}
|
|
4121
|
-
} catch (e) {
|
|
4122
|
-
if (reachedDatabase(e)) throw e;
|
|
4123
|
-
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
4124
|
-
}
|
|
4125
|
-
}
|
|
4126
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
4127
|
-
if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
|
|
4128
|
-
try {
|
|
4129
|
-
const batchResults = await this.batchFetchManyRelatedRows(collectionPath, rowIds, key);
|
|
4130
|
-
for (const row of rows) {
|
|
4131
|
-
const eid = row[idInfo.fieldName];
|
|
4132
|
-
row[key] = batchResults.get(String(eid)) || [];
|
|
4133
|
-
}
|
|
4134
|
-
} catch (e) {
|
|
4135
|
-
if (reachedDatabase(e)) throw e;
|
|
4136
|
-
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
4137
|
-
}
|
|
4138
|
-
}
|
|
4460
|
+
if (normalizedInclude) await this.loadIncludes(rows, collection, collectionPath, normalizedInclude);
|
|
4139
4461
|
return rows;
|
|
4140
4462
|
}
|
|
4141
4463
|
/**
|
|
4142
|
-
* Fetch a single row with
|
|
4464
|
+
* Fetch a single row, with the relations `include` names.
|
|
4465
|
+
*
|
|
4466
|
+
* The same two steps `fetchCollectionForRest` takes, for one row, through
|
|
4467
|
+
* the same {@link loadIncludes}. It used to be a separate implementation on
|
|
4468
|
+
* `db.query.findFirst({ with })` with a hand-written N+1 fallback beneath
|
|
4469
|
+
* it, and the two disagreed: the primary path returned a to-one relation as
|
|
4470
|
+
* the target's columns, the fallback merged an `id` over them, and a
|
|
4471
|
+
* relation that resolved to nothing was *absent* on one path and `null` on
|
|
4472
|
+
* the other. `find()[0]` and `findById()` now answer with the same row.
|
|
4143
4473
|
*/
|
|
4144
|
-
async fetchOneForRest(collectionPath, id, include, databaseId) {
|
|
4474
|
+
async fetchOneForRest(collectionPath, id, include, databaseId, options) {
|
|
4145
4475
|
if (!await this.isAddressableUnder(collectionPath, id)) return null;
|
|
4146
4476
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4147
4477
|
const table = getTableForCollection(collection, this.registry);
|
|
@@ -4150,56 +4480,14 @@ var FetchService = class FetchService {
|
|
|
4150
4480
|
const idField = table[idInfo.fieldName];
|
|
4151
4481
|
if (!idCanAddressTable(id, table, idInfoArray)) return null;
|
|
4152
4482
|
const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
|
|
4153
|
-
const
|
|
4154
|
-
const
|
|
4155
|
-
|
|
4156
|
-
const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
|
|
4157
|
-
const row = await qb.findFirst({
|
|
4158
|
-
where: eq(idField, parsedId),
|
|
4159
|
-
...withConfig ? { with: withConfig } : {}
|
|
4160
|
-
});
|
|
4161
|
-
if (!row) return null;
|
|
4162
|
-
const restRow = toRestRow(row, collection, this.registry);
|
|
4163
|
-
await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
|
|
4164
|
-
return restRow;
|
|
4165
|
-
} catch (e) {
|
|
4166
|
-
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
4167
|
-
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
4168
|
-
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
4169
|
-
}
|
|
4170
|
-
if (reachedDatabase(e)) throw e;
|
|
4171
|
-
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
4172
|
-
}
|
|
4173
|
-
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
4174
|
-
const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
|
|
4483
|
+
const withDeleted = options?.withDeleted;
|
|
4484
|
+
const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
|
|
4485
|
+
const result = await this.db.select(projection).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
|
|
4175
4486
|
if (result.length === 0) return null;
|
|
4176
|
-
const
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
const shouldInclude = (key) => include[0] === "*" || include.includes(key);
|
|
4181
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
4182
|
-
if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
|
|
4183
|
-
try {
|
|
4184
|
-
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, {});
|
|
4185
|
-
if (relation.cardinality === "one") {
|
|
4186
|
-
if (relatedRows.length > 0) {
|
|
4187
|
-
const e = relatedRows[0];
|
|
4188
|
-
flatEntity[key] = {
|
|
4189
|
-
id: e.id,
|
|
4190
|
-
...e.values
|
|
4191
|
-
};
|
|
4192
|
-
}
|
|
4193
|
-
} else flatEntity[key] = relatedRows.map((e) => ({
|
|
4194
|
-
id: e.id,
|
|
4195
|
-
...e.values
|
|
4196
|
-
}));
|
|
4197
|
-
} catch (e) {
|
|
4198
|
-
if (reachedDatabase(e)) throw e;
|
|
4199
|
-
logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
|
|
4200
|
-
}
|
|
4201
|
-
}
|
|
4202
|
-
return flatEntity;
|
|
4487
|
+
const row = toRestRow(result[0], collection, this.registry);
|
|
4488
|
+
const normalizedInclude = normalizeInclude(include);
|
|
4489
|
+
if (normalizedInclude) await this.loadIncludes([row], collection, collectionPath, normalizedInclude);
|
|
4490
|
+
return row;
|
|
4203
4491
|
}
|
|
4204
4492
|
/**
|
|
4205
4493
|
* Fetch raw rows without any relation processing (for REST fast path)
|
|
@@ -4207,12 +4495,20 @@ var FetchService = class FetchService {
|
|
|
4207
4495
|
async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
|
|
4208
4496
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4209
4497
|
const table = getTableForCollection(collection, this.registry);
|
|
4210
|
-
const
|
|
4498
|
+
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4499
|
+
const idInfo = idInfoArray[0];
|
|
4500
|
+
const idField = table[idInfo.fieldName];
|
|
4211
4501
|
let vectorMeta;
|
|
4212
4502
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
4213
|
-
const visible =
|
|
4503
|
+
const visible = this.columnProjection(table, collection, options.fields, idInfoArray, options.distinct === true);
|
|
4214
4504
|
const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
|
|
4215
4505
|
const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
|
|
4506
|
+
const wantsDistinct = options.distinct === true;
|
|
4507
|
+
if (wantsDistinct && (vectorMeta || rankSelect)) throw ApiError.badRequest("`distinct` cannot be combined with a search or vector query: both attach a per-row score to every row, so no two rows are ever equal and DISTINCT would have no effect. Drop one of the two.", "DISTINCT_NOT_APPLICABLE");
|
|
4508
|
+
const selectFrom = () => {
|
|
4509
|
+
const builder = wantsDistinct ? this.db.selectDistinct.bind(this.db) : this.db.select.bind(this.db);
|
|
4510
|
+
return visible ? builder(visible).from(table).$dynamic() : builder().from(table).$dynamic();
|
|
4511
|
+
};
|
|
4216
4512
|
let query = vectorMeta ? this.db.select({
|
|
4217
4513
|
table_row: visible ?? table,
|
|
4218
4514
|
_distance: vectorMeta.distanceSelect
|
|
@@ -4220,9 +4516,10 @@ var FetchService = class FetchService {
|
|
|
4220
4516
|
table_row: visible ?? table,
|
|
4221
4517
|
_score: rankSelect,
|
|
4222
4518
|
...matchesSelect ? { _matches: matchesSelect } : {}
|
|
4223
|
-
}).from(table).$dynamic() :
|
|
4519
|
+
}).from(table).$dynamic() : selectFrom();
|
|
4224
4520
|
const allConditions = [];
|
|
4225
4521
|
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
4522
|
+
withSoftDelete(allConditions, collection, table, options.withDeleted);
|
|
4226
4523
|
if (options.searchString) {
|
|
4227
4524
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
4228
4525
|
if (searchConditions.length === 0) return [];
|
|
@@ -4241,8 +4538,22 @@ var FetchService = class FetchService {
|
|
|
4241
4538
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
4242
4539
|
if (finalCondition) query = query.where(finalCondition);
|
|
4243
4540
|
}
|
|
4244
|
-
const
|
|
4245
|
-
|
|
4541
|
+
const sortKeys = normalizeDriverOrderBy(options.orderBy, options.order) ?? [];
|
|
4542
|
+
if (wantsDistinct && visible) {
|
|
4543
|
+
const selected = new Set(Object.keys(visible));
|
|
4544
|
+
const missing = sortKeys.map(([field]) => field).filter((field) => !selected.has(field));
|
|
4545
|
+
if (missing.length > 0) throw ApiError.badRequest(`\`distinct\` cannot sort by ${missing.map((f) => `'${f}'`).join(", ")}: a DISTINCT read can only be ordered by columns it returns, or the rows it collapses have no defined order. Add ${missing.map((f) => `'${f}'`).join(", ")} to \`fields\`, or drop them from \`orderBy\`.`, "DISTINCT_ORDER_BY_NOT_SELECTED", { fields: missing });
|
|
4546
|
+
}
|
|
4547
|
+
const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, sortKeys, collection, options.searchString), idField, !wantsDistinct);
|
|
4548
|
+
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
4549
|
+
if (options.startAfter) {
|
|
4550
|
+
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
4551
|
+
if (cursorConditions.length > 0) {
|
|
4552
|
+
allConditions.push(...cursorConditions);
|
|
4553
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
4554
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4246
4557
|
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
4247
4558
|
if (limitValue) query = query.limit(limitValue);
|
|
4248
4559
|
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
@@ -4258,39 +4569,6 @@ var FetchService = class FetchService {
|
|
|
4258
4569
|
}));
|
|
4259
4570
|
return rawResults;
|
|
4260
4571
|
}
|
|
4261
|
-
/**
|
|
4262
|
-
* Check if the Drizzle instance has the relational query API available
|
|
4263
|
-
* for a given collection path.
|
|
4264
|
-
* Note: Primary path now uses inline `getQueryBuilder()` checks.
|
|
4265
|
-
*/
|
|
4266
|
-
hasDrizzleQueryAPI(collectionPath) {
|
|
4267
|
-
if (!this.getQueryBuilder("__probe__")) return false;
|
|
4268
|
-
const tableName = getTableName(getTableForCollection(getCollectionByPath(collectionPath, this.registry), this.registry));
|
|
4269
|
-
return !!this.getQueryBuilder(tableName);
|
|
4270
|
-
}
|
|
4271
|
-
/**
|
|
4272
|
-
* Fallback path used when db.query is unavailable.
|
|
4273
|
-
* The primary path uses db.query.findMany with `with` config, which
|
|
4274
|
-
* loads all relations in a single query.
|
|
4275
|
-
*
|
|
4276
|
-
* Batch fetch many-to-many related rows for multiple parent IDs.
|
|
4277
|
-
* Groups results by parent ID to avoid N+1.
|
|
4278
|
-
*/
|
|
4279
|
-
async batchFetchManyRelatedRows(parentCollectionPath, parentIds, relationKey) {
|
|
4280
|
-
if (parentIds.length === 0) return /* @__PURE__ */ new Map();
|
|
4281
|
-
const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
|
|
4282
|
-
if (!relation) {
|
|
4283
|
-
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
4284
|
-
return /* @__PURE__ */ new Map();
|
|
4285
|
-
}
|
|
4286
|
-
const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
|
|
4287
|
-
const flatMap = /* @__PURE__ */ new Map();
|
|
4288
|
-
for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
|
|
4289
|
-
...e.values,
|
|
4290
|
-
id: e.id
|
|
4291
|
-
})));
|
|
4292
|
-
return flatMap;
|
|
4293
|
-
}
|
|
4294
4572
|
};
|
|
4295
4573
|
//#endregion
|
|
4296
4574
|
//#region src/services/RelationWriteService.ts
|
|
@@ -4556,6 +4834,103 @@ var RelationWriteService = class {
|
|
|
4556
4834
|
}
|
|
4557
4835
|
};
|
|
4558
4836
|
//#endregion
|
|
4837
|
+
//#region src/services/field-op-sql.ts
|
|
4838
|
+
/**
|
|
4839
|
+
* Turning `{ views: { $inc: 1 } }` into the `SET` clause that cannot lose a
|
|
4840
|
+
* concurrent write.
|
|
4841
|
+
*
|
|
4842
|
+
* The contract — which operators exist, what they accept, which property types
|
|
4843
|
+
* they are legal on — lives in `@rebasepro/server`. What is here is the part
|
|
4844
|
+
* only this driver can know: an `array` property is a native `text[]` on one
|
|
4845
|
+
* collection and a jsonb document on the next (see the DDL generator's `array`
|
|
4846
|
+
* arm), and the two need entirely different statements. Compiling from the
|
|
4847
|
+
* property type alone would emit `array_append` against a jsonb column, which
|
|
4848
|
+
* Postgres rejects at plan time with a function-does-not-exist error naming
|
|
4849
|
+
* neither the collection nor the field.
|
|
4850
|
+
*
|
|
4851
|
+
* Every expression is NULL-safe. `NULL + 1` is `NULL`, and a counter that
|
|
4852
|
+
* silently empties itself the first time it is incremented from an unset column
|
|
4853
|
+
* is worse than one that never worked.
|
|
4854
|
+
*
|
|
4855
|
+
* @module
|
|
4856
|
+
*/
|
|
4857
|
+
/** What the column is, as Postgres sees it: `text[]`, `jsonb`, `integer`, … */
|
|
4858
|
+
function sqlTypeOf(column) {
|
|
4859
|
+
try {
|
|
4860
|
+
return String(column.getSQLType()).toLowerCase();
|
|
4861
|
+
} catch {
|
|
4862
|
+
return "";
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4865
|
+
function isNativeArray(type) {
|
|
4866
|
+
return type.endsWith("[]") || type.endsWith(" array");
|
|
4867
|
+
}
|
|
4868
|
+
/** A `json` column needs the result cast back; a `jsonb` one does not. */
|
|
4869
|
+
function isPlainJson(type) {
|
|
4870
|
+
return type === "json";
|
|
4871
|
+
}
|
|
4872
|
+
function asList(operand) {
|
|
4873
|
+
return Array.isArray(operand) ? operand : [operand];
|
|
4874
|
+
}
|
|
4875
|
+
/**
|
|
4876
|
+
* The expression one operation compiles to, or a 400 saying why it cannot.
|
|
4877
|
+
*
|
|
4878
|
+
* `field` is the property key; it is already known to be a column of `table`
|
|
4879
|
+
* (the caller checks that against the same table it builds the statement from).
|
|
4880
|
+
*/
|
|
4881
|
+
function compileFieldOp(table, field, op, context) {
|
|
4882
|
+
const column = table[field];
|
|
4883
|
+
if (!column) throw ApiError.badRequest(`'${field}' is not a column of "${context.collectionPath}", so ${op.operator} has nothing to apply to.`, "INVALID_FIELD_OPERATION");
|
|
4884
|
+
const type = sqlTypeOf(column);
|
|
4885
|
+
switch (op.operator) {
|
|
4886
|
+
case "$inc": return sql`COALESCE(${column}, 0) + ${op.operand}`;
|
|
4887
|
+
case "$push": {
|
|
4888
|
+
const values = asList(op.operand);
|
|
4889
|
+
if (isNativeArray(type)) {
|
|
4890
|
+
let expression = sql`${column}`;
|
|
4891
|
+
for (const value of values) expression = sql`array_append(${expression}, ${value})`;
|
|
4892
|
+
return expression;
|
|
4893
|
+
}
|
|
4894
|
+
if (type.startsWith("json")) {
|
|
4895
|
+
const appended = sql`COALESCE(${column}::jsonb, '[]'::jsonb) || ${JSON.stringify(values)}::jsonb`;
|
|
4896
|
+
return isPlainJson(type) ? sql`(${appended})::json` : appended;
|
|
4897
|
+
}
|
|
4898
|
+
throw unsupportedColumn(field, op.operator, type, context);
|
|
4899
|
+
}
|
|
4900
|
+
case "$pull": {
|
|
4901
|
+
const values = asList(op.operand);
|
|
4902
|
+
if (isNativeArray(type)) {
|
|
4903
|
+
let expression = sql`${column}`;
|
|
4904
|
+
for (const value of values) expression = sql`array_remove(${expression}, ${value})`;
|
|
4905
|
+
return expression;
|
|
4906
|
+
}
|
|
4907
|
+
if (type.startsWith("json")) {
|
|
4908
|
+
const filtered = sql`COALESCE((
|
|
4909
|
+
SELECT jsonb_agg(element)
|
|
4910
|
+
FROM jsonb_array_elements(COALESCE(${column}::jsonb, '[]'::jsonb)) AS element
|
|
4911
|
+
WHERE NOT (element IN (SELECT jsonb_array_elements(${sql`${JSON.stringify(values)}::jsonb`})))
|
|
4912
|
+
), '[]'::jsonb)`;
|
|
4913
|
+
return isPlainJson(type) ? sql`(${filtered})::json` : filtered;
|
|
4914
|
+
}
|
|
4915
|
+
throw unsupportedColumn(field, op.operator, type, context);
|
|
4916
|
+
}
|
|
4917
|
+
case "$merge": {
|
|
4918
|
+
if (!type.startsWith("json")) throw unsupportedColumn(field, op.operator, type, context);
|
|
4919
|
+
const merged = sql`COALESCE(${column}::jsonb, '{}'::jsonb) || ${JSON.stringify(op.operand)}::jsonb`;
|
|
4920
|
+
return isPlainJson(type) ? sql`(${merged})::json` : merged;
|
|
4921
|
+
}
|
|
4922
|
+
}
|
|
4923
|
+
}
|
|
4924
|
+
function unsupportedColumn(field, operator, type, context) {
|
|
4925
|
+
return ApiError.badRequest(`${operator} cannot be applied to '${field}' on "${context.collectionPath}": the column is ${type || "of an unknown type"}, and ${operator} needs ` + (operator === "$merge" ? "a json or jsonb column." : "an array or json column."), "INVALID_FIELD_OPERATION");
|
|
4926
|
+
}
|
|
4927
|
+
/** Compile a whole map of operations into a `SET`-ready object. */
|
|
4928
|
+
function compileFieldOps(table, fieldOps, context) {
|
|
4929
|
+
const compiled = {};
|
|
4930
|
+
for (const [field, op] of Object.entries(fieldOps)) compiled[field] = compileFieldOp(table, field, op, context);
|
|
4931
|
+
return compiled;
|
|
4932
|
+
}
|
|
4933
|
+
//#endregion
|
|
4559
4934
|
//#region src/services/PersistService.ts
|
|
4560
4935
|
/**
|
|
4561
4936
|
* Service for handling all row write operations.
|
|
@@ -4646,10 +5021,17 @@ var PersistService = class {
|
|
|
4646
5021
|
* Save an row (create or update)
|
|
4647
5022
|
*
|
|
4648
5023
|
* With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
|
|
4649
|
-
* UPDATE
|
|
4650
|
-
*
|
|
4651
|
-
*
|
|
4652
|
-
*
|
|
5024
|
+
* UPDATE rather than a plain UPDATE. That is one statement, so it cannot
|
|
5025
|
+
* lose a race the way a read-then-write can, and it does not care whether
|
|
5026
|
+
* the row already exists — which is what a re-runnable import needs. The
|
|
5027
|
+
* conflict is matched on the primary key unless `options.onConflict` names
|
|
5028
|
+
* other columns; see {@link SaveProps.onConflict} for why that matters.
|
|
5029
|
+
*
|
|
5030
|
+
* `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
|
|
5031
|
+
* split out here rather than at the REST boundary because every request
|
|
5032
|
+
* boundary — HTTP, the WebSocket, in-process `context.data` — reaches this
|
|
5033
|
+
* one method, and a rule applied at one door is a rule the other doors do
|
|
5034
|
+
* not have. What they compile to is `field-op-sql.ts`.
|
|
4653
5035
|
*/
|
|
4654
5036
|
async save(collectionPath, values, id, databaseId, options) {
|
|
4655
5037
|
let effectiveCollectionPath = collectionPath;
|
|
@@ -4692,8 +5074,14 @@ var PersistService = class {
|
|
|
4692
5074
|
if (!field) throw new Error(`Primary key field '${info.fieldName}' not found in table for collection '${effectiveCollectionPath}'`);
|
|
4693
5075
|
returningKeys[info.fieldName] = field;
|
|
4694
5076
|
});
|
|
5077
|
+
const { values: plainValues, fieldOps } = splitFieldOps(effectiveValues);
|
|
5078
|
+
const fieldOpKeys = Object.keys(fieldOps);
|
|
5079
|
+
if (fieldOpKeys.length > 0) {
|
|
5080
|
+
if (!id) throw ApiError.badRequest(`Field operations (${fieldOpKeys.map((k) => `'${k}'`).join(", ")}) need an existing row to act on, and this write to "${effectiveCollectionPath}" is an insert. Send the value itself instead.`, "INVALID_FIELD_OPERATION");
|
|
5081
|
+
if (options?.upsert) throw ApiError.badRequest(`Field operations (${fieldOpKeys.map((k) => `'${k}'`).join(", ")}) cannot be combined with an upsert: an upsert may insert, and there would be no stored value to operate on. Use an update, or send the value itself.`, "INVALID_FIELD_OPERATION");
|
|
5082
|
+
}
|
|
4695
5083
|
const relationValues = {};
|
|
4696
|
-
const otherValues = { ...
|
|
5084
|
+
const otherValues = { ...plainValues };
|
|
4697
5085
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
4698
5086
|
for (const key in resolvedRelations) {
|
|
4699
5087
|
const relation = resolvedRelations[key];
|
|
@@ -4711,14 +5099,20 @@ var PersistService = class {
|
|
|
4711
5099
|
const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
|
|
4712
5100
|
const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
|
|
4713
5101
|
assertWritableColumns(entityData, table, effectiveCollectionPath);
|
|
5102
|
+
if (fieldOpKeys.length > 0) assertWritableColumns(Object.fromEntries(fieldOpKeys.map((key) => [key, null])), table, effectiveCollectionPath);
|
|
4714
5103
|
savedId = await this.db.transaction(async (tx) => {
|
|
4715
5104
|
let currentId;
|
|
4716
5105
|
if (id && !options?.upsert) {
|
|
4717
5106
|
currentId = id;
|
|
4718
5107
|
const idValues = parseIdValues(id, idInfoArray);
|
|
4719
5108
|
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
4720
|
-
|
|
4721
|
-
|
|
5109
|
+
const scalarKeys = Object.keys(entityData);
|
|
5110
|
+
const compiledOps = fieldOpKeys.length > 0 ? compileFieldOps(table, fieldOps, { collectionPath: effectiveCollectionPath }) : void 0;
|
|
5111
|
+
if (scalarKeys.length > 0 || compiledOps) {
|
|
5112
|
+
const updateQuery = tx.update(table).set({
|
|
5113
|
+
...entityData,
|
|
5114
|
+
...compiledOps ?? {}
|
|
5115
|
+
});
|
|
4722
5116
|
const conditions = [];
|
|
4723
5117
|
for (const info of idInfoArray) {
|
|
4724
5118
|
const field = table[info.fieldName];
|
|
@@ -4731,12 +5125,18 @@ var PersistService = class {
|
|
|
4731
5125
|
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
4732
5126
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
4733
5127
|
const insertQuery = tx.insert(table).values(dataForInsert);
|
|
4734
|
-
const
|
|
5128
|
+
const targetFields = options?.onConflict && options.onConflict.length > 0 ? [...options.onConflict] : idInfoArray.map((info) => info.fieldName);
|
|
5129
|
+
const hasFullKey = targetFields.length > 0 && targetFields.every((field) => dataForInsert[field] !== void 0);
|
|
4735
5130
|
let result;
|
|
4736
5131
|
if (options?.upsert && hasFullKey) {
|
|
4737
|
-
const target =
|
|
5132
|
+
const target = targetFields.map((field) => {
|
|
5133
|
+
const column = table[field];
|
|
5134
|
+
if (!column) throw ApiError.badRequest(`'${field}' is not a column of "${effectiveCollectionPath}", so it cannot be an upsert target.`, "INVALID_CONFLICT_TARGET");
|
|
5135
|
+
return column;
|
|
5136
|
+
});
|
|
4738
5137
|
const set = { ...dataForInsert };
|
|
4739
5138
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
5139
|
+
for (const field of targetFields) delete set[field];
|
|
4740
5140
|
for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
|
|
4741
5141
|
result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
|
|
4742
5142
|
target,
|
|
@@ -4755,7 +5155,7 @@ var PersistService = class {
|
|
|
4755
5155
|
return currentId;
|
|
4756
5156
|
});
|
|
4757
5157
|
} catch (error) {
|
|
4758
|
-
throw this.toUserFriendlyError(error, collection.slug);
|
|
5158
|
+
throw this.toUserFriendlyError(error, collection.slug, collection);
|
|
4759
5159
|
}
|
|
4760
5160
|
const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
|
|
4761
5161
|
if (!finalEntity) throw new Error("Could not fetch row after save.");
|
|
@@ -4784,13 +5184,23 @@ var PersistService = class {
|
|
|
4784
5184
|
}
|
|
4785
5185
|
/**
|
|
4786
5186
|
* Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
|
|
5187
|
+
*
|
|
5188
|
+
* `collection` is passed so the SQLSTATE can be turned into a *field*: the
|
|
5189
|
+
* error carries a physical column and the caller only ever sees wire names,
|
|
5190
|
+
* so `author_id` had to be translated to `authorId` by whoever read the
|
|
5191
|
+
* message — which nothing does. See `pgFieldViolations`.
|
|
4787
5192
|
*/
|
|
4788
|
-
toUserFriendlyError(error, collectionSlug) {
|
|
5193
|
+
toUserFriendlyError(error, collectionSlug, collection) {
|
|
4789
5194
|
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
4790
5195
|
const pgError = extractPgError(error);
|
|
4791
5196
|
if (pgError) {
|
|
4792
|
-
const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
4793
|
-
|
|
5197
|
+
const { message, code, violations } = pgErrorToFriendlyMessage(pgError, collectionSlug, { collection });
|
|
5198
|
+
/** `details` for a 4xx, carrying the field(s) — never the values. */
|
|
5199
|
+
const details = violations.length > 0 ? {
|
|
5200
|
+
collection: collectionSlug,
|
|
5201
|
+
violations
|
|
5202
|
+
} : void 0;
|
|
5203
|
+
if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`, details) : ApiError.badRequest(message, `PG_${code}`, details);
|
|
4794
5204
|
if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
|
|
4795
5205
|
return new Error(message, { cause: error });
|
|
4796
5206
|
}
|
|
@@ -4827,8 +5237,8 @@ var DataService = class {
|
|
|
4827
5237
|
/**
|
|
4828
5238
|
* Fetch a single row by ID
|
|
4829
5239
|
*/
|
|
4830
|
-
async fetchOne(collectionPath, id, databaseId) {
|
|
4831
|
-
return this.fetchService.fetchOne(collectionPath, id, databaseId);
|
|
5240
|
+
async fetchOne(collectionPath, id, databaseId, withDeleted) {
|
|
5241
|
+
return this.fetchService.fetchOne(collectionPath, id, databaseId, withDeleted);
|
|
4832
5242
|
}
|
|
4833
5243
|
/**
|
|
4834
5244
|
* Fetch a collection of rows with optional filtering, ordering, and pagination
|
|
@@ -4837,6 +5247,27 @@ var DataService = class {
|
|
|
4837
5247
|
return this.fetchService.fetchCollection(collectionPath, options);
|
|
4838
5248
|
}
|
|
4839
5249
|
/**
|
|
5250
|
+
* The REST read pipeline: flat rows, with exactly the relations `include`
|
|
5251
|
+
* names — see {@link FetchService.fetchCollectionForRest}.
|
|
5252
|
+
*
|
|
5253
|
+
* Exposed on the facade because it is what *every* consumer should be
|
|
5254
|
+
* reading through. The realtime refetch used to go via `fetchCollection`
|
|
5255
|
+
* above, which nests a relation under a `{ __type: "relation" }` envelope
|
|
5256
|
+
* and loaded every relation regardless, so a subscription and a REST list
|
|
5257
|
+
* answered the same query with two different row shapes.
|
|
5258
|
+
*/
|
|
5259
|
+
async fetchCollectionForRest(collectionPath, options = {}, include) {
|
|
5260
|
+
return this.fetchService.fetchCollectionForRest(collectionPath, options, include);
|
|
5261
|
+
}
|
|
5262
|
+
/** One row, through the same pipeline. See {@link FetchService.fetchOneForRest}. */
|
|
5263
|
+
async fetchOneForRest(collectionPath, id, include, databaseId, options) {
|
|
5264
|
+
return this.fetchService.fetchOneForRest(collectionPath, id, include, databaseId, options);
|
|
5265
|
+
}
|
|
5266
|
+
/** See {@link FetchService.cursorFor}. */
|
|
5267
|
+
cursorFor(collectionPath, row, orderBy) {
|
|
5268
|
+
return this.fetchService.cursorFor(collectionPath, row, orderBy);
|
|
5269
|
+
}
|
|
5270
|
+
/**
|
|
4840
5271
|
* Search rows by text
|
|
4841
5272
|
*/
|
|
4842
5273
|
async searchRows(collectionPath, searchString, options = {}) {
|
|
@@ -4955,7 +5386,9 @@ var physicalShapeOf = (prop) => JSON.stringify([
|
|
|
4955
5386
|
prop.columnType ?? null,
|
|
4956
5387
|
prop.dimensions ?? null,
|
|
4957
5388
|
prop.isId ?? null,
|
|
4958
|
-
prop.validation?.max ?? null
|
|
5389
|
+
prop.validation?.max ?? null,
|
|
5390
|
+
prop.precision ?? null,
|
|
5391
|
+
prop.scale ?? null
|
|
4959
5392
|
]);
|
|
4960
5393
|
/** Whether the table behind a collection exists and is empty. */
|
|
4961
5394
|
var tableIsEmpty = (collection, facts) => {
|
|
@@ -5226,37 +5659,34 @@ async function generateSchemaCommit(input) {
|
|
|
5226
5659
|
const options = { constraints: "converge" };
|
|
5227
5660
|
const classified = classifyCollectionChanges(input.before, input.after, input.existing);
|
|
5228
5661
|
if (!classified.applicable) throw new SchemaCommitError(`This change cannot be applied to a running database:\n` + classified.changes.filter((change) => change.verdict !== "safe").map((change) => ` • ${change.detail}${change.remedy ? `\n ${change.remedy}` : ""}`).join("\n"), classified);
|
|
5229
|
-
const [schema, ddl, policies, search, vector] = await Promise.all([
|
|
5230
|
-
generateSchema(input.after),
|
|
5231
|
-
generatePostgresDdl(input.after, {
|
|
5232
|
-
includePolicies: false,
|
|
5233
|
-
includeSearch: false,
|
|
5234
|
-
includeVector: false
|
|
5235
|
-
}),
|
|
5236
|
-
Promise.resolve(generatePostgresPoliciesDdl(input.after)),
|
|
5237
|
-
Promise.resolve(generatePostgresSearchDdl(input.after)),
|
|
5238
|
-
Promise.resolve(generatePostgresVectorDdl(input.after, { extensions: declaredDatabaseExtensions() }))
|
|
5239
|
-
]);
|
|
5240
5662
|
const generated = [
|
|
5241
5663
|
{
|
|
5242
5664
|
path: paths.schemaFile,
|
|
5243
|
-
contents:
|
|
5665
|
+
contents: generateSchema(input.after)
|
|
5244
5666
|
},
|
|
5245
5667
|
{
|
|
5246
5668
|
path: paths.ddlFile,
|
|
5247
|
-
contents:
|
|
5669
|
+
contents: generatePostgresDdl(input.after, {
|
|
5670
|
+
includePolicies: false,
|
|
5671
|
+
includeSearch: false,
|
|
5672
|
+
includeVector: false
|
|
5673
|
+
})
|
|
5248
5674
|
},
|
|
5249
5675
|
{
|
|
5250
5676
|
path: paths.policiesFile,
|
|
5251
|
-
contents:
|
|
5677
|
+
contents: generatePostgresPoliciesDdl(input.after)
|
|
5252
5678
|
},
|
|
5253
5679
|
{
|
|
5254
5680
|
path: paths.searchFile,
|
|
5255
|
-
contents:
|
|
5681
|
+
contents: generatePostgresSearchDdl(input.after)
|
|
5682
|
+
},
|
|
5683
|
+
{
|
|
5684
|
+
path: paths.vectorFile,
|
|
5685
|
+
contents: generatePostgresVectorDdl(input.after, { extensions: declaredDatabaseExtensions() })
|
|
5256
5686
|
},
|
|
5257
5687
|
{
|
|
5258
|
-
path: paths.
|
|
5259
|
-
contents:
|
|
5688
|
+
path: paths.triggersFile,
|
|
5689
|
+
contents: generatePostgresTriggersDdl(input.after)
|
|
5260
5690
|
}
|
|
5261
5691
|
];
|
|
5262
5692
|
const previous = planCollectionSchemaEnsure(input.before, existing, options);
|
|
@@ -5322,6 +5752,37 @@ var RoleSwitchUnavailableError = class extends Error {
|
|
|
5322
5752
|
this.pgError = pgError;
|
|
5323
5753
|
}
|
|
5324
5754
|
};
|
|
5755
|
+
/**
|
|
5756
|
+
* Refuse a write that would leave a *required* `created_by` / `updated_by`
|
|
5757
|
+
* column null because nobody is acting.
|
|
5758
|
+
*
|
|
5759
|
+
* A `user_on_create` column takes the uid from the call context, and an
|
|
5760
|
+
* anonymous request, a service token and an in-process write all have none.
|
|
5761
|
+
* Storing `null` is the right answer where the column allows it — plenty of
|
|
5762
|
+
* rows are legitimately written by the server. Where the collection has said
|
|
5763
|
+
* `required`, it is not: that declaration means "a row must record who made
|
|
5764
|
+
* it", and a write that cannot answer the question is a write this collection
|
|
5765
|
+
* does not accept. Better a 400 naming the field than a 23502 naming the
|
|
5766
|
+
* column, three layers down, after the hooks have run.
|
|
5767
|
+
*/
|
|
5768
|
+
function assertActingUserForAutoValues(properties, status, uid, path) {
|
|
5769
|
+
if (uid) return;
|
|
5770
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
5771
|
+
const prop = property;
|
|
5772
|
+
if (!prop || prop.type !== "string") continue;
|
|
5773
|
+
if (prop.autoValue !== "user_on_create" && prop.autoValue !== "user_on_update") continue;
|
|
5774
|
+
if (!prop.validation?.required) continue;
|
|
5775
|
+
if (status === "existing" && prop.autoValue === "user_on_create") continue;
|
|
5776
|
+
throw ApiError.badRequest(`'${key}' on '${path}' records the acting user and is required, and this request has none. Sign in, or drop \`required\` from the property to let the server write rows anonymously.`, "VALIDATION_CONSTRAINT", {
|
|
5777
|
+
collection: path,
|
|
5778
|
+
violations: [{
|
|
5779
|
+
field: key,
|
|
5780
|
+
code: "required",
|
|
5781
|
+
message: `'${key}' records the acting user, and there is none.`
|
|
5782
|
+
}]
|
|
5783
|
+
});
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5325
5786
|
var PostgresBackendDriver = class PostgresBackendDriver {
|
|
5326
5787
|
db;
|
|
5327
5788
|
registry;
|
|
@@ -5431,15 +5892,16 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5431
5892
|
const rows = await raw.fetchCollectionForRest(collectionPath, options, include);
|
|
5432
5893
|
return this.applyAfterReadForRest(rows, collectionPath);
|
|
5433
5894
|
},
|
|
5434
|
-
fetchOneForRest: async (collectionPath, id, include, databaseId) => {
|
|
5435
|
-
const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId);
|
|
5895
|
+
fetchOneForRest: async (collectionPath, id, include, databaseId, options) => {
|
|
5896
|
+
const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId, options);
|
|
5436
5897
|
if (!row) return row;
|
|
5437
5898
|
const [masked] = await this.applyAfterReadForRest([row], collectionPath);
|
|
5438
5899
|
return masked;
|
|
5439
5900
|
},
|
|
5440
5901
|
aggregate: async (collectionPath, options) => {
|
|
5441
5902
|
return raw.aggregate(collectionPath, options);
|
|
5442
|
-
}
|
|
5903
|
+
},
|
|
5904
|
+
cursorFor: (collectionPath, row, orderBy) => raw.cursorFor(collectionPath, row, orderBy)
|
|
5443
5905
|
};
|
|
5444
5906
|
}
|
|
5445
5907
|
/**
|
|
@@ -5661,8 +6123,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5661
6123
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
5662
6124
|
};
|
|
5663
6125
|
}
|
|
5664
|
-
async fetchOne({ path, id, databaseId, collection }) {
|
|
5665
|
-
let row = await this.dataService.fetchOne(path, id, databaseId || collection?.databaseId);
|
|
6126
|
+
async fetchOne({ path, id, databaseId, collection, withDeleted }) {
|
|
6127
|
+
let row = await this.dataService.fetchOne(path, id, databaseId || collection?.databaseId, withDeleted);
|
|
5666
6128
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
5667
6129
|
if (row && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
5668
6130
|
const contextForCallback = this.buildCallContext();
|
|
@@ -5713,10 +6175,110 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5713
6175
|
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
5714
6176
|
};
|
|
5715
6177
|
}
|
|
5716
|
-
|
|
6178
|
+
/**
|
|
6179
|
+
* How many membership rows a single write will read to answer "which
|
|
6180
|
+
* tenants is this caller in".
|
|
6181
|
+
*
|
|
6182
|
+
* A cap, not a limit on the feature: past it the API-level check stops
|
|
6183
|
+
* being able to prove a tenant is *not* the caller's, and defers to the
|
|
6184
|
+
* policy's `WITH CHECK`, which decides either way. The alternative — an
|
|
6185
|
+
* uncapped read of a membership table on every insert — makes the cost of a
|
|
6186
|
+
* write depend on how many organizations one user happens to belong to.
|
|
6187
|
+
*/
|
|
6188
|
+
static TENANT_MEMBERSHIP_CAP = 100;
|
|
6189
|
+
/**
|
|
6190
|
+
* Stamp, or refuse, the tenant on a write to a `tenant`-scoped collection.
|
|
6191
|
+
*
|
|
6192
|
+
* A no-op for every collection that declares no tenancy, which is the
|
|
6193
|
+
* common case and costs one property read.
|
|
6194
|
+
*/
|
|
6195
|
+
async applyTenantScope(collection, path, values, status, previousValues) {
|
|
6196
|
+
const tenant = getTenantConfig(collection);
|
|
6197
|
+
if (!tenant) return values;
|
|
6198
|
+
const bypassRoles = tenantBypassRoles(tenant);
|
|
6199
|
+
const callerRoles = (this.user?.roles ?? []).map((r) => typeof r === "string" ? r : String(r?.id ?? r));
|
|
6200
|
+
const bypass = !this.user || callerRoles.some((r) => bypassRoles.includes(r));
|
|
6201
|
+
const { tenants, complete } = bypass ? {
|
|
6202
|
+
tenants: [],
|
|
6203
|
+
complete: true
|
|
6204
|
+
} : await this.resolveCallerTenants(tenant, collection);
|
|
6205
|
+
const decision = resolveTenantWrite({
|
|
6206
|
+
tenant,
|
|
6207
|
+
values,
|
|
6208
|
+
status,
|
|
6209
|
+
callerTenants: tenants,
|
|
6210
|
+
callerTenantsComplete: complete,
|
|
6211
|
+
bypass,
|
|
6212
|
+
previousValues,
|
|
6213
|
+
slug: collection?.slug ?? path
|
|
6214
|
+
});
|
|
6215
|
+
if (decision.refusal) {
|
|
6216
|
+
const { code, field, message } = decision.refusal;
|
|
6217
|
+
throw ApiError.badRequest(message, code, {
|
|
6218
|
+
collection: path,
|
|
6219
|
+
violations: [{
|
|
6220
|
+
field,
|
|
6221
|
+
code,
|
|
6222
|
+
message
|
|
6223
|
+
}]
|
|
6224
|
+
});
|
|
6225
|
+
}
|
|
6226
|
+
return decision.values;
|
|
6227
|
+
}
|
|
6228
|
+
/**
|
|
6229
|
+
* The tenants this caller may write into.
|
|
6230
|
+
*
|
|
6231
|
+
* Two sources, and they answer at different costs. A `claim` is already on
|
|
6232
|
+
* the request — no query. A `membership` is rows in a table, and reading
|
|
6233
|
+
* them is a real query, so it is capped (see
|
|
6234
|
+
* {@link PostgresBackendDriver.TENANT_MEMBERSHIP_CAP}) and `complete` says
|
|
6235
|
+
* whether the cap was hit.
|
|
6236
|
+
*
|
|
6237
|
+
* The membership read runs in the caller's own context, so the membership
|
|
6238
|
+
* collection's RLS applies to it — exactly as it does inside the generated
|
|
6239
|
+
* policy's subquery. That is deliberate: if the two disagreed, the API
|
|
6240
|
+
* would refuse writes the database would have accepted, or accept writes it
|
|
6241
|
+
* refuses. `validateCollectionConfig` is what makes sure the membership
|
|
6242
|
+
* collection is readable by its own members in the first place.
|
|
6243
|
+
*/
|
|
6244
|
+
async resolveCallerTenants(tenant, collection) {
|
|
6245
|
+
if ("claim" in tenant.from) {
|
|
6246
|
+
const value = this.user?.claims?.[tenant.from.claim];
|
|
6247
|
+
return {
|
|
6248
|
+
tenants: value === void 0 || value === null || value === "" ? [] : [value],
|
|
6249
|
+
complete: true
|
|
6250
|
+
};
|
|
6251
|
+
}
|
|
6252
|
+
const uid = this.user?.uid;
|
|
6253
|
+
if (!uid) return {
|
|
6254
|
+
tenants: [],
|
|
6255
|
+
complete: true
|
|
6256
|
+
};
|
|
6257
|
+
const { collection: slug, userField, tenantField } = tenant.from.membership;
|
|
6258
|
+
const cap = PostgresBackendDriver.TENANT_MEMBERSHIP_CAP;
|
|
6259
|
+
try {
|
|
6260
|
+
const rows = await this.dataService.fetchCollection(slug, {
|
|
6261
|
+
filter: { [userField]: ["==", uid] },
|
|
6262
|
+
limit: cap + 1,
|
|
6263
|
+
databaseId: collection?.databaseId
|
|
6264
|
+
});
|
|
6265
|
+
return {
|
|
6266
|
+
tenants: Array.from(new Set(rows.slice(0, cap).map((row) => row[tenantField]).filter((value) => value !== null && value !== void 0).map((value) => typeof value === "object" ? value.id ?? value : value))),
|
|
6267
|
+
complete: rows.length <= cap
|
|
6268
|
+
};
|
|
6269
|
+
} catch (err) {
|
|
6270
|
+
logger.debug(`[save] Could not read '${slug}' to resolve the caller's tenants`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6271
|
+
return {
|
|
6272
|
+
tenants: [],
|
|
6273
|
+
complete: false
|
|
6274
|
+
};
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
async save({ path, id, values, collection, status, upsert, onConflict }) {
|
|
5717
6278
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
5718
6279
|
let updatedValues = values;
|
|
5719
6280
|
const contextForCallback = this.buildCallContext();
|
|
6281
|
+
if ((status === "new" || status === "copy") && resolvedCollection?.properties) updatedValues = applyDefaultValuesOnCreate(updatedValues, resolvedCollection.properties);
|
|
5720
6282
|
let previousValuesForHistory;
|
|
5721
6283
|
if (status === "existing" && id) try {
|
|
5722
6284
|
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
@@ -5769,14 +6331,27 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5769
6331
|
} catch (callbackError) {
|
|
5770
6332
|
throw toCallbackError(callbackError, "beforeSave", path);
|
|
5771
6333
|
}
|
|
5772
|
-
if (resolvedCollection?.properties)
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
6334
|
+
if (resolvedCollection?.properties) {
|
|
6335
|
+
updatedValues = updateDateAutoValues({
|
|
6336
|
+
inputValues: updatedValues,
|
|
6337
|
+
properties: resolvedCollection.properties,
|
|
6338
|
+
status: status ?? "new",
|
|
6339
|
+
timestampNowValue: /* @__PURE__ */ new Date()
|
|
6340
|
+
});
|
|
6341
|
+
assertActingUserForAutoValues(resolvedCollection.properties, status ?? "new", this.user?.uid, path);
|
|
6342
|
+
updatedValues = updateUserAutoValues({
|
|
6343
|
+
inputValues: updatedValues,
|
|
6344
|
+
properties: resolvedCollection.properties,
|
|
6345
|
+
status: status ?? "new",
|
|
6346
|
+
uid: this.user?.uid
|
|
6347
|
+
});
|
|
6348
|
+
}
|
|
6349
|
+
updatedValues = await this.applyTenantScope(resolvedCollection, path, updatedValues, status ?? "new", previousValuesForHistory);
|
|
5778
6350
|
try {
|
|
5779
|
-
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, {
|
|
6351
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, {
|
|
6352
|
+
upsert,
|
|
6353
|
+
onConflict
|
|
6354
|
+
});
|
|
5780
6355
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
5781
6356
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
5782
6357
|
collection: resolvedCollection,
|
|
@@ -5832,7 +6407,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5832
6407
|
} catch (callbackError) {
|
|
5833
6408
|
throw toCallbackError(callbackError, "afterSave", path);
|
|
5834
6409
|
}
|
|
5835
|
-
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
6410
|
+
if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
|
|
5836
6411
|
tableName: path,
|
|
5837
6412
|
id: savedId,
|
|
5838
6413
|
action: status === "new" ? "create" : "update",
|
|
@@ -5887,13 +6462,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5887
6462
|
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
5888
6463
|
* with the last write winning, exactly as separate calls would.
|
|
5889
6464
|
*/
|
|
5890
|
-
|
|
6465
|
+
bindToTransaction(tx) {
|
|
6466
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
6467
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
6468
|
+
txDriver.client = this.client;
|
|
6469
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
6470
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
6471
|
+
return txDriver;
|
|
6472
|
+
}
|
|
6473
|
+
async saveMany({ path, rows, collection, upsert, onConflict }) {
|
|
5891
6474
|
return this.db.transaction(async (tx) => {
|
|
5892
|
-
const txDriver =
|
|
5893
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
5894
|
-
txDriver.client = this.client;
|
|
5895
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
5896
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
6475
|
+
const txDriver = this.bindToTransaction(tx);
|
|
5897
6476
|
const saved = [];
|
|
5898
6477
|
for (let i = 0; i < rows.length; i++) {
|
|
5899
6478
|
const values = rows[i];
|
|
@@ -5904,7 +6483,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5904
6483
|
values,
|
|
5905
6484
|
collection,
|
|
5906
6485
|
status: "new",
|
|
5907
|
-
upsert
|
|
6486
|
+
upsert,
|
|
6487
|
+
onConflict
|
|
5908
6488
|
}));
|
|
5909
6489
|
} catch (error) {
|
|
5910
6490
|
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
@@ -5935,11 +6515,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5935
6515
|
*/
|
|
5936
6516
|
async updateMany({ path, updates, collection }) {
|
|
5937
6517
|
return this.db.transaction(async (tx) => {
|
|
5938
|
-
const txDriver =
|
|
5939
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
5940
|
-
txDriver.client = this.client;
|
|
5941
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
5942
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
6518
|
+
const txDriver = this.bindToTransaction(tx);
|
|
5943
6519
|
const saved = [];
|
|
5944
6520
|
for (let i = 0; i < updates.length; i++) {
|
|
5945
6521
|
const { id, values } = updates[i];
|
|
@@ -5981,13 +6557,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5981
6557
|
* dependents would behave differently depending on how many rows the caller
|
|
5982
6558
|
* happened to delete at once. Same pipeline, one transaction.
|
|
5983
6559
|
*/
|
|
5984
|
-
async deleteMany({ path, ids, collection }) {
|
|
6560
|
+
async deleteMany({ path, ids, collection, hard }) {
|
|
5985
6561
|
await this.db.transaction(async (tx) => {
|
|
5986
|
-
const txDriver =
|
|
5987
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
5988
|
-
txDriver.client = this.client;
|
|
5989
|
-
txDriver._deferNotifications = this._deferNotifications;
|
|
5990
|
-
txDriver._pendingNotifications = this._pendingNotifications;
|
|
6562
|
+
const txDriver = this.bindToTransaction(tx);
|
|
5991
6563
|
for (let i = 0; i < ids.length; i++) {
|
|
5992
6564
|
const id = ids[i];
|
|
5993
6565
|
try {
|
|
@@ -6006,7 +6578,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6006
6578
|
path,
|
|
6007
6579
|
values: existing
|
|
6008
6580
|
},
|
|
6009
|
-
collection
|
|
6581
|
+
collection,
|
|
6582
|
+
hard
|
|
6010
6583
|
});
|
|
6011
6584
|
} catch (error) {
|
|
6012
6585
|
throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
@@ -6018,7 +6591,107 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6018
6591
|
}
|
|
6019
6592
|
});
|
|
6020
6593
|
}
|
|
6021
|
-
|
|
6594
|
+
/**
|
|
6595
|
+
* A mixed list of writes across collections, as one unit of work.
|
|
6596
|
+
*
|
|
6597
|
+
* Same transaction, same tx-bound sub-driver and same per-entry error
|
|
6598
|
+
* labelling as {@link saveMany} — deliberately the same plumbing rather
|
|
6599
|
+
* than a second copy of it, because the three ways this could drift
|
|
6600
|
+
* (notifications not deferred, the sub-driver not bound, an error losing
|
|
6601
|
+
* its status) are all silent.
|
|
6602
|
+
*
|
|
6603
|
+
* The one thing this adds is `$ref`. An operation may name itself, and a
|
|
6604
|
+
* later one may stand a `{ "$ref": "order.id" }` where a value goes; the
|
|
6605
|
+
* substitution happens here because inside the transaction is the only
|
|
6606
|
+
* place the row the reference points at exists. Backward references only —
|
|
6607
|
+
* the REST layer refuses a forward one before the transaction opens, so a
|
|
6608
|
+
* body that cannot work never costs a rollback.
|
|
6609
|
+
*/
|
|
6610
|
+
async batchWrite({ operations }) {
|
|
6611
|
+
return this.db.transaction(async (tx) => {
|
|
6612
|
+
const txDriver = this.bindToTransaction(tx);
|
|
6613
|
+
const results = [];
|
|
6614
|
+
/** What each named operation wrote, for the `$ref`s after it. */
|
|
6615
|
+
const named = /* @__PURE__ */ new Map();
|
|
6616
|
+
for (let i = 0; i < operations.length; i++) {
|
|
6617
|
+
const operation = operations[i];
|
|
6618
|
+
try {
|
|
6619
|
+
const values = operation.values ? resolveBatchRefs(operation.values, named) : void 0;
|
|
6620
|
+
const rawId = operation.id !== void 0 ? resolveBatchRefs(operation.id, named) : void 0;
|
|
6621
|
+
const id = rawId === void 0 || rawId === null ? void 0 : String(rawId);
|
|
6622
|
+
let row = null;
|
|
6623
|
+
switch (operation.op) {
|
|
6624
|
+
case "create":
|
|
6625
|
+
row = await txDriver.save({
|
|
6626
|
+
path: operation.path,
|
|
6627
|
+
values: values ?? {},
|
|
6628
|
+
collection: operation.collection,
|
|
6629
|
+
status: "new"
|
|
6630
|
+
});
|
|
6631
|
+
break;
|
|
6632
|
+
case "upsert":
|
|
6633
|
+
row = await txDriver.save({
|
|
6634
|
+
path: operation.path,
|
|
6635
|
+
values: values ?? {},
|
|
6636
|
+
collection: operation.collection,
|
|
6637
|
+
status: "new",
|
|
6638
|
+
upsert: true,
|
|
6639
|
+
onConflict: operation.onConflict
|
|
6640
|
+
});
|
|
6641
|
+
break;
|
|
6642
|
+
case "update":
|
|
6643
|
+
if (!await txDriver.fetchOne({
|
|
6644
|
+
path: operation.path,
|
|
6645
|
+
id,
|
|
6646
|
+
collection: operation.collection
|
|
6647
|
+
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
6648
|
+
statusCode: 404,
|
|
6649
|
+
code: "NOT_FOUND"
|
|
6650
|
+
});
|
|
6651
|
+
row = await txDriver.save({
|
|
6652
|
+
path: operation.path,
|
|
6653
|
+
id,
|
|
6654
|
+
values: values ?? {},
|
|
6655
|
+
collection: operation.collection,
|
|
6656
|
+
status: "existing"
|
|
6657
|
+
});
|
|
6658
|
+
break;
|
|
6659
|
+
case "delete": {
|
|
6660
|
+
const existing = await txDriver.fetchOne({
|
|
6661
|
+
path: operation.path,
|
|
6662
|
+
id,
|
|
6663
|
+
collection: operation.collection
|
|
6664
|
+
});
|
|
6665
|
+
if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
6666
|
+
statusCode: 404,
|
|
6667
|
+
code: "NOT_FOUND"
|
|
6668
|
+
});
|
|
6669
|
+
await txDriver.delete({
|
|
6670
|
+
row: {
|
|
6671
|
+
id,
|
|
6672
|
+
path: operation.path,
|
|
6673
|
+
values: existing
|
|
6674
|
+
},
|
|
6675
|
+
collection: operation.collection
|
|
6676
|
+
});
|
|
6677
|
+
row = null;
|
|
6678
|
+
break;
|
|
6679
|
+
}
|
|
6680
|
+
}
|
|
6681
|
+
if (operation.ref && row) named.set(operation.ref, row);
|
|
6682
|
+
results.push(row);
|
|
6683
|
+
} catch (error) {
|
|
6684
|
+
throw Object.assign(new Error(`Operation ${i} of ${operations.length} (${operation.op} on "${operation.path}") failed: ${error?.message ?? error}`, { cause: error }), {
|
|
6685
|
+
statusCode: error?.statusCode,
|
|
6686
|
+
code: error?.code,
|
|
6687
|
+
name: error?.name
|
|
6688
|
+
});
|
|
6689
|
+
}
|
|
6690
|
+
}
|
|
6691
|
+
return results;
|
|
6692
|
+
});
|
|
6693
|
+
}
|
|
6694
|
+
async delete({ row, collection, hard }) {
|
|
6022
6695
|
const targetPath = row.path;
|
|
6023
6696
|
const targetRow = { ...row.values ?? {} };
|
|
6024
6697
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
@@ -6058,7 +6731,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6058
6731
|
} catch (callbackError) {
|
|
6059
6732
|
throw toCallbackError(callbackError, "beforeDelete", targetPath);
|
|
6060
6733
|
}
|
|
6061
|
-
|
|
6734
|
+
const softDelete = hard ? void 0 : resolveSoftDelete(resolvedCollection);
|
|
6735
|
+
if (softDelete) await this.dataService.save(targetPath, { [softDelete.field]: /* @__PURE__ */ new Date() }, row.id, resolvedCollection?.databaseId);
|
|
6736
|
+
else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
|
|
6062
6737
|
try {
|
|
6063
6738
|
if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
|
|
6064
6739
|
if (globalCallbacks?.afterDelete) await globalCallbacks.afterDelete({
|
|
@@ -6086,7 +6761,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6086
6761
|
} catch (callbackError) {
|
|
6087
6762
|
throw toCallbackError(callbackError, "afterDelete", targetPath);
|
|
6088
6763
|
}
|
|
6089
|
-
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
6764
|
+
if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
|
|
6090
6765
|
tableName: targetPath,
|
|
6091
6766
|
id: row.id.toString(),
|
|
6092
6767
|
action: "delete",
|
|
@@ -6108,12 +6783,13 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6108
6783
|
async checkUniqueField(path, name, value, id, collection) {
|
|
6109
6784
|
return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
|
|
6110
6785
|
}
|
|
6111
|
-
async count({ path, collection, filter, logical, searchString, vectorSearch }) {
|
|
6786
|
+
async count({ path, collection, filter, logical, searchString, vectorSearch, withDeleted }) {
|
|
6112
6787
|
return this.dataService.count(path, {
|
|
6113
6788
|
filter,
|
|
6114
6789
|
logical,
|
|
6115
6790
|
searchString,
|
|
6116
|
-
vectorSearch
|
|
6791
|
+
vectorSearch,
|
|
6792
|
+
withDeleted
|
|
6117
6793
|
});
|
|
6118
6794
|
}
|
|
6119
6795
|
getTargetDb(databaseName) {
|
|
@@ -6365,21 +7041,22 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
6365
7041
|
return delegate.restFetchService.fetchCollectionForRest(collectionPath, options, include);
|
|
6366
7042
|
}, { accessMode: "read only" });
|
|
6367
7043
|
},
|
|
6368
|
-
fetchOneForRest: async (collectionPath, id, include, databaseId) => {
|
|
7044
|
+
fetchOneForRest: async (collectionPath, id, include, databaseId, options) => {
|
|
6369
7045
|
return this.withTransaction(async (delegate) => {
|
|
6370
|
-
return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId);
|
|
7046
|
+
return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId, options);
|
|
6371
7047
|
}, { accessMode: "read only" });
|
|
6372
7048
|
},
|
|
6373
7049
|
aggregate: async (collectionPath, options) => {
|
|
6374
7050
|
return this.withTransaction(async (delegate) => {
|
|
6375
7051
|
return delegate.restFetchService.aggregate(collectionPath, options);
|
|
6376
7052
|
}, { accessMode: "read only" });
|
|
6377
|
-
}
|
|
7053
|
+
},
|
|
7054
|
+
cursorFor: (collectionPath, row, orderBy) => this.delegate.restFetchService.cursorFor?.(collectionPath, row, orderBy)
|
|
6378
7055
|
};
|
|
6379
7056
|
}
|
|
6380
7057
|
async withTransaction(operation, options) {
|
|
6381
7058
|
const pendingNotifications = [];
|
|
6382
|
-
const result = await this.delegate.db.transaction(async (tx) => {
|
|
7059
|
+
const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await this.delegate.db.transaction(async (tx) => {
|
|
6383
7060
|
let uid = this.user?.uid;
|
|
6384
7061
|
if (!uid) {
|
|
6385
7062
|
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
@@ -6390,7 +7067,8 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
6390
7067
|
await applyAuthContext(tx, {
|
|
6391
7068
|
uid,
|
|
6392
7069
|
roles: userRoles,
|
|
6393
|
-
isAnonymous: this.user?.isAnonymous === true
|
|
7070
|
+
isAnonymous: this.user?.isAnonymous === true,
|
|
7071
|
+
claims: this.user?.claims
|
|
6394
7072
|
}, this.delegate.rlsUserRole);
|
|
6395
7073
|
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
6396
7074
|
const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
|
|
@@ -6399,7 +7077,7 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
6399
7077
|
txDelegate._pendingNotifications = pendingNotifications;
|
|
6400
7078
|
txDelegate.client = this.delegate.client;
|
|
6401
7079
|
return await operation(txDelegate);
|
|
6402
|
-
}, options);
|
|
7080
|
+
}, options));
|
|
6403
7081
|
for (const notification of pendingNotifications) try {
|
|
6404
7082
|
await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
|
|
6405
7083
|
} catch (e) {
|
|
@@ -6417,7 +7095,8 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
6417
7095
|
injectAuthContext(unsubscribe) {
|
|
6418
7096
|
const authContext = {
|
|
6419
7097
|
uid: this.user?.uid || "anonymous",
|
|
6420
|
-
roles: this.user?.roles ?? []
|
|
7098
|
+
roles: this.user?.roles ?? [],
|
|
7099
|
+
...this.user?.claims ? { claims: this.user.claims } : {}
|
|
6421
7100
|
};
|
|
6422
7101
|
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
6423
7102
|
const lastSub = entries[entries.length - 1]?.[1];
|
|
@@ -6448,6 +7127,33 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
6448
7127
|
async saveMany(props) {
|
|
6449
7128
|
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
6450
7129
|
}
|
|
7130
|
+
/**
|
|
7131
|
+
* Present for the same reason `saveMany` is, and absent until now.
|
|
7132
|
+
*
|
|
7133
|
+
* Every request that reaches the REST layer is served by *this* class — the
|
|
7134
|
+
* base driver never sees one — and the routes ask `if (!driver.updateMany)`
|
|
7135
|
+
* before doing anything. So `PATCH /api/data/<c>/bulk` and
|
|
7136
|
+
* `POST /api/data/<c>/bulk/delete` answered `BULK_UNSUPPORTED` on Postgres,
|
|
7137
|
+
* the one backend that implements them, for every authenticated caller.
|
|
7138
|
+
* The methods existed one class down and nothing forwarded to them.
|
|
7139
|
+
*/
|
|
7140
|
+
async updateMany(props) {
|
|
7141
|
+
return this.withTransaction((delegate) => delegate.updateMany(props));
|
|
7142
|
+
}
|
|
7143
|
+
async deleteMany(props) {
|
|
7144
|
+
return this.withTransaction((delegate) => delegate.deleteMany(props));
|
|
7145
|
+
}
|
|
7146
|
+
/**
|
|
7147
|
+
* One transaction, one RLS context, every collection the batch touches.
|
|
7148
|
+
*
|
|
7149
|
+
* The whole point is that it runs as the caller: a batch that dropped to
|
|
7150
|
+
* the base driver would write across collections with row-level security
|
|
7151
|
+
* switched off, which is the opposite of what a cross-collection write
|
|
7152
|
+
* needs.
|
|
7153
|
+
*/
|
|
7154
|
+
async batchWrite(props) {
|
|
7155
|
+
return this.withTransaction((delegate) => delegate.batchWrite(props));
|
|
7156
|
+
}
|
|
6451
7157
|
async delete(props) {
|
|
6452
7158
|
return this.withTransaction((delegate) => delegate.delete(props));
|
|
6453
7159
|
}
|
|
@@ -8185,11 +8891,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8185
8891
|
orderBy,
|
|
8186
8892
|
order: request.order,
|
|
8187
8893
|
limit: boundedLimit,
|
|
8188
|
-
offset: request.offset,
|
|
8894
|
+
offset: request.page != null && request.page > 0 ? (request.page - 1) * boundedLimit : request.offset,
|
|
8189
8895
|
startAfter: request.startAfter,
|
|
8190
8896
|
databaseId: request.collection?.databaseId,
|
|
8191
8897
|
searchString: request.searchString,
|
|
8192
|
-
searchExplain: request.searchExplain
|
|
8898
|
+
searchExplain: request.searchExplain,
|
|
8899
|
+
include: request.include,
|
|
8900
|
+
fields: request.fields,
|
|
8901
|
+
distinct: request.distinct
|
|
8193
8902
|
},
|
|
8194
8903
|
authContext,
|
|
8195
8904
|
started: 0,
|
|
@@ -8198,7 +8907,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8198
8907
|
this._subscriptions.set(subscriptionId, subscription);
|
|
8199
8908
|
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8200
8909
|
const rows = await this.fetchCollectionWithAuth(request.path, subscription.collectionRequest, authContext);
|
|
8201
|
-
|
|
8910
|
+
const meta = await this.collectionMetaWithAuth(request.path, subscription.collectionRequest, rows, authContext);
|
|
8911
|
+
if (canDeliver()) this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path, meta);
|
|
8202
8912
|
} catch (error) {
|
|
8203
8913
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
8204
8914
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8361,7 +9071,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8361
9071
|
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8362
9072
|
try {
|
|
8363
9073
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
8364
|
-
|
|
9074
|
+
const meta = await this.collectionMetaWithAuth(notifyPath, subscription.collectionRequest, rows, subscription.authContext);
|
|
9075
|
+
if (canDeliver()) this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath, meta);
|
|
8365
9076
|
} catch (error) {
|
|
8366
9077
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
8367
9078
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8399,24 +9110,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8399
9110
|
uid: ANONYMOUS_USER_ID,
|
|
8400
9111
|
roles: ["anon"]
|
|
8401
9112
|
};
|
|
8402
|
-
return await this.db.transaction(async (tx) => {
|
|
9113
|
+
return await withFieldViewer({ roles: activeAuth.roles ?? [] }, async () => await this.db.transaction(async (tx) => {
|
|
8403
9114
|
await applyAuthContext(tx, {
|
|
8404
9115
|
uid: activeAuth.uid,
|
|
8405
9116
|
roles: activeAuth.roles,
|
|
8406
|
-
isAnonymous: activeAuth.isAnonymous === true
|
|
9117
|
+
isAnonymous: activeAuth.isAnonymous === true,
|
|
9118
|
+
claims: activeAuth.claims
|
|
8407
9119
|
}, this.rlsUserRole);
|
|
8408
|
-
const
|
|
8409
|
-
let fetchedEntities;
|
|
8410
|
-
if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
|
|
8411
|
-
filter: collectionRequest.filter,
|
|
8412
|
-
logical: collectionRequest.logical,
|
|
8413
|
-
orderBy: collectionRequest.orderBy,
|
|
8414
|
-
order: collectionRequest.order,
|
|
8415
|
-
limit: collectionRequest.limit,
|
|
8416
|
-
databaseId: collectionRequest.databaseId,
|
|
8417
|
-
searchExplain: collectionRequest.searchExplain
|
|
8418
|
-
});
|
|
8419
|
-
else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
9120
|
+
const fetchedEntities = await new DataService(tx, this.registry).fetchCollectionForRest(notifyPath, {
|
|
8420
9121
|
filter: collectionRequest.filter,
|
|
8421
9122
|
logical: collectionRequest.logical,
|
|
8422
9123
|
orderBy: collectionRequest.orderBy,
|
|
@@ -8424,8 +9125,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8424
9125
|
limit: collectionRequest.limit,
|
|
8425
9126
|
offset: collectionRequest.offset,
|
|
8426
9127
|
startAfter: collectionRequest.startAfter,
|
|
8427
|
-
|
|
8428
|
-
|
|
9128
|
+
searchString: collectionRequest.searchString,
|
|
9129
|
+
searchExplain: collectionRequest.searchExplain,
|
|
9130
|
+
databaseId: collectionRequest.databaseId,
|
|
9131
|
+
fields: collectionRequest.fields,
|
|
9132
|
+
distinct: collectionRequest.distinct
|
|
9133
|
+
}, collectionRequest.include);
|
|
8429
9134
|
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
8430
9135
|
const resolvedCollection = collection ? {
|
|
8431
9136
|
...collection,
|
|
@@ -8467,27 +9172,78 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8467
9172
|
}));
|
|
8468
9173
|
}
|
|
8469
9174
|
return fetchedEntities;
|
|
8470
|
-
});
|
|
9175
|
+
}));
|
|
8471
9176
|
}
|
|
8472
|
-
|
|
9177
|
+
return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchCollectionForRest(notifyPath, {
|
|
8473
9178
|
filter: collectionRequest.filter,
|
|
8474
9179
|
logical: collectionRequest.logical,
|
|
8475
9180
|
orderBy: collectionRequest.orderBy,
|
|
8476
9181
|
order: collectionRequest.order,
|
|
8477
9182
|
limit: collectionRequest.limit,
|
|
9183
|
+
offset: collectionRequest.offset,
|
|
9184
|
+
startAfter: collectionRequest.startAfter,
|
|
9185
|
+
searchString: collectionRequest.searchString,
|
|
9186
|
+
searchExplain: collectionRequest.searchExplain,
|
|
8478
9187
|
databaseId: collectionRequest.databaseId,
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
9188
|
+
fields: collectionRequest.fields,
|
|
9189
|
+
distinct: collectionRequest.distinct
|
|
9190
|
+
}, collectionRequest.include));
|
|
9191
|
+
}
|
|
9192
|
+
/**
|
|
9193
|
+
* The `meta` a `collection_update` frame carries beside its rows.
|
|
9194
|
+
*
|
|
9195
|
+
* Frames used to carry rows and primary keys and nothing else, so the
|
|
9196
|
+
* client issued a `GET /count` **per push** to fill in a total it needed to
|
|
9197
|
+
* render the same list it had just been handed — one extra round trip per
|
|
9198
|
+
* write, per subscriber, forever. The refetch already knows the query; it
|
|
9199
|
+
* counts once, here, under the same RLS transaction that read the rows, so
|
|
9200
|
+
* the total describes the same set they came from.
|
|
9201
|
+
*/
|
|
9202
|
+
async collectionMetaWithAuth(notifyPath, collectionRequest, rows, authContext) {
|
|
9203
|
+
const limit = collectionRequest.limit ?? rows.length;
|
|
9204
|
+
const offset = collectionRequest.offset ?? 0;
|
|
9205
|
+
const countOnce = async (service) => service.count(notifyPath, {
|
|
8482
9206
|
filter: collectionRequest.filter,
|
|
8483
9207
|
logical: collectionRequest.logical,
|
|
8484
|
-
|
|
8485
|
-
order: collectionRequest.order,
|
|
8486
|
-
limit: collectionRequest.limit,
|
|
8487
|
-
offset: collectionRequest.offset,
|
|
8488
|
-
startAfter: collectionRequest.startAfter,
|
|
9208
|
+
searchString: collectionRequest.searchString,
|
|
8489
9209
|
databaseId: collectionRequest.databaseId
|
|
8490
9210
|
});
|
|
9211
|
+
let total;
|
|
9212
|
+
try {
|
|
9213
|
+
if (this.driver) {
|
|
9214
|
+
const activeAuth = authContext || {
|
|
9215
|
+
uid: ANONYMOUS_USER_ID,
|
|
9216
|
+
roles: ["anon"]
|
|
9217
|
+
};
|
|
9218
|
+
total = await this.db.transaction(async (tx) => {
|
|
9219
|
+
await applyAuthContext(tx, {
|
|
9220
|
+
uid: activeAuth.uid,
|
|
9221
|
+
roles: activeAuth.roles,
|
|
9222
|
+
isAnonymous: activeAuth.isAnonymous === true,
|
|
9223
|
+
claims: activeAuth.claims
|
|
9224
|
+
}, this.rlsUserRole);
|
|
9225
|
+
return countOnce(new DataService(tx, this.registry));
|
|
9226
|
+
});
|
|
9227
|
+
} else total = await countOnce(this.dataService);
|
|
9228
|
+
} catch (error) {
|
|
9229
|
+
logger.warn(`[RealtimeService] Could not count '${notifyPath}' for a subscription frame`, { error });
|
|
9230
|
+
return {
|
|
9231
|
+
limit,
|
|
9232
|
+
offset,
|
|
9233
|
+
hasMore: false,
|
|
9234
|
+
partial: true
|
|
9235
|
+
};
|
|
9236
|
+
}
|
|
9237
|
+
const last = rows[rows.length - 1];
|
|
9238
|
+
const hasMore = offset + rows.length < total;
|
|
9239
|
+
const nextCursor = hasMore && last ? this.dataService.cursorFor?.(notifyPath, last, normalizeDriverOrderBy(collectionRequest.orderBy, collectionRequest.order)) : void 0;
|
|
9240
|
+
return {
|
|
9241
|
+
total,
|
|
9242
|
+
limit,
|
|
9243
|
+
offset,
|
|
9244
|
+
hasMore,
|
|
9245
|
+
...nextCursor && { nextCursor }
|
|
9246
|
+
};
|
|
8491
9247
|
}
|
|
8492
9248
|
/**
|
|
8493
9249
|
* Debounce an row refetch for a WebSocket subscription.
|
|
@@ -8538,13 +9294,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8538
9294
|
uid: ANONYMOUS_USER_ID,
|
|
8539
9295
|
roles: ["anon"]
|
|
8540
9296
|
};
|
|
8541
|
-
return await this.db.transaction(async (tx) => {
|
|
9297
|
+
return await withFieldViewer({ roles: activeAuth.roles ?? [] }, async () => await this.db.transaction(async (tx) => {
|
|
8542
9298
|
await applyAuthContext(tx, {
|
|
8543
9299
|
uid: activeAuth.uid,
|
|
8544
9300
|
roles: activeAuth.roles,
|
|
8545
|
-
isAnonymous: activeAuth.isAnonymous === true
|
|
9301
|
+
isAnonymous: activeAuth.isAnonymous === true,
|
|
9302
|
+
claims: activeAuth.claims
|
|
8546
9303
|
}, this.rlsUserRole);
|
|
8547
|
-
let processedEntity = await new DataService(tx, this.registry).
|
|
9304
|
+
let processedEntity = await new DataService(tx, this.registry).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
|
|
8548
9305
|
if (processedEntity) {
|
|
8549
9306
|
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
8550
9307
|
const resolvedCollection = collection ? {
|
|
@@ -8584,16 +9341,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8584
9341
|
}
|
|
8585
9342
|
}
|
|
8586
9343
|
return processedEntity;
|
|
8587
|
-
});
|
|
9344
|
+
}));
|
|
8588
9345
|
}
|
|
8589
|
-
return await this.dataService.
|
|
9346
|
+
return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchOneForRest(notifyPath, id) ?? void 0);
|
|
8590
9347
|
}
|
|
8591
|
-
sendCollectionUpdate(clientId, subscriptionId, rows, path) {
|
|
9348
|
+
sendCollectionUpdate(clientId, subscriptionId, rows, path, meta) {
|
|
8592
9349
|
const message = {
|
|
8593
9350
|
type: "collection_update",
|
|
8594
9351
|
subscriptionId,
|
|
8595
9352
|
rows,
|
|
8596
|
-
pks: this.primaryKeysForPath(path)
|
|
9353
|
+
pks: this.primaryKeysForPath(path),
|
|
9354
|
+
...meta && { meta }
|
|
8597
9355
|
};
|
|
8598
9356
|
this.sendMessage(clientId, message);
|
|
8599
9357
|
}
|
|
@@ -9750,7 +10508,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
9750
10508
|
uid: jwtPayload.uid,
|
|
9751
10509
|
roles: jwtPayload.roles ?? [],
|
|
9752
10510
|
isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin"),
|
|
9753
|
-
isAnonymous: jwtPayload.isAnonymous === true
|
|
10511
|
+
isAnonymous: jwtPayload.isAnonymous === true,
|
|
10512
|
+
claims: jwtPayload.claims
|
|
9754
10513
|
};
|
|
9755
10514
|
}
|
|
9756
10515
|
if (verifiedUser) {
|
|
@@ -9825,7 +10584,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
9825
10584
|
if (!path || !values || typeof values !== "object") return;
|
|
9826
10585
|
const collection = driver.registry?.getCollectionByPath(path);
|
|
9827
10586
|
if (!collection) return;
|
|
9828
|
-
assertWriteRequestValid(values, collection);
|
|
10587
|
+
assertWriteRequestValid(values, collection, { viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] } });
|
|
10588
|
+
assertFieldOpsValid(values, collection);
|
|
9829
10589
|
};
|
|
9830
10590
|
const getScopedDelegate = async () => {
|
|
9831
10591
|
const session = clientSessions.get(clientId);
|
|
@@ -9837,7 +10597,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
9837
10597
|
photoURL: null,
|
|
9838
10598
|
providerId: "websocket",
|
|
9839
10599
|
isAnonymous: false,
|
|
9840
|
-
roles: session.user.roles ?? []
|
|
10600
|
+
roles: session.user.roles ?? [],
|
|
10601
|
+
claims: session.user.claims
|
|
9841
10602
|
} : {
|
|
9842
10603
|
uid: ANONYMOUS_USER_ID,
|
|
9843
10604
|
displayName: null,
|
|
@@ -10146,7 +10907,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10146
10907
|
const authContext = session?.user ? {
|
|
10147
10908
|
uid: session.user.uid,
|
|
10148
10909
|
roles: session.user.roles ?? [],
|
|
10149
|
-
isAnonymous: session.user.isAnonymous === true
|
|
10910
|
+
isAnonymous: session.user.isAnonymous === true,
|
|
10911
|
+
claims: session.user.claims
|
|
10150
10912
|
} : {
|
|
10151
10913
|
uid: ANONYMOUS_USER_ID,
|
|
10152
10914
|
roles: ["anon"],
|
|
@@ -10353,7 +11115,7 @@ function createBackupCron(config) {
|
|
|
10353
11115
|
enabled: config.enabled ?? true,
|
|
10354
11116
|
timeoutSeconds: 3600,
|
|
10355
11117
|
async handler({ log }) {
|
|
10356
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
11118
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-DA7a6SUV.js").then((n) => n.r);
|
|
10357
11119
|
const { destination } = config;
|
|
10358
11120
|
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 }).`);
|
|
10359
11121
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -10425,22 +11187,21 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
|
|
|
10425
11187
|
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
10426
11188
|
var asColumns = (value) => Array.isArray(value) ? value : [value];
|
|
10427
11189
|
/**
|
|
10428
|
-
* Distinguish "this column name is wrong" from "
|
|
11190
|
+
* Distinguish "this column name is wrong" from "this database was never
|
|
11191
|
+
* migrated".
|
|
10429
11192
|
*
|
|
10430
|
-
* They present identically here — a relation asks for a column the
|
|
10431
|
-
*
|
|
10432
|
-
*
|
|
10433
|
-
* projects.
|
|
11193
|
+
* They present identically here — a relation asks for a column the table does
|
|
11194
|
+
* not have — but they are opposite problems with opposite fixes, and getting
|
|
11195
|
+
* them the wrong way round is how the 0.12 → 0.13 upgrade bricked projects.
|
|
10434
11196
|
*
|
|
10435
|
-
*
|
|
10436
|
-
*
|
|
10437
|
-
*
|
|
10438
|
-
*
|
|
10439
|
-
* the
|
|
10440
|
-
*
|
|
10441
|
-
*
|
|
10442
|
-
*
|
|
10443
|
-
* pinning a column that no longer exists.
|
|
11197
|
+
* 0.13 changed the rule that derives foreign-key names: `categories` yields
|
|
11198
|
+
* `category_id` where it used to yield `categorie_id`. Boot-ensure renames the
|
|
11199
|
+
* database column to match — but only in a process that provisions. Where
|
|
11200
|
+
* nothing has, the database still carries the old spelling while the config
|
|
11201
|
+
* derives the new one, and the generic fix ("set `through.targetColumn` to one
|
|
11202
|
+
* of: …", listing the legacy name because that is what the table still has)
|
|
11203
|
+
* talks the reader into pinning a column that is about to be renamed out from
|
|
11204
|
+
* under them.
|
|
10444
11205
|
*
|
|
10445
11206
|
* So when the wanted name is what the current rule derives, and the table
|
|
10446
11207
|
* carries what the *previous* rule would have derived from the same source, say
|
|
@@ -10468,8 +11229,8 @@ function staleCodegenRename(wanted, available, sources) {
|
|
|
10468
11229
|
/** The shared explanation, so every relation kind reports it identically. */
|
|
10469
11230
|
function staleCodegenDefect(table, { legacy, current }) {
|
|
10470
11231
|
return {
|
|
10471
|
-
problem: `the
|
|
10472
|
-
fix: `
|
|
11232
|
+
problem: `the database still has \`${legacy}\` on \`${table}\`, but this release derives \`${current}\` — the column predates the foreign-key naming fix and nothing has renamed it yet`,
|
|
11233
|
+
fix: `let boot-ensure rename it (redeploy, or start with schema provisioning enabled), or run \`rebase db push\`. Then run \`rebase schema generate\` so the committed schema file agrees. To keep \`${legacy}\` instead, name it explicitly on the relation.`
|
|
10473
11234
|
};
|
|
10474
11235
|
}
|
|
10475
11236
|
/**
|
|
@@ -10648,16 +11409,13 @@ function assertRelationsResolve(collections, registry) {
|
|
|
10648
11409
|
const defects = findRelationDefects(collections, registry);
|
|
10649
11410
|
if (defects.length === 0) return;
|
|
10650
11411
|
const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
|
|
10651
|
-
throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against
|
|
11412
|
+
throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database this server is connected to.
|
|
10652
11413
|
|
|
10653
11414
|
Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
|
|
10654
11415
|
|
|
10655
|
-
If the
|
|
10656
|
-
|
|
10657
|
-
|
|
10658
|
-
rebase schema generate
|
|
10659
|
-
|
|
10660
|
-
If it is already current, then the collection is what disagrees with it:
|
|
11416
|
+
If the schema has not been applied to this database yet, apply it — \`rebase db push\`
|
|
11417
|
+
(dev), \`rebase db migrate\` (prod), or a redeploy, which creates tables at boot. If it
|
|
11418
|
+
has, then the collection is what disagrees with the database:
|
|
10661
11419
|
|
|
10662
11420
|
` + lines.join("\n\n") + "\n");
|
|
10663
11421
|
}
|
|
@@ -11907,11 +12665,30 @@ var HistoryService = class {
|
|
|
11907
12665
|
};
|
|
11908
12666
|
}
|
|
11909
12667
|
/**
|
|
11910
|
-
* Record a history entry for
|
|
11911
|
-
*
|
|
11912
|
-
*
|
|
12668
|
+
* Record a history entry for a row change.
|
|
12669
|
+
*
|
|
12670
|
+
* ## Why this is allowed to fail the write
|
|
11913
12671
|
*
|
|
11914
|
-
*
|
|
12672
|
+
* It used to swallow every error into a log line, and the driver called it
|
|
12673
|
+
* without `await`, so the promise was dropped on the floor. Both halves of
|
|
12674
|
+
* that made the same claim: that the audit trail is a nice-to-have. It is
|
|
12675
|
+
* not — `history: true` is opted into by collections whose changes somebody
|
|
12676
|
+
* has to be able to reconstruct, and a trail with gaps is worse than no
|
|
12677
|
+
* trail, because nothing distinguishes "no change was made" from "the entry
|
|
12678
|
+
* did not get written". The gaps were silent and unbounded: a transient
|
|
12679
|
+
* error on the history insert lost the entry for a row that committed
|
|
12680
|
+
* anyway, and nobody found out.
|
|
12681
|
+
*
|
|
12682
|
+
* So it throws, and the driver awaits it inside the write's transaction.
|
|
12683
|
+
* The row and its history entry commit together or neither does. That is a
|
|
12684
|
+
* real trade — a broken `rebase.entity_history` now fails writes to the
|
|
12685
|
+
* collections that declare history, where before it failed quietly — and it
|
|
12686
|
+
* is the right side of it for a table that exists to answer "who changed
|
|
12687
|
+
* this". The table is created at boot (`ensureHistoryTableExists`), so the
|
|
12688
|
+
* failure mode is a database that is broken in other ways too.
|
|
12689
|
+
*
|
|
12690
|
+
* The pruning pass stays non-blocking: a prune that does not run leaves
|
|
12691
|
+
* *extra* history, which is not a loss and is fixed by the next write.
|
|
11915
12692
|
*/
|
|
11916
12693
|
async recordHistory(params) {
|
|
11917
12694
|
const { tableName, id, action, values, previousValues, updatedBy } = params;
|
|
@@ -11919,7 +12696,7 @@ var HistoryService = class {
|
|
|
11919
12696
|
if (action === "update" && (!changedFields || changedFields.length === 0)) return;
|
|
11920
12697
|
try {
|
|
11921
12698
|
await this.db.execute(sql`
|
|
11922
|
-
INSERT INTO rebase.entity_history
|
|
12699
|
+
INSERT INTO rebase.entity_history
|
|
11923
12700
|
(table_name, entity_id, action, changed_fields, "values", previous_values, updated_by)
|
|
11924
12701
|
VALUES (
|
|
11925
12702
|
${tableName},
|
|
@@ -11931,10 +12708,11 @@ var HistoryService = class {
|
|
|
11931
12708
|
${updatedBy ?? null}
|
|
11932
12709
|
)
|
|
11933
12710
|
`);
|
|
11934
|
-
this.pruneEntity(tableName, id).catch((err) => logger.error("History prune failed", { error: err }));
|
|
11935
12711
|
} catch (error) {
|
|
11936
12712
|
logger.error("Failed to record row history", { error });
|
|
12713
|
+
throw new Error(`Could not record the history entry for "${tableName}" (${String(id)}). The write was rolled back: this collection declares \`history: true\`, and a row that commits without its audit entry leaves a gap nothing can distinguish from "no change was made".`, { cause: error });
|
|
11937
12714
|
}
|
|
12715
|
+
this.pruneEntity(tableName, id).catch((err) => logger.error("History prune failed", { error: err }));
|
|
11938
12716
|
}
|
|
11939
12717
|
/**
|
|
11940
12718
|
* Fetch history entries for an row, ordered by most recent first.
|
|
@@ -12221,6 +12999,12 @@ async function introspectSchema(client, pgSchema) {
|
|
|
12221
12999
|
c.udt_name,
|
|
12222
13000
|
c.is_nullable,
|
|
12223
13001
|
c.column_default,
|
|
13002
|
+
-- A stored generated column must be declared as one, or drizzle
|
|
13003
|
+
-- writes DEFAULT into it on every INSERT. Postgres tolerates that
|
|
13004
|
+
-- today; the generated module has always declared them properly,
|
|
13005
|
+
-- and the runtime tables are built to match it.
|
|
13006
|
+
c.is_generated,
|
|
13007
|
+
c.generation_expression,
|
|
12224
13008
|
(SELECT a.atttypmod FROM pg_attribute a
|
|
12225
13009
|
JOIN pg_class pc ON a.attrelid = pc.oid
|
|
12226
13010
|
WHERE pc.relname = c.table_name
|
|
@@ -12372,18 +13156,40 @@ function buildCollectionsFromSchema({ tablesMap, enumMap, joinTables }, pgSchema
|
|
|
12372
13156
|
/**
|
|
12373
13157
|
* Build drizzle tables at runtime from an introspected schema.
|
|
12374
13158
|
*
|
|
12375
|
-
*
|
|
12376
|
-
*
|
|
12377
|
-
*
|
|
12378
|
-
* `
|
|
13159
|
+
* **Every** data source's tables are built here, from `information_schema`,
|
|
13160
|
+
* after boot-ensure has run — a project with declared collections included. The
|
|
13161
|
+
* committed `schema.generated.ts` is a build artefact for Atlas, `db push`,
|
|
13162
|
+
* `eject` and user code; the runtime does not read it for its tables, because a
|
|
13163
|
+
* file that is one commit behind the database used to change what the server
|
|
13164
|
+
* served (a renamed foreign-key column 500'd every request, a lost relation
|
|
13165
|
+
* silently emptied a list). See `catalogue-schema.ts` for the caller.
|
|
12379
13166
|
*
|
|
12380
13167
|
* These are handed to drizzle as its schema, which keeps the relational query
|
|
12381
13168
|
* path (`db.query.*`) working; without them FetchService would fall back to
|
|
12382
13169
|
* plain selects and lose relation loading.
|
|
13170
|
+
*
|
|
13171
|
+
* Two things a caller has to supply for the tables to match what the generated
|
|
13172
|
+
* module used to produce, both about *names*: the Drizzle object is keyed by the
|
|
13173
|
+
* wire name of a column and not by the column (`authorId`, not `author_id`), and
|
|
13174
|
+
* a relation is keyed by the collection's own relation key rather than by a
|
|
13175
|
+
* guess made from the foreign key. Both come from the collections — see
|
|
13176
|
+
* `columnKeysFromCollections` and `buildDrizzleRelationsFromCollections`.
|
|
12383
13177
|
*/
|
|
13178
|
+
var columnNameAsKey = (_table, column) => column;
|
|
12384
13179
|
/** drizzle ships no bytea builder; binary must round-trip as a Buffer. */
|
|
12385
13180
|
var bytea = customType({ dataType: () => "bytea" });
|
|
12386
13181
|
/**
|
|
13182
|
+
* A full-text search column, declared with its real SQL type.
|
|
13183
|
+
*
|
|
13184
|
+
* Not cosmetic: `isSearchIndexColumn` asks the column for its type and drops
|
|
13185
|
+
* every `tsvector` from the projection, so a search column that fell through to
|
|
13186
|
+
* `text` (the old default for an unrecognised type) would be selected, returned
|
|
13187
|
+
* to callers, and rendered in the admin as a wall of lexemes. drizzle ships no
|
|
13188
|
+
* builder for these two.
|
|
13189
|
+
*/
|
|
13190
|
+
var tsvector = customType({ dataType: () => "tsvector" });
|
|
13191
|
+
var tsquery = customType({ dataType: () => "tsquery" });
|
|
13192
|
+
/**
|
|
12387
13193
|
* Postgres stores a column's type modifier (varchar length, vector dimensions)
|
|
12388
13194
|
* in `atttypmod`. For length-carrying string types it is length + VARHDRSZ(4);
|
|
12389
13195
|
* for pgvector it is the dimension count as-is. -1 means unspecified.
|
|
@@ -12415,8 +13221,11 @@ function scalarBuilder(udtName, name, col) {
|
|
|
12415
13221
|
case "date": return date(name);
|
|
12416
13222
|
case "time": return time(name);
|
|
12417
13223
|
case "timetz": return time(name, { withTimezone: true });
|
|
12418
|
-
case "timestamp": return timestamp(name);
|
|
12419
|
-
case "timestamptz": return timestamp(name, {
|
|
13224
|
+
case "timestamp": return timestamp(name, { mode: "string" });
|
|
13225
|
+
case "timestamptz": return timestamp(name, {
|
|
13226
|
+
withTimezone: true,
|
|
13227
|
+
mode: "string"
|
|
13228
|
+
});
|
|
12420
13229
|
case "interval": return interval(name);
|
|
12421
13230
|
case "bytea": return bytea(name);
|
|
12422
13231
|
case "inet": return inet(name);
|
|
@@ -12438,6 +13247,8 @@ function scalarBuilder(udtName, name, col) {
|
|
|
12438
13247
|
const length = varlenLength(col);
|
|
12439
13248
|
return length ? varchar(name, { length }) : text(name);
|
|
12440
13249
|
}
|
|
13250
|
+
case "tsvector": return tsvector(name);
|
|
13251
|
+
case "tsquery": return tsquery(name);
|
|
12441
13252
|
default: return text(name);
|
|
12442
13253
|
}
|
|
12443
13254
|
}
|
|
@@ -12447,20 +13258,26 @@ function columnBuilderFor(col) {
|
|
|
12447
13258
|
}
|
|
12448
13259
|
/**
|
|
12449
13260
|
* Build one drizzle table per introspected table, keyed by table name.
|
|
13261
|
+
*
|
|
13262
|
+
* @param columnKey how a column is named on the wire — see {@link ColumnKeyResolver}.
|
|
12450
13263
|
*/
|
|
12451
|
-
function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public") {
|
|
13264
|
+
function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public", columnKey = columnNameAsKey) {
|
|
12452
13265
|
const schema = pgSchemaName === "public" ? null : pgSchema(pgSchemaName);
|
|
12453
13266
|
const createTable = schema ? schema.table.bind(schema) : pgTable;
|
|
12454
13267
|
const tables = {};
|
|
12455
13268
|
for (const [tableName, meta] of tablesMap) {
|
|
12456
13269
|
const columns = {};
|
|
13270
|
+
const keyOf = /* @__PURE__ */ new Map();
|
|
12457
13271
|
for (const col of meta.columns) {
|
|
12458
13272
|
let builder = columnBuilderFor(col);
|
|
12459
13273
|
if (col.is_nullable === "NO") builder = builder.notNull();
|
|
12460
13274
|
if (meta.pks.length === 1 && meta.pks[0] === col.column_name) builder = builder.primaryKey();
|
|
12461
|
-
|
|
13275
|
+
if (col.is_generated === "ALWAYS") builder = builder.generatedAlwaysAs(sql.raw(col.generation_expression ?? ""));
|
|
13276
|
+
const key = columnKey(tableName, col.column_name);
|
|
13277
|
+
keyOf.set(col.column_name, key);
|
|
13278
|
+
columns[key] = builder;
|
|
12462
13279
|
}
|
|
12463
|
-
tables[tableName] = createTable(tableName, columns, meta.pks.length > 1 ? (t) => [primaryKey({ columns: meta.pks.map((pk) => t[pk]) })] : void 0);
|
|
13280
|
+
tables[tableName] = createTable(tableName, columns, meta.pks.length > 1 ? (t) => [primaryKey({ columns: meta.pks.map((pk) => t[keyOf.get(pk) ?? pk]) })] : void 0);
|
|
12464
13281
|
}
|
|
12465
13282
|
return tables;
|
|
12466
13283
|
}
|
|
@@ -12475,29 +13292,29 @@ function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public") {
|
|
|
12475
13292
|
* The keys here must match `buildRelations` in introspect-runtime.ts, which is
|
|
12476
13293
|
* what names the collection's relation properties.
|
|
12477
13294
|
*/
|
|
12478
|
-
function buildDrizzleRelationsFromSchema(tablesMap, tables) {
|
|
13295
|
+
function buildDrizzleRelationsFromSchema(tablesMap, tables, columnKey = columnNameAsKey) {
|
|
12479
13296
|
/** Owning side, per table: the `one()` relations from its foreign keys. */
|
|
12480
13297
|
const owning = /* @__PURE__ */ new Map();
|
|
12481
13298
|
/** Inverse side, per referenced table: the matching `many()` back-references. */
|
|
12482
13299
|
const inverse = /* @__PURE__ */ new Map();
|
|
12483
13300
|
for (const [tableName, meta] of tablesMap) {
|
|
12484
13301
|
if (!tables[tableName]) continue;
|
|
12485
|
-
const columnNames = new Set(meta.columns.map((c) => c.column_name));
|
|
13302
|
+
const columnNames = new Set(meta.columns.map((c) => columnKey(tableName, c.column_name)));
|
|
12486
13303
|
for (const fk of meta.fks) {
|
|
12487
13304
|
if (!tables[fk.foreign_table_name]) continue;
|
|
12488
|
-
let key = fk.column_name.replace(/_id$/, "");
|
|
13305
|
+
let key = toWireKey(fk.column_name.replace(/_id$/, ""));
|
|
12489
13306
|
if (meta.pks.includes(fk.column_name) && key === fk.column_name) key = fk.foreign_table_name;
|
|
12490
13307
|
if (columnNames.has(key)) continue;
|
|
12491
13308
|
const relationName = `${tableName}_${fk.column_name}`;
|
|
12492
13309
|
owning.set(tableName, [...owning.get(tableName) ?? [], {
|
|
12493
13310
|
key,
|
|
12494
13311
|
targetTable: fk.foreign_table_name,
|
|
12495
|
-
fkColumn: fk.column_name,
|
|
12496
|
-
targetColumn: fk.foreign_column_name,
|
|
13312
|
+
fkColumn: columnKey(tableName, fk.column_name),
|
|
13313
|
+
targetColumn: columnKey(fk.foreign_table_name, fk.foreign_column_name),
|
|
12497
13314
|
relationName
|
|
12498
13315
|
}]);
|
|
12499
13316
|
const backKey = tableName;
|
|
12500
|
-
if (new Set((tablesMap.get(fk.foreign_table_name)?.columns ?? []).map((c) => c.column_name)).has(backKey)) continue;
|
|
13317
|
+
if (new Set((tablesMap.get(fk.foreign_table_name)?.columns ?? []).map((c) => columnKey(fk.foreign_table_name, c.column_name))).has(backKey)) continue;
|
|
12501
13318
|
inverse.set(fk.foreign_table_name, [...inverse.get(fk.foreign_table_name) ?? [], {
|
|
12502
13319
|
key: backKey,
|
|
12503
13320
|
sourceTable: tableName,
|
|
@@ -12528,6 +13345,584 @@ function buildDrizzleRelationsFromSchema(tablesMap, tables) {
|
|
|
12528
13345
|
return built;
|
|
12529
13346
|
}
|
|
12530
13347
|
//#endregion
|
|
13348
|
+
//#region src/schema/config-relations.ts
|
|
13349
|
+
/**
|
|
13350
|
+
* Build drizzle `relations()` for declared collections, from the collections.
|
|
13351
|
+
*
|
|
13352
|
+
* The heuristic builder in `dynamic-tables.ts` names a relation after the
|
|
13353
|
+
* foreign-key column (`author_id` → `author`), which is the only evidence a
|
|
13354
|
+
* database offers and is right for BaaS mode. It is wrong for a project that
|
|
13355
|
+
* declares its collections: there the relation's name is whatever the author
|
|
13356
|
+
* called it, `include=writer` addresses that name, and a relation whose key was
|
|
13357
|
+
* guessed from the column simply does not answer to it — the read returns the
|
|
13358
|
+
* bare foreign key and no target, silently, which is how a demo lost every
|
|
13359
|
+
* relation in its list view while the detail view (a different code path) kept
|
|
13360
|
+
* working.
|
|
13361
|
+
*
|
|
13362
|
+
* So when there are collections, they are the authority for *which* relations
|
|
13363
|
+
* exist and what they are called; the live catalogue is the authority for the
|
|
13364
|
+
* columns underneath. This is the same pairing `generate-drizzle-schema-logic`
|
|
13365
|
+
* emits into `schema.generated.ts` — kind for kind, key for key, and paired by
|
|
13366
|
+
* the same {@link sharedRelationName} — only built against tables read back from
|
|
13367
|
+
* `information_schema` rather than against a file.
|
|
13368
|
+
*/
|
|
13369
|
+
/** A collection's table, with any schema prefix stripped — as the DDL creates it. */
|
|
13370
|
+
function bareTableName(name) {
|
|
13371
|
+
return name.includes(".") ? name.split(".").pop() : name;
|
|
13372
|
+
}
|
|
13373
|
+
/**
|
|
13374
|
+
* The field key a collection's rows are addressed by, as the generated module
|
|
13375
|
+
* spells it: the property marked `isId`, else `id`.
|
|
13376
|
+
*
|
|
13377
|
+
* Deliberately the config's answer rather than the catalogue's primary key —
|
|
13378
|
+
* `references: [users.id]` in the generated file is keyed the same way, and the
|
|
13379
|
+
* two have to name the same Drizzle key for a relation to compile at all.
|
|
13380
|
+
*/
|
|
13381
|
+
function primaryKeyFieldKey(collection) {
|
|
13382
|
+
for (const [key, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && Boolean(prop.isId)) return key;
|
|
13383
|
+
return "id";
|
|
13384
|
+
}
|
|
13385
|
+
/**
|
|
13386
|
+
* A column of a built table, by its Drizzle key or by its column name.
|
|
13387
|
+
*
|
|
13388
|
+
* The key is the answer in the ordinary case, and the name is the fallback for
|
|
13389
|
+
* the one place a caller holds a column and not a field: a junction table's two
|
|
13390
|
+
* foreign keys, which are keyed by column *unless* the project also declares a
|
|
13391
|
+
* collection over that table, in which case they are keyed by its property
|
|
13392
|
+
* names. Looking both up costs a scan of one table and removes the difference.
|
|
13393
|
+
*/
|
|
13394
|
+
function columnOf(table, key) {
|
|
13395
|
+
if (!table) return void 0;
|
|
13396
|
+
const direct = table[key];
|
|
13397
|
+
if (direct) return direct;
|
|
13398
|
+
for (const column of Object.values(getTableColumns(table))) if (column?.name === key) return column;
|
|
13399
|
+
}
|
|
13400
|
+
/**
|
|
13401
|
+
* Drizzle relations for every declared collection, keyed `<table>Relations`.
|
|
13402
|
+
*
|
|
13403
|
+
* @param collections every collection backed by a table in `tables`
|
|
13404
|
+
* @param tables catalogue-built tables, keyed by bare table name
|
|
13405
|
+
*/
|
|
13406
|
+
function buildDrizzleRelationsFromCollections(collections, tables) {
|
|
13407
|
+
const ones = /* @__PURE__ */ new Map();
|
|
13408
|
+
const manys = /* @__PURE__ */ new Map();
|
|
13409
|
+
/** `<relationName>::<kind>` already emitted on a table — the generator dedupes the same way. */
|
|
13410
|
+
const emitted = /* @__PURE__ */ new Map();
|
|
13411
|
+
const claim = (table, name, kind) => {
|
|
13412
|
+
const seen = emitted.get(table) ?? /* @__PURE__ */ new Set();
|
|
13413
|
+
emitted.set(table, seen);
|
|
13414
|
+
const key = `${name}::${kind}`;
|
|
13415
|
+
if (seen.has(key)) return false;
|
|
13416
|
+
seen.add(key);
|
|
13417
|
+
return true;
|
|
13418
|
+
};
|
|
13419
|
+
const addOne = (table, spec) => ones.set(table, [...ones.get(table) ?? [], spec]);
|
|
13420
|
+
const addMany = (table, spec) => manys.set(table, [...manys.get(table) ?? [], spec]);
|
|
13421
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
13422
|
+
for (const collection of collections) {
|
|
13423
|
+
const table = bareTableName(getTableName$1(collection));
|
|
13424
|
+
if (table && tables[table]) byTable.set(table, collection);
|
|
13425
|
+
}
|
|
13426
|
+
for (const [tableName, collection] of byTable) {
|
|
13427
|
+
const table = tables[tableName];
|
|
13428
|
+
for (const [relationKey, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
13429
|
+
let target;
|
|
13430
|
+
try {
|
|
13431
|
+
target = relation.target();
|
|
13432
|
+
} catch (err) {
|
|
13433
|
+
logger.debug(`[schema] Relation ${collection.slug}.${relationKey} has no resolvable target`, { error: err });
|
|
13434
|
+
continue;
|
|
13435
|
+
}
|
|
13436
|
+
const targetTable = tables[bareTableName(getTableName$1(target))];
|
|
13437
|
+
const name = sharedRelationName(relation, collection);
|
|
13438
|
+
switch (relation.kind) {
|
|
13439
|
+
case "belongsTo": {
|
|
13440
|
+
if (!targetTable) continue;
|
|
13441
|
+
if (!claim(tableName, name, relation.kind)) continue;
|
|
13442
|
+
const localKey = fieldKeyForColumn(collection, relation.localKey);
|
|
13443
|
+
const referenced = primaryKeyFieldKey(target);
|
|
13444
|
+
const field = columnOf(table, localKey);
|
|
13445
|
+
const reference = columnOf(targetTable, referenced);
|
|
13446
|
+
if (!field || !reference) continue;
|
|
13447
|
+
addOne(tableName, {
|
|
13448
|
+
key: relationKey,
|
|
13449
|
+
target: targetTable,
|
|
13450
|
+
fields: [field],
|
|
13451
|
+
references: [reference],
|
|
13452
|
+
relationName: name
|
|
13453
|
+
});
|
|
13454
|
+
break;
|
|
13455
|
+
}
|
|
13456
|
+
case "hasOne":
|
|
13457
|
+
if (!targetTable) continue;
|
|
13458
|
+
if (!claim(tableName, name, relation.kind)) continue;
|
|
13459
|
+
addOne(tableName, {
|
|
13460
|
+
key: relationKey,
|
|
13461
|
+
target: targetTable,
|
|
13462
|
+
relationName: name
|
|
13463
|
+
});
|
|
13464
|
+
break;
|
|
13465
|
+
case "hasMany":
|
|
13466
|
+
if (!targetTable) continue;
|
|
13467
|
+
if (!claim(tableName, name, relation.kind)) continue;
|
|
13468
|
+
addMany(tableName, {
|
|
13469
|
+
key: relationKey,
|
|
13470
|
+
target: targetTable,
|
|
13471
|
+
relationName: name
|
|
13472
|
+
});
|
|
13473
|
+
break;
|
|
13474
|
+
case "manyToMany": {
|
|
13475
|
+
const junctionName = bareTableName(relation.through.table);
|
|
13476
|
+
const junction = tables[junctionName];
|
|
13477
|
+
if (!junction) continue;
|
|
13478
|
+
if (!claim(tableName, name, relation.kind)) continue;
|
|
13479
|
+
addMany(tableName, {
|
|
13480
|
+
key: relationKey,
|
|
13481
|
+
target: junction,
|
|
13482
|
+
relationName: name
|
|
13483
|
+
});
|
|
13484
|
+
addJunctionSides(relation, collection, target, junctionName, tables, addOne, claim);
|
|
13485
|
+
break;
|
|
13486
|
+
}
|
|
13487
|
+
case "via": break;
|
|
13488
|
+
}
|
|
13489
|
+
}
|
|
13490
|
+
}
|
|
13491
|
+
for (const [tableName, collection] of byTable) {
|
|
13492
|
+
const table = tables[tableName];
|
|
13493
|
+
for (const [, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
13494
|
+
if (relation.kind !== "hasMany" && relation.kind !== "hasOne") continue;
|
|
13495
|
+
let target;
|
|
13496
|
+
try {
|
|
13497
|
+
target = relation.target();
|
|
13498
|
+
} catch {
|
|
13499
|
+
continue;
|
|
13500
|
+
}
|
|
13501
|
+
const targetTableName = bareTableName(getTableName$1(target));
|
|
13502
|
+
const targetTable = tables[targetTableName];
|
|
13503
|
+
if (!targetTable) continue;
|
|
13504
|
+
const name = sharedRelationName(relation, collection);
|
|
13505
|
+
if (!claim(targetTableName, name, "belongsTo")) continue;
|
|
13506
|
+
const fkKey = fieldKeyForColumn(target, relation.foreignKeyOnTarget);
|
|
13507
|
+
const referencedKey = relation.sourceKey ? fieldKeyForColumn(collection, relation.sourceKey) : primaryKeyFieldKey(collection);
|
|
13508
|
+
const field = columnOf(targetTable, fkKey);
|
|
13509
|
+
const reference = columnOf(table, referencedKey);
|
|
13510
|
+
if (!field || !reference) continue;
|
|
13511
|
+
addOne(targetTableName, {
|
|
13512
|
+
key: `_synth_${targetTableName}_${fkKey}`,
|
|
13513
|
+
target: tables[tableName],
|
|
13514
|
+
fields: [field],
|
|
13515
|
+
references: [reference],
|
|
13516
|
+
relationName: name
|
|
13517
|
+
});
|
|
13518
|
+
}
|
|
13519
|
+
}
|
|
13520
|
+
const built = {};
|
|
13521
|
+
for (const tableName of /* @__PURE__ */ new Set([...ones.keys(), ...manys.keys()])) {
|
|
13522
|
+
const table = tables[tableName];
|
|
13523
|
+
if (!table) continue;
|
|
13524
|
+
const tableOnes = ones.get(tableName) ?? [];
|
|
13525
|
+
const tableManys = manys.get(tableName) ?? [];
|
|
13526
|
+
built[`${tableName}Relations`] = relations(table, ({ one, many }) => {
|
|
13527
|
+
const map = {};
|
|
13528
|
+
for (const spec of tableOnes) map[spec.key] = spec.fields ? one(spec.target, {
|
|
13529
|
+
fields: spec.fields,
|
|
13530
|
+
references: spec.references,
|
|
13531
|
+
relationName: spec.relationName
|
|
13532
|
+
}) : one(spec.target, { relationName: spec.relationName });
|
|
13533
|
+
for (const spec of tableManys) {
|
|
13534
|
+
if (map[spec.key]) continue;
|
|
13535
|
+
map[spec.key] = many(spec.target, { relationName: spec.relationName });
|
|
13536
|
+
}
|
|
13537
|
+
return map;
|
|
13538
|
+
});
|
|
13539
|
+
}
|
|
13540
|
+
return built;
|
|
13541
|
+
}
|
|
13542
|
+
/**
|
|
13543
|
+
* The junction table's own two `one()` sides.
|
|
13544
|
+
*
|
|
13545
|
+
* A many-to-many is a `many(junction)` on each endpoint, so the junction has to
|
|
13546
|
+
* carry the matching `one()` back to each — without them drizzle can join a
|
|
13547
|
+
* post to its `posts_tags` rows and no further, and the tags come back as
|
|
13548
|
+
* junction rows. Keyed by the junction's own columns, which is how the
|
|
13549
|
+
* generated file keys them too.
|
|
13550
|
+
*/
|
|
13551
|
+
function addJunctionSides(relation, sourceCollection, targetCollection, junctionName, tables, addOne, claim) {
|
|
13552
|
+
const junction = tables[junctionName];
|
|
13553
|
+
const sourceTable = tables[bareTableName(getTableName$1(sourceCollection))];
|
|
13554
|
+
const targetTable = tables[bareTableName(getTableName$1(targetCollection))];
|
|
13555
|
+
if (!junction || !sourceTable || !targetTable) return;
|
|
13556
|
+
const sourceName = sharedRelationName(relation, sourceCollection);
|
|
13557
|
+
const inverse = Object.values(resolveCollectionRelations(targetCollection)).find((r) => r.kind === "manyToMany" && bareTableName(r.through.table) === junctionName);
|
|
13558
|
+
const targetName = inverse ? sharedRelationName(inverse, targetCollection) : `${junctionName}_${relation.through.targetColumn}`;
|
|
13559
|
+
const pairs = [[
|
|
13560
|
+
relation.through.sourceColumn,
|
|
13561
|
+
sourceTable,
|
|
13562
|
+
sourceName,
|
|
13563
|
+
sourceCollection,
|
|
13564
|
+
"source"
|
|
13565
|
+
], [
|
|
13566
|
+
relation.through.targetColumn,
|
|
13567
|
+
targetTable,
|
|
13568
|
+
targetName,
|
|
13569
|
+
targetCollection,
|
|
13570
|
+
"target"
|
|
13571
|
+
]];
|
|
13572
|
+
for (const [column, endpointTable, relationName, endpointCollection] of pairs) {
|
|
13573
|
+
if (!claim(junctionName, relationName, "junction")) continue;
|
|
13574
|
+
const field = columnOf(junction, column);
|
|
13575
|
+
const reference = columnOf(endpointTable, primaryKeyFieldKey(endpointCollection));
|
|
13576
|
+
if (!field || !reference) continue;
|
|
13577
|
+
addOne(junctionName, {
|
|
13578
|
+
key: column,
|
|
13579
|
+
target: endpointTable,
|
|
13580
|
+
fields: [field],
|
|
13581
|
+
references: [reference],
|
|
13582
|
+
relationName
|
|
13583
|
+
});
|
|
13584
|
+
}
|
|
13585
|
+
}
|
|
13586
|
+
//#endregion
|
|
13587
|
+
//#region src/schema/catalogue-schema.ts
|
|
13588
|
+
/** The Postgres schema a collection's table lives in. */
|
|
13589
|
+
function schemaOfCollection(collection, fallback) {
|
|
13590
|
+
const declared = collection.schema;
|
|
13591
|
+
return typeof declared === "string" && declared.length > 0 ? declared : fallback;
|
|
13592
|
+
}
|
|
13593
|
+
/**
|
|
13594
|
+
* How a column of each table is keyed in the Drizzle object.
|
|
13595
|
+
*
|
|
13596
|
+
* The wire name, which is the property key — `authorId`, never `author_id` —
|
|
13597
|
+
* because that is what every read and write path in this driver indexes tables
|
|
13598
|
+
* and rows by, and what the generated module has always emitted. Getting this
|
|
13599
|
+
* wrong does not throw: drizzle leaves an unknown key out of the statement and
|
|
13600
|
+
* the write reports success having stored nothing.
|
|
13601
|
+
*
|
|
13602
|
+
* Two exceptions, both columns no property declares:
|
|
13603
|
+
*
|
|
13604
|
+
* - the generated search columns keep their raw names, because `search_vector`
|
|
13605
|
+
* is how `searchColumnNames` excludes them from responses and how the
|
|
13606
|
+
* condition builder finds the one to match against;
|
|
13607
|
+
* - a table no collection claims (a junction) is keyed by its **key** columns'
|
|
13608
|
+
* names, which is also what the generated file does for them — but a
|
|
13609
|
+
* junction that declares `through.properties` keys those payload columns by
|
|
13610
|
+
* their property key, for the same reason a collection does. `joinedAt` with
|
|
13611
|
+
* `columnName: "joined_at"` has to be one name on both sides or a `_pivot`
|
|
13612
|
+
* write is silently dropped by drizzle and the read serves a key the caller
|
|
13613
|
+
* never declared.
|
|
13614
|
+
*/
|
|
13615
|
+
function columnKeysFromCollections(collections) {
|
|
13616
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
13617
|
+
const rawColumns = /* @__PURE__ */ new Map();
|
|
13618
|
+
for (const collection of collections) {
|
|
13619
|
+
const table = bareTableName(getTableName$1(collection));
|
|
13620
|
+
if (!table) continue;
|
|
13621
|
+
byTable.set(table, collection);
|
|
13622
|
+
rawColumns.set(table, new Set(searchColumnNames(collection)));
|
|
13623
|
+
}
|
|
13624
|
+
const junctionByTable = /* @__PURE__ */ new Map();
|
|
13625
|
+
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
13626
|
+
if (Object.keys(spec.properties).length === 0) continue;
|
|
13627
|
+
junctionByTable.set(bareTableName(spec.table), getJunctionCollectionConfig(spec));
|
|
13628
|
+
}
|
|
13629
|
+
return (tableName, columnName) => {
|
|
13630
|
+
const collection = byTable.get(tableName);
|
|
13631
|
+
if (!collection) {
|
|
13632
|
+
const junction = junctionByTable.get(tableName);
|
|
13633
|
+
return junction ? fieldKeyForColumn(junction, columnName) : columnName;
|
|
13634
|
+
}
|
|
13635
|
+
if (rawColumns.get(tableName)?.has(columnName)) return columnName;
|
|
13636
|
+
return fieldKeyForColumn(collection, columnName);
|
|
13637
|
+
};
|
|
13638
|
+
}
|
|
13639
|
+
/**
|
|
13640
|
+
* Read the tables this data source serves out of `information_schema`.
|
|
13641
|
+
*
|
|
13642
|
+
* Restricted to the collections handed in, because the driver is given every
|
|
13643
|
+
* collection the project declares while each database holds only its own: an
|
|
13644
|
+
* unfiltered read had a second source (`database("analytics")`) reporting the
|
|
13645
|
+
* default's tables as its own. Junction tables come too — they back no
|
|
13646
|
+
* collection and a many-to-many is unreadable without them.
|
|
13647
|
+
*
|
|
13648
|
+
* Every schema the collections name is read, not just `public`. The users
|
|
13649
|
+
* collection lives in `rebase`, and a run restricted to `public` would leave the
|
|
13650
|
+
* registry with no table for it at all.
|
|
13651
|
+
*/
|
|
13652
|
+
async function readCatalogueSchema(options) {
|
|
13653
|
+
const { client, collections, defaultSchema } = options;
|
|
13654
|
+
/** schema → the tables wanted from it. */
|
|
13655
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
13656
|
+
const want = (schema, table) => {
|
|
13657
|
+
const set = wanted.get(schema) ?? /* @__PURE__ */ new Set();
|
|
13658
|
+
wanted.set(schema, set);
|
|
13659
|
+
set.add(table);
|
|
13660
|
+
};
|
|
13661
|
+
for (const collection of collections) {
|
|
13662
|
+
const table = bareTableName(getTableName$1(collection));
|
|
13663
|
+
if (table) want(schemaOfCollection(collection, defaultSchema), table);
|
|
13664
|
+
}
|
|
13665
|
+
for (const spec of resolveJunctionSpecs(collections).values()) want(spec.schema, bareTableName(spec.table));
|
|
13666
|
+
const columnKey = columnKeysFromCollections(collections);
|
|
13667
|
+
const tables = {};
|
|
13668
|
+
const meta = /* @__PURE__ */ new Map();
|
|
13669
|
+
const missing = [];
|
|
13670
|
+
for (const [schema, names] of wanted) {
|
|
13671
|
+
const live = await introspectSchema(client, schema);
|
|
13672
|
+
const present = /* @__PURE__ */ new Map();
|
|
13673
|
+
for (const name of names) {
|
|
13674
|
+
const found = live.tablesMap.get(name);
|
|
13675
|
+
if (!found) {
|
|
13676
|
+
missing.push(schema === defaultSchema ? name : `${schema}.${name}`);
|
|
13677
|
+
continue;
|
|
13678
|
+
}
|
|
13679
|
+
if (tables[name]) continue;
|
|
13680
|
+
present.set(name, found);
|
|
13681
|
+
meta.set(name, found);
|
|
13682
|
+
}
|
|
13683
|
+
Object.assign(tables, buildDrizzleTablesFromSchema(present, schema, columnKey));
|
|
13684
|
+
}
|
|
13685
|
+
return {
|
|
13686
|
+
tables,
|
|
13687
|
+
relations: buildDrizzleRelationsFromCollections(collections, tables),
|
|
13688
|
+
meta,
|
|
13689
|
+
missing
|
|
13690
|
+
};
|
|
13691
|
+
}
|
|
13692
|
+
/**
|
|
13693
|
+
* The BaaS variant: no collections are declared, so every table in one schema is
|
|
13694
|
+
* served and both the collections and the relations are derived from the
|
|
13695
|
+
* foreign keys.
|
|
13696
|
+
*
|
|
13697
|
+
* Kept apart from {@link readCatalogueSchema} because the two answer different
|
|
13698
|
+
* questions. Here the database is the entire specification; there it supplies
|
|
13699
|
+
* the columns for a specification the project already wrote down.
|
|
13700
|
+
*/
|
|
13701
|
+
function buildBaasSchema(tablesMap, pgSchemaName, collections) {
|
|
13702
|
+
const columnKey = columnKeysFromCollections(collections);
|
|
13703
|
+
const tables = buildDrizzleTablesFromSchema(tablesMap, pgSchemaName, columnKey);
|
|
13704
|
+
return {
|
|
13705
|
+
tables,
|
|
13706
|
+
relations: buildDrizzleRelationsFromSchema(tablesMap, tables, columnKey)
|
|
13707
|
+
};
|
|
13708
|
+
}
|
|
13709
|
+
//#endregion
|
|
13710
|
+
//#region src/schema/generated-schema-diff.ts
|
|
13711
|
+
/**
|
|
13712
|
+
* Compare the committed `schema.generated.ts` against the live catalogue, and
|
|
13713
|
+
* say so — once per difference, at boot, as a warning and nothing more.
|
|
13714
|
+
*
|
|
13715
|
+
* The runtime stopped reading that file for its tables (see
|
|
13716
|
+
* `catalogue-schema.ts`), which removes an entire class of production failure
|
|
13717
|
+
* and creates a smaller one: a stale file is now *invisible*. It is still real —
|
|
13718
|
+
* it is what Atlas plans migrations from, what `db push` diffs, what `eject`
|
|
13719
|
+
* writes out and what user code imports — so a project whose file no longer
|
|
13720
|
+
* describes its database will hit it later, in a tool, with no clue that boot
|
|
13721
|
+
* already knew.
|
|
13722
|
+
*
|
|
13723
|
+
* Hence this: a guard rather than a doc. It never fails boot and never changes
|
|
13724
|
+
* what is served — the previous arrangement, where the file could do both,
|
|
13725
|
+
* is exactly what was removed.
|
|
13726
|
+
*
|
|
13727
|
+
* `generated-schema-staleness.ts` answers a narrower question (does the file
|
|
13728
|
+
* name every table the collections do) from the file's *source text*. This one
|
|
13729
|
+
* compares the loaded module against the database, column by column.
|
|
13730
|
+
*/
|
|
13731
|
+
/**
|
|
13732
|
+
* The families a boot warning is worth raising over.
|
|
13733
|
+
*
|
|
13734
|
+
* Deliberately coarse. `varchar(120)` and `text` are the same family because the
|
|
13735
|
+
* runtime treats them identically and a warning about the difference would fire
|
|
13736
|
+
* on every boot of a correct project — the widths still reach Postgres, which
|
|
13737
|
+
* enforces them. What this is looking for is the kind of drift that changes what
|
|
13738
|
+
* a value *is*: a number that became text, a timestamp that became a date, a
|
|
13739
|
+
* column that stopped existing.
|
|
13740
|
+
*/
|
|
13741
|
+
function typeFamily(sqlType) {
|
|
13742
|
+
if (!sqlType) return "unknown";
|
|
13743
|
+
let type = sqlType.trim().toLowerCase();
|
|
13744
|
+
const isArray = type.endsWith("[]") || type.startsWith("_");
|
|
13745
|
+
type = type.replace(/\[\]$/, "").replace(/^_/, "");
|
|
13746
|
+
type = type.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
13747
|
+
const family = FAMILIES[type] ?? type;
|
|
13748
|
+
return isArray ? `${family}[]` : family;
|
|
13749
|
+
}
|
|
13750
|
+
var FAMILIES = {
|
|
13751
|
+
text: "text",
|
|
13752
|
+
varchar: "text",
|
|
13753
|
+
"character varying": "text",
|
|
13754
|
+
bpchar: "text",
|
|
13755
|
+
char: "text",
|
|
13756
|
+
character: "text",
|
|
13757
|
+
citext: "text",
|
|
13758
|
+
name: "text",
|
|
13759
|
+
int2: "integer",
|
|
13760
|
+
smallint: "integer",
|
|
13761
|
+
smallserial: "integer",
|
|
13762
|
+
int4: "integer",
|
|
13763
|
+
int: "integer",
|
|
13764
|
+
integer: "integer",
|
|
13765
|
+
serial: "integer",
|
|
13766
|
+
int8: "bigint",
|
|
13767
|
+
bigint: "bigint",
|
|
13768
|
+
bigserial: "bigint",
|
|
13769
|
+
float4: "float",
|
|
13770
|
+
real: "float",
|
|
13771
|
+
float8: "float",
|
|
13772
|
+
"double precision": "float",
|
|
13773
|
+
numeric: "numeric",
|
|
13774
|
+
decimal: "numeric",
|
|
13775
|
+
money: "numeric",
|
|
13776
|
+
bool: "boolean",
|
|
13777
|
+
boolean: "boolean",
|
|
13778
|
+
uuid: "uuid",
|
|
13779
|
+
json: "json",
|
|
13780
|
+
jsonb: "jsonb",
|
|
13781
|
+
date: "date",
|
|
13782
|
+
time: "time",
|
|
13783
|
+
"time without time zone": "time",
|
|
13784
|
+
timetz: "time",
|
|
13785
|
+
"time with time zone": "time",
|
|
13786
|
+
timestamp: "timestamp",
|
|
13787
|
+
"timestamp without time zone": "timestamp",
|
|
13788
|
+
timestamptz: "timestamptz",
|
|
13789
|
+
"timestamp with time zone": "timestamptz",
|
|
13790
|
+
interval: "interval",
|
|
13791
|
+
bytea: "bytea",
|
|
13792
|
+
tsvector: "tsvector",
|
|
13793
|
+
tsquery: "tsquery",
|
|
13794
|
+
vector: "vector"
|
|
13795
|
+
};
|
|
13796
|
+
function catalogueColumns(meta) {
|
|
13797
|
+
const columns = /* @__PURE__ */ new Map();
|
|
13798
|
+
for (const col of meta.columns) columns.set(col.column_name, {
|
|
13799
|
+
name: col.column_name,
|
|
13800
|
+
family: typeFamily(col.udt_name || col.data_type),
|
|
13801
|
+
notNull: col.is_nullable === "NO"
|
|
13802
|
+
});
|
|
13803
|
+
return columns;
|
|
13804
|
+
}
|
|
13805
|
+
/**
|
|
13806
|
+
* Every way the generated module and the database disagree, for the tables both
|
|
13807
|
+
* describe.
|
|
13808
|
+
*
|
|
13809
|
+
* One-directional in one respect only: a table the module has and the database
|
|
13810
|
+
* does not is reported (that is the stale-file case), while a table the database
|
|
13811
|
+
* has and the module does not is left alone — a database may hold anything, and
|
|
13812
|
+
* the collections-vs-file question is `generated-schema-staleness`'s.
|
|
13813
|
+
*/
|
|
13814
|
+
function diffGeneratedSchemaAgainstCatalogue(options) {
|
|
13815
|
+
const { generated, catalogue, collections } = options;
|
|
13816
|
+
if (!generated) return [];
|
|
13817
|
+
const collectionByTable = /* @__PURE__ */ new Map();
|
|
13818
|
+
for (const collection of collections) {
|
|
13819
|
+
const table = getTableName$1(collection);
|
|
13820
|
+
if (table) collectionByTable.set(bareTableName(table), collection);
|
|
13821
|
+
}
|
|
13822
|
+
const differences = [];
|
|
13823
|
+
for (const value of Object.values(generated)) {
|
|
13824
|
+
if (!isTable(value)) continue;
|
|
13825
|
+
const table = getTableName(value);
|
|
13826
|
+
const collection = collectionByTable.get(table);
|
|
13827
|
+
const at = {
|
|
13828
|
+
table,
|
|
13829
|
+
collection: collection?.slug
|
|
13830
|
+
};
|
|
13831
|
+
const meta = catalogue.get(table);
|
|
13832
|
+
if (!meta) {
|
|
13833
|
+
if (collection) differences.push({
|
|
13834
|
+
...at,
|
|
13835
|
+
kind: "missing-table"
|
|
13836
|
+
});
|
|
13837
|
+
continue;
|
|
13838
|
+
}
|
|
13839
|
+
const live = catalogueColumns(meta);
|
|
13840
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13841
|
+
const authOwned = isAuthCollection(collection) ? new Set(AUTH_USERS_COLUMNS.map((c) => c.column)) : void 0;
|
|
13842
|
+
for (const [key, column] of Object.entries(getTableColumns(value))) {
|
|
13843
|
+
const name = column.name ?? key;
|
|
13844
|
+
seen.add(name);
|
|
13845
|
+
const property = collection ? fieldKeyForColumn(collection, name) : key;
|
|
13846
|
+
const generatedFamily = typeFamily(column.getSQLType?.());
|
|
13847
|
+
const generatedNotNull = Boolean(column.notNull);
|
|
13848
|
+
const found = live.get(name);
|
|
13849
|
+
if (!found) {
|
|
13850
|
+
differences.push({
|
|
13851
|
+
...at,
|
|
13852
|
+
kind: "missing-column",
|
|
13853
|
+
column: name,
|
|
13854
|
+
property,
|
|
13855
|
+
generated: generatedFamily
|
|
13856
|
+
});
|
|
13857
|
+
continue;
|
|
13858
|
+
}
|
|
13859
|
+
if (found.family !== generatedFamily) differences.push({
|
|
13860
|
+
...at,
|
|
13861
|
+
kind: "type",
|
|
13862
|
+
column: name,
|
|
13863
|
+
property,
|
|
13864
|
+
generated: generatedFamily,
|
|
13865
|
+
live: found.family
|
|
13866
|
+
});
|
|
13867
|
+
if (found.notNull !== generatedNotNull && !authOwned?.has(name)) differences.push({
|
|
13868
|
+
...at,
|
|
13869
|
+
kind: "nullability",
|
|
13870
|
+
column: name,
|
|
13871
|
+
property,
|
|
13872
|
+
generated: generatedNotNull ? "NOT NULL" : "nullable",
|
|
13873
|
+
live: found.notNull ? "NOT NULL" : "nullable"
|
|
13874
|
+
});
|
|
13875
|
+
}
|
|
13876
|
+
for (const [name, column] of live) {
|
|
13877
|
+
if (seen.has(name)) continue;
|
|
13878
|
+
if (authOwned?.has(name)) continue;
|
|
13879
|
+
differences.push({
|
|
13880
|
+
...at,
|
|
13881
|
+
kind: "extra-column",
|
|
13882
|
+
column: name,
|
|
13883
|
+
property: collection ? fieldKeyForColumn(collection, name) : name,
|
|
13884
|
+
live: column.family
|
|
13885
|
+
});
|
|
13886
|
+
}
|
|
13887
|
+
}
|
|
13888
|
+
return differences;
|
|
13889
|
+
}
|
|
13890
|
+
/** One line, in the terms the reader has: collection, property, column. */
|
|
13891
|
+
function describeSchemaDifference(difference) {
|
|
13892
|
+
const where = difference.collection ? `${difference.collection} (table "${difference.table}")` : `table "${difference.table}"`;
|
|
13893
|
+
const field = difference.column ? `${difference.property && difference.property !== difference.column ? `${difference.property} → "${difference.column}"` : `"${difference.column}"`}` : void 0;
|
|
13894
|
+
switch (difference.kind) {
|
|
13895
|
+
case "missing-table": return `${where} is in schema.generated.ts but not in the database`;
|
|
13896
|
+
case "missing-column": return `${where}: ${field} is in schema.generated.ts but not in the database`;
|
|
13897
|
+
case "extra-column": return `${where}: ${field} is in the database but not in schema.generated.ts (${difference.live})`;
|
|
13898
|
+
case "type": return `${where}: ${field} is ${difference.generated} in schema.generated.ts and ${difference.live} in the database`;
|
|
13899
|
+
case "nullability": return `${where}: ${field} is ${difference.generated} in schema.generated.ts and ${difference.live} in the database`;
|
|
13900
|
+
}
|
|
13901
|
+
}
|
|
13902
|
+
/**
|
|
13903
|
+
* Report the differences, one warning each, and never throw.
|
|
13904
|
+
*
|
|
13905
|
+
* Per difference rather than one summary block on purpose: these are read out of
|
|
13906
|
+
* a log aggregator as often as a terminal, and a single multi-line message
|
|
13907
|
+
* cannot be filtered down to the one collection somebody is looking at.
|
|
13908
|
+
*/
|
|
13909
|
+
function warnOnGeneratedSchemaDrift(differences) {
|
|
13910
|
+
if (differences.length === 0) return;
|
|
13911
|
+
logger.warn(`📄 [schema] schema.generated.ts no longer describes the database (${differences.length} difference${differences.length === 1 ? "" : "s"}). The server serves the database, so nothing here is broken — but Atlas, \`rebase db push\`, \`rebase eject\` and any code importing that file are all reading a stale description. Run \`rebase schema generate\`.`);
|
|
13912
|
+
for (const difference of differences) logger.warn(` • ${describeSchemaDifference(difference)}`, {
|
|
13913
|
+
schemaDrift: {
|
|
13914
|
+
kind: difference.kind,
|
|
13915
|
+
table: difference.table,
|
|
13916
|
+
collection: difference.collection,
|
|
13917
|
+
column: difference.column,
|
|
13918
|
+
property: difference.property,
|
|
13919
|
+
generated: difference.generated,
|
|
13920
|
+
live: difference.live
|
|
13921
|
+
},
|
|
13922
|
+
fix: "rebase schema generate"
|
|
13923
|
+
});
|
|
13924
|
+
}
|
|
13925
|
+
//#endregion
|
|
12531
13926
|
//#region src/PostgresBootstrapper.ts
|
|
12532
13927
|
/**
|
|
12533
13928
|
* PostgresBootstrapper
|
|
@@ -12756,7 +14151,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12756
14151
|
* handful of `CREATE OR REPLACE FUNCTION`.
|
|
12757
14152
|
*/
|
|
12758
14153
|
const provisionRlsRuntime = async (queryable) => {
|
|
12759
|
-
const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-
|
|
14154
|
+
const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-BlzsOUtz.js").then((n) => n.i);
|
|
12760
14155
|
await queryable.query("SELECT pg_advisory_lock(hashtext('rebase_auth_functions_init'))");
|
|
12761
14156
|
try {
|
|
12762
14157
|
for (const statement of RLS_BOOTSTRAP_STATEMENTS) await queryable.query(statement);
|
|
@@ -12789,33 +14184,49 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12789
14184
|
}
|
|
12790
14185
|
if (policyless.length > 0) logger.warn(`🔒 [rls] ${policyless.length} table(s) have RLS enabled but no policies, so they will return no rows: ${policyless.join(", ")}`);
|
|
12791
14186
|
introspectedCollections = buildCollectionsFromSchema(schema, pgSchemaName);
|
|
12792
|
-
introspectedTables =
|
|
12793
|
-
introspectedRelations = buildDrizzleRelationsFromSchema(schema.tablesMap, introspectedTables);
|
|
14187
|
+
({tables: introspectedTables, relations: introspectedRelations} = buildBaasSchema(schema.tablesMap, pgSchemaName, introspectedCollections));
|
|
12794
14188
|
logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
|
|
12795
14189
|
}
|
|
12796
14190
|
let ownTables;
|
|
12797
14191
|
let ownRelations;
|
|
14192
|
+
let ownCatalogue;
|
|
14193
|
+
let ownCollections = [];
|
|
12798
14194
|
const sourceKey = config.dataSourceKey ?? DEFAULT_DATA_SOURCE_KEY;
|
|
12799
|
-
if (!introspectedCollections &&
|
|
12800
|
-
|
|
12801
|
-
if (
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
12805
|
-
|
|
12806
|
-
|
|
12807
|
-
|
|
12808
|
-
const
|
|
12809
|
-
|
|
14195
|
+
if (!introspectedCollections && collections && collections.length > 0) {
|
|
14196
|
+
ownCollections = collections.filter((c) => c.dataSource === sourceKey || !c.dataSource && sourceKey === DEFAULT_DATA_SOURCE_KEY);
|
|
14197
|
+
if (ownCollections.length > 0) {
|
|
14198
|
+
try {
|
|
14199
|
+
await rawClient.query("SELECT 1");
|
|
14200
|
+
} catch (err) {
|
|
14201
|
+
const fatal = diagnoseConnectFailure(err, pgConfig.connectionString);
|
|
14202
|
+
if (fatal) throw fatal;
|
|
14203
|
+
}
|
|
14204
|
+
const catalogue = await readCatalogueSchema({
|
|
14205
|
+
client: rawClient,
|
|
14206
|
+
collections: ownCollections,
|
|
14207
|
+
defaultSchema: pgConfig.introspectionSchema ?? "public"
|
|
14208
|
+
});
|
|
14209
|
+
ownTables = catalogue.tables;
|
|
14210
|
+
ownRelations = catalogue.relations;
|
|
14211
|
+
ownCatalogue = catalogue.meta;
|
|
14212
|
+
if (catalogue.missing.length > 0) logger.warn(`[PostgresRegistry] Data source "${sourceKey}" has no table yet for: ${catalogue.missing.join(", ")}. Their collections will answer "table not found" until the schema is provisioned.`);
|
|
12810
14213
|
}
|
|
12811
14214
|
}
|
|
12812
14215
|
const activeCollections = introspectedCollections ?? collections;
|
|
12813
|
-
const schemaTables = introspectedTables ??
|
|
12814
|
-
const schemaRelations = introspectedRelations ??
|
|
14216
|
+
const schemaTables = introspectedTables ?? ownTables;
|
|
14217
|
+
const schemaRelations = introspectedRelations ?? ownRelations;
|
|
14218
|
+
if (ownCatalogue) try {
|
|
14219
|
+
warnOnGeneratedSchemaDrift(diffGeneratedSchemaAgainstCatalogue({
|
|
14220
|
+
generated: pgConfig.schema?.tables,
|
|
14221
|
+
catalogue: ownCatalogue,
|
|
14222
|
+
collections: ownCollections
|
|
14223
|
+
}));
|
|
14224
|
+
} catch (err) {
|
|
14225
|
+
logger.debug("[schema] Could not compare schema.generated.ts against the database", { error: err });
|
|
14226
|
+
}
|
|
12815
14227
|
const registry = buildCollectionRegistry({
|
|
12816
14228
|
collections: activeCollections,
|
|
12817
14229
|
tables: schemaTables,
|
|
12818
|
-
enums: pgConfig.schema?.enums,
|
|
12819
14230
|
relations: schemaRelations
|
|
12820
14231
|
});
|
|
12821
14232
|
if (schemaTables) {
|
|
@@ -13127,7 +14538,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13127
14538
|
* proved it can bootstrap with.
|
|
13128
14539
|
*/
|
|
13129
14540
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
13130
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
14541
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-Bkvehxdm.js").then((n) => n.n);
|
|
13131
14542
|
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
13132
14543
|
for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
|
|
13133
14544
|
return { applied: plan.actions.length - plan.failures.length };
|
|
@@ -13161,7 +14572,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13161
14572
|
await provisionRlsRuntime(provisioningQueryable(driverResult));
|
|
13162
14573
|
},
|
|
13163
14574
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
13164
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
14575
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-CHO0moXQ.js");
|
|
13165
14576
|
const queryable = provisioningQueryable(driverResult);
|
|
13166
14577
|
const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
|
|
13167
14578
|
if (!rows[0]?.present) await provisionRlsRuntime(queryable);
|
|
@@ -13172,7 +14583,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13172
14583
|
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.`);
|
|
13173
14584
|
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.`);
|
|
13174
14585
|
try {
|
|
13175
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
14586
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-BlzsOUtz.js").then((n) => n.i);
|
|
13176
14587
|
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
13177
14588
|
info: (m) => logger.info(m),
|
|
13178
14589
|
warn: (m) => logger.warn(m)
|
|
@@ -13296,6 +14707,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
13296
14707
|
};
|
|
13297
14708
|
}
|
|
13298
14709
|
//#endregion
|
|
13299
|
-
export { ADMIN_ONLY_TYPES, AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, BranchingUnsupportedError, CHANNEL_BUS_NOTIFY_CHANNEL, CONNECTION_OWNER, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PUBLIC_TYPES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, REBASE_USER_ROLE, RealtimeService, RoleSwitchUnavailableError, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, cappedPoolMax, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectConnectionPosture, detectToolMajor, diagnoseRowSecurityDumpFailure, discardPartialDump, effectiveSqlRole, ensureAppRole, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema,
|
|
14710
|
+
export { ADMIN_ONLY_TYPES, AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, BranchingUnsupportedError, CHANNEL_BUS_NOTIFY_CHANNEL, CONNECTION_OWNER, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PUBLIC_TYPES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, REBASE_USER_ROLE, RealtimeService, RoleSwitchUnavailableError, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, cappedPoolMax, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectConnectionPosture, detectToolMajor, diagnoseRowSecurityDumpFailure, discardPartialDump, effectiveSqlRole, ensureAppRole, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isRoleSwitchingOptedOut, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, main, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, poolMaxCeiling, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
13300
14711
|
|
|
13301
14712
|
//# sourceMappingURL=index.es.js.map
|