@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g041c925
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/PostgresAdapter.d.ts +1 -1
- package/dist/PostgresBackendDriver.d.ts +16 -7
- package/dist/PostgresBootstrapper.d.ts +6 -6
- package/dist/auth/services.d.ts +1 -1
- package/dist/backup/backup-cron.d.ts +1 -1
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/index.d.ts +4 -4
- package/dist/{backup-service-BZoixhVl.js → backup-service-FN6V3rVi.js} +4 -4
- package/dist/{backup-service-BZoixhVl.js.map → backup-service-FN6V3rVi.js.map} +1 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/collections/buildRegistry.d.ts +1 -1
- package/dist/collections/validate-relations.d.ts +1 -1
- package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
- package/dist/connection-GOKU3Hu5.js.map +1 -0
- package/dist/connection.d.ts +16 -0
- package/dist/data-transformer.d.ts +1 -1
- package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-B01cv9UC.js} +4 -4
- package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-B01cv9UC.js.map} +1 -1
- package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-CvW6tbI7.js} +1482 -12
- package/dist/ensure-collection-tables-CvW6tbI7.js.map +1 -0
- package/dist/index.d.ts +16 -16
- package/dist/index.es.js +2183 -1817
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DAwWHs81.js} +3 -3
- package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DAwWHs81.js.map} +1 -1
- package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-eSahD7ec.js} +13 -3
- package/dist/rls-enforcement-eSahD7ec.js.map +1 -0
- package/dist/schema/classify-change.d.ts +82 -0
- package/dist/schema/dynamic-tables.d.ts +1 -1
- package/dist/schema/ensure-collection-policies.d.ts +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +93 -2
- package/dist/schema/generate-schema-commit.d.ts +136 -0
- package/dist/schema/generated-schema-staleness.d.ts +19 -0
- package/dist/schema/introspect-db-constraints.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +3 -3
- package/dist/schema/introspect-db-project.d.ts +1 -1
- package/dist/schema/introspect-db-queries.d.ts +1 -1
- package/dist/schema/introspect-db-structure.d.ts +2 -2
- package/dist/schema/introspect-runtime.d.ts +1 -1
- package/dist/schema/vector-index.d.ts +88 -0
- package/dist/services/BranchService.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -4
- package/dist/services/PersistService.d.ts +5 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/RelationWriteService.d.ts +3 -3
- package/dist/services/cdc/junction-tables.d.ts +1 -1
- package/dist/services/cdc/trigger-cdc.d.ts +1 -1
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
- package/dist/services/channel-bus/index.d.ts +2 -2
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/dataService.d.ts +10 -10
- package/dist/services/index.d.ts +4 -4
- package/dist/services/junction-writes.d.ts +2 -2
- package/dist/services/nested-path.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +3 -3
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/services/write-denial.d.ts +1 -1
- package/dist/{src-BBFsDaeA.js → src-DolrXONo.js} +93 -1
- package/dist/src-DolrXONo.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +2 -2
- package/dist/{websocket-BVgDVO-V.js → websocket-7Dp77lTh.js} +43 -6
- package/dist/websocket-7Dp77lTh.js.map +1 -0
- package/dist/websocket.d.ts +29 -2
- package/package.json +7 -7
- package/src/PostgresBackendDriver.ts +41 -2
- package/src/backup/backup-service.ts +1 -1
- package/src/cli-helpers.ts +3 -2
- package/src/connection.ts +37 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/classify-change.ts +436 -0
- package/src/schema/ensure-collection-tables.test.ts +168 -1
- package/src/schema/ensure-collection-tables.ts +344 -14
- package/src/schema/generate-drizzle-schema-logic.ts +23 -11
- package/src/schema/generate-drizzle-schema.ts +13 -2
- package/src/schema/generate-postgres-ddl-logic.ts +16 -1
- package/src/schema/generate-postgres-ddl.ts +13 -2
- package/src/schema/generate-schema-commit.ts +242 -0
- package/src/schema/generated-schema-staleness.ts +114 -1
- package/src/schema/vector-index.ts +278 -0
- package/src/services/collection-helpers.ts +3 -2
- package/src/websocket.ts +36 -2
- package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
- package/dist/connection-BuZ97wsr.js.map +0 -1
- package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
- package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
- package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
- package/dist/src-BBFsDaeA.js.map +0 -1
- package/dist/utils/table-classification.d.ts +0 -8
- package/dist/websocket-BVgDVO-V.js.map +0 -1
- package/src/utils/table-classification.ts +0 -16
package/dist/index.es.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { a as
|
|
4
|
+
import { a as createReadReplicaConnection, c as poolMaxCeiling, i as createPostgresDatabaseConnection, o as guardPoolAgainstDirtyRelease, r as createDirectDatabaseConnection, s as pinSearchPath, t as cappedPoolMax } from "./connection-GOKU3Hu5.js";
|
|
5
5
|
import { n as resolveClientListLimit, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
|
|
6
|
-
import { $ as
|
|
7
|
-
import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, g as encodeRelationAggregateSort, l as isRelationalCollectionConfig, v as parseRelationAggregateSort } from "./src-
|
|
8
|
-
import {
|
|
9
|
-
import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-
|
|
10
|
-
import { A as parseDbNameFromUrl, C as buildRowSecurityPgOptions, D as joinStorageKey, E as globalsFileForDump, F as withDatabaseName, M as resolveConnectionString, N as serverVersionNumToMajor, O as parseBackupDestination, P as splitGlobalsStatements, S as buildPgRestoreListArgs, T as diagnoseRowSecurityDumpFailure, _ as selectBackupsToPrune, a as detectToolMajor, b as buildPgDumpallGlobalsArgs, c as getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as require_source, h as validateDump, i as createDump, j as parsePgToolMajor, k as parseBackupTimestamp, 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 buildBackupFilename, w as checkToolServerCompatibility, x as buildPgRestoreArgs, y as buildPgDumpArgs } from "./backup-service-
|
|
11
|
-
import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-
|
|
6
|
+
import { $ as camelCase, A as fieldKeyForColumn, B as parseIdValues, C as getJunctionCollectionConfig, D as policyToPostgres, E as getEffectiveSecurityRules, F as getTableVarName, G as updateDateAutoValues, H as createRelationRef, I as resolveCollectionRelations, J as legacyForeignKeyName, K as firstFreeKey, L as buildCompositeId, M as getColumnName, N as getEnumVarName, O as securityRuleToConditions, P as getTableName$1, Q as mergeDeep, R as getDeclaredPrimaryKeys, S as resolveStringColumnLength, T as resolveJunctionSpecs, U as createRelationRefWithData, V as sortCollectionsBySlug, W as normalizeToEntityRelation, X as getPolicyNamesForRule, Y as toWireKey, Z as isPrototypePollutingKey, _ as OrderBySpecError, a as generatePostgresDdl, at as Vector, b as CollectionRegistry, d as authUsersColumnSql, et as toSnakeCase, f as SEARCH_UNACCENT_FN, g as buildSdkData, h as visibleColumnProjection, i as readSchemaFactsFor, it as isManyToMany, j as findRelation, l as resolveColumnName$1, m as hiddenColumnsOption, n as planCollectionSchemaEnsure, o as generatePostgresPoliciesDdl, p as buildSearchColumnSpec, q as generateForeignKeyName, rt as hasForeignKeyOnTarget, s as generatePostgresSearchDdl, u as AUTH_USERS_COLUMNS, v as normalizeDriverOrderBy, w as getJunctionSecurityRules, x as relationalCollections, y as parseOrderBySpecStrict, z as isAddressableId } from "./ensure-collection-tables-CvW6tbI7.js";
|
|
7
|
+
import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, g as encodeRelationAggregateSort, l as isRelationalCollectionConfig, v as parseRelationAggregateSort } from "./src-DolrXONo.js";
|
|
8
|
+
import { n as PUBLIC_TYPES, r as createPostgresWebSocket, t as ADMIN_ONLY_TYPES } from "./websocket-7Dp77lTh.js";
|
|
9
|
+
import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-eSahD7ec.js";
|
|
10
|
+
import { A as parseDbNameFromUrl, C as buildRowSecurityPgOptions, D as joinStorageKey, E as globalsFileForDump, F as withDatabaseName, M as resolveConnectionString, N as serverVersionNumToMajor, O as parseBackupDestination, P as splitGlobalsStatements, S as buildPgRestoreListArgs, T as diagnoseRowSecurityDumpFailure, _ as selectBackupsToPrune, a as detectToolMajor, b as buildPgDumpallGlobalsArgs, c as getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as require_source, h as validateDump, i as createDump, j as parsePgToolMajor, k as parseBackupTimestamp, 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 buildBackupFilename, w as checkToolServerCompatibility, x as buildPgRestoreArgs, y as buildPgDumpArgs } from "./backup-service-FN6V3rVi.js";
|
|
11
|
+
import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-DAwWHs81.js";
|
|
12
12
|
import { Client, Pool } from "pg";
|
|
13
13
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
14
14
|
import { ApiError, createDdlBootstrapper, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
@@ -16,10 +16,17 @@ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, in
|
|
|
16
16
|
import { PgArray, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
17
17
|
import fs, { promises } from "fs";
|
|
18
18
|
import path from "path";
|
|
19
|
-
import chokidar from "chokidar";
|
|
20
19
|
import { WebSocket } from "ws";
|
|
21
20
|
import { EventEmitter } from "events";
|
|
22
21
|
import { randomUUID } from "crypto";
|
|
22
|
+
//#region ../types/src/types/schema_editing.ts
|
|
23
|
+
var DEFAULT_COMMIT_PATHS = {
|
|
24
|
+
schemaFile: "backend/src/schema.generated.ts",
|
|
25
|
+
ddlFile: "drizzle/schema.sql",
|
|
26
|
+
policiesFile: "drizzle/policies.sql",
|
|
27
|
+
searchFile: "drizzle/search.sql"
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
23
30
|
//#region ../types/src/types/channel_bus.ts
|
|
24
31
|
/**
|
|
25
32
|
* Whether `setting` is an already-constructed transport rather than a request
|
|
@@ -5563,1916 +5570,2274 @@ var BranchService = class {
|
|
|
5563
5570
|
}
|
|
5564
5571
|
};
|
|
5565
5572
|
//#endregion
|
|
5566
|
-
//#region src/
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
this.realtimeService = realtimeService;
|
|
5609
|
-
this.historyService = historyService;
|
|
5610
|
-
this.user = user;
|
|
5611
|
-
this.data = buildSdkData(this);
|
|
5612
|
-
if (poolManager) this.branchService = new BranchService(db, poolManager);
|
|
5613
|
-
}
|
|
5614
|
-
/**
|
|
5615
|
-
* Typed admin capabilities (SQLAdmin + SchemaAdmin + BranchAdmin).
|
|
5616
|
-
* Implemented as a getter so method references are resolved at call-time,
|
|
5617
|
-
* allowing test spies applied after construction to take effect.
|
|
5618
|
-
*/
|
|
5619
|
-
get admin() {
|
|
5620
|
-
return {
|
|
5621
|
-
executeSql: (...args) => this.executeSql(...args),
|
|
5622
|
-
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
5623
|
-
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
5624
|
-
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
5625
|
-
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
5626
|
-
fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
|
|
5627
|
-
fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
|
|
5628
|
-
...this.branchService ? {
|
|
5629
|
-
createBranch: this.branchService.createBranch.bind(this.branchService),
|
|
5630
|
-
deleteBranch: this.branchService.deleteBranch.bind(this.branchService),
|
|
5631
|
-
listBranches: this.branchService.listBranches.bind(this.branchService),
|
|
5632
|
-
getBranchInfo: this.branchService.getBranchInfo.bind(this.branchService)
|
|
5633
|
-
} : {}
|
|
5634
|
-
};
|
|
5573
|
+
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
5574
|
+
/**
|
|
5575
|
+
* Resolve the SQL column name for a property.
|
|
5576
|
+
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
5577
|
+
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
5578
|
+
*/
|
|
5579
|
+
var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
5580
|
+
/**
|
|
5581
|
+
* A string literal for the generated schema file.
|
|
5582
|
+
*
|
|
5583
|
+
* Column names, table names and enum values are all written into this file as
|
|
5584
|
+
* literals, and none of them is constrained to be quote-free: a Postgres
|
|
5585
|
+
* identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
|
|
5586
|
+
* Interpolating them raw ended the literal early — for enum values, inside
|
|
5587
|
+
* single quotes, where an apostrophe is not an edge case.
|
|
5588
|
+
*/
|
|
5589
|
+
var quote$1 = (value) => JSON.stringify(value);
|
|
5590
|
+
/** An object key: verbatim when it is an identifier, quoted otherwise. */
|
|
5591
|
+
var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
|
|
5592
|
+
/**
|
|
5593
|
+
* A property access on a generated table variable.
|
|
5594
|
+
*
|
|
5595
|
+
* `users.full name` is not an expression; `users["full name"]` is, and Drizzle
|
|
5596
|
+
* treats the two identically.
|
|
5597
|
+
*/
|
|
5598
|
+
var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
|
|
5599
|
+
var resolveColumnName = (propName, prop) => {
|
|
5600
|
+
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
5601
|
+
return toSnakeCase(propName);
|
|
5602
|
+
};
|
|
5603
|
+
var getPrimaryKeyProp = (collection) => {
|
|
5604
|
+
if (collection.properties) {
|
|
5605
|
+
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
|
|
5606
|
+
if (idPropEntry) {
|
|
5607
|
+
const prop = idPropEntry[1];
|
|
5608
|
+
const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
|
|
5609
|
+
return {
|
|
5610
|
+
name: idPropEntry[0],
|
|
5611
|
+
type: prop.type === "number" ? "number" : "string",
|
|
5612
|
+
isUuid
|
|
5613
|
+
};
|
|
5614
|
+
}
|
|
5635
5615
|
}
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5616
|
+
const idProp = collection.properties?.["id"];
|
|
5617
|
+
if (idProp?.type === "number") return {
|
|
5618
|
+
name: "id",
|
|
5619
|
+
type: "number",
|
|
5620
|
+
isUuid: false
|
|
5621
|
+
};
|
|
5622
|
+
return {
|
|
5623
|
+
name: "id",
|
|
5624
|
+
type: "string",
|
|
5625
|
+
isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
|
|
5626
|
+
};
|
|
5627
|
+
};
|
|
5628
|
+
/**
|
|
5629
|
+
* Given a raw DB column name (e.g. "client_id"), the Drizzle property key that
|
|
5630
|
+
* maps to it.
|
|
5631
|
+
*
|
|
5632
|
+
* One line, because the rule is shared: the Drizzle object key is the wire
|
|
5633
|
+
* name, and {@link fieldKeyForColumn} is the one definition of what a column is
|
|
5634
|
+
* named on the wire. This used to be a private copy that fell back to the
|
|
5635
|
+
* column verbatim, which is how a derived foreign key ended up served as
|
|
5636
|
+
* `author_id` beside a hand-authored `displayName`.
|
|
5637
|
+
*/
|
|
5638
|
+
var resolvePropertyKeyForColumn = (collection, column) => fieldKeyForColumn(collection, column);
|
|
5639
|
+
var isNumericId = (collection) => {
|
|
5640
|
+
return getPrimaryKeyProp(collection).type === "number";
|
|
5641
|
+
};
|
|
5642
|
+
var getPrimaryKeyName = (collection) => {
|
|
5643
|
+
return getPrimaryKeyProp(collection).name;
|
|
5644
|
+
};
|
|
5645
|
+
var isIdProperty$1 = (propName, prop, collection) => {
|
|
5646
|
+
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
5647
|
+
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
5648
|
+
};
|
|
5649
|
+
/**
|
|
5650
|
+
* The Drizzle column declaration a property compiles to, or `null` when the
|
|
5651
|
+
* property puts no column on *this* table (an inverse relation, whose column
|
|
5652
|
+
* lives on the target). Exported so it can be checked against its DDL twin
|
|
5653
|
+
* `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
|
|
5654
|
+
* with a database column and no Drizzle key.
|
|
5655
|
+
*/
|
|
5656
|
+
var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
5657
|
+
const colName = resolveColumnName(propName, prop);
|
|
5658
|
+
let columnDefinition;
|
|
5659
|
+
switch (prop.type) {
|
|
5660
|
+
case "string": {
|
|
5661
|
+
const stringProp = prop;
|
|
5662
|
+
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
|
|
5663
|
+
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
5664
|
+
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
5665
|
+
else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
5666
|
+
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
5667
|
+
else columnDefinition = `text(${quote$1(colName)})`;
|
|
5668
|
+
if (isIdProperty$1(propName, prop, collection)) columnDefinition += ".primaryKey()";
|
|
5669
|
+
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
5670
|
+
if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
|
|
5671
|
+
else if (stringProp.isId === "cuid") columnDefinition += ".default(sql`cuid()`)";
|
|
5672
|
+
else if (typeof stringProp.isId === "string") {
|
|
5673
|
+
const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`") ? stringProp.isId.substring(4, stringProp.isId.length - 1) : stringProp.isId;
|
|
5674
|
+
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
5675
|
+
}
|
|
5655
5676
|
}
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
if (properties) propertyCallbacks = buildPropertyCallbacks(properties);
|
|
5703
|
-
return {
|
|
5704
|
-
collection: resolvedCollection,
|
|
5705
|
-
callbacks,
|
|
5706
|
-
globalCallbacks,
|
|
5707
|
-
propertyCallbacks
|
|
5708
|
-
};
|
|
5709
|
-
}
|
|
5710
|
-
/**
|
|
5711
|
-
* Run the three-tier afterRead pipeline (global → collection → property) on a
|
|
5712
|
-
* single row for a collection whose callbacks have already been resolved.
|
|
5713
|
-
*/
|
|
5714
|
-
async applyAfterReadToRow(row, path, resolved, contextForCallback) {
|
|
5715
|
-
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = resolved;
|
|
5716
|
-
let out = row;
|
|
5717
|
-
if (globalCallbacks?.afterRead) out = await globalCallbacks.afterRead({
|
|
5718
|
-
collection: resolvedCollection,
|
|
5719
|
-
path,
|
|
5720
|
-
row: out,
|
|
5721
|
-
context: contextForCallback
|
|
5722
|
-
}) ?? out;
|
|
5723
|
-
if (callbacks?.afterRead) out = await callbacks.afterRead({
|
|
5724
|
-
collection: resolvedCollection,
|
|
5725
|
-
path,
|
|
5726
|
-
row: out,
|
|
5727
|
-
context: contextForCallback
|
|
5728
|
-
}) ?? out;
|
|
5729
|
-
if (propertyCallbacks?.afterRead) out = await propertyCallbacks.afterRead({
|
|
5730
|
-
collection: resolvedCollection,
|
|
5731
|
-
path,
|
|
5732
|
-
row: out,
|
|
5733
|
-
context: contextForCallback
|
|
5734
|
-
}) ?? out;
|
|
5735
|
-
return out;
|
|
5736
|
-
}
|
|
5737
|
-
static hasAfterRead(resolved) {
|
|
5738
|
-
return !!(resolved.globalCallbacks?.afterRead || resolved.callbacks?.afterRead || resolved.propertyCallbacks?.afterRead);
|
|
5739
|
-
}
|
|
5740
|
-
/**
|
|
5741
|
-
* Apply afterRead to REST/SDK read results.
|
|
5742
|
-
*
|
|
5743
|
-
* The REST / `include` path fetches rows through the raw fetch service, which
|
|
5744
|
-
* does NOT run callbacks — so without this, `afterRead` transforms (e.g. PII
|
|
5745
|
-
* masking) are silently skipped on every SDK/REST read, leaking raw data.
|
|
5746
|
-
* This choke point guarantees afterRead runs there too, matching the driver's
|
|
5747
|
-
* fetchCollection/fetchOne paths.
|
|
5748
|
-
*
|
|
5749
|
-
* It also masks embedded relation data one level deep by running the TARGET
|
|
5750
|
-
* collection's afterRead (so `post.author.email` is masked by the authors
|
|
5751
|
-
* collection, not left raw).
|
|
5752
|
-
*/
|
|
5753
|
-
async applyAfterReadForRest(rows, path) {
|
|
5754
|
-
if (!rows || rows.length === 0) return rows;
|
|
5755
|
-
const resolved = this.resolveCollectionCallbacks(void 0, path);
|
|
5756
|
-
const contextForCallback = this.buildCallContext();
|
|
5757
|
-
const hasOwn = PostgresBackendDriver.hasAfterRead(resolved);
|
|
5758
|
-
const relationTargets = {};
|
|
5759
|
-
if (resolved.collection) try {
|
|
5760
|
-
const rels = resolveCollectionRelations(resolved.collection);
|
|
5761
|
-
for (const [key, rel] of Object.entries(rels)) {
|
|
5762
|
-
const targetPath = (typeof rel.target === "function" ? rel.target() : void 0)?.slug;
|
|
5763
|
-
if (!targetPath) continue;
|
|
5764
|
-
const targetResolved = this.resolveCollectionCallbacks(void 0, targetPath);
|
|
5765
|
-
if (PostgresBackendDriver.hasAfterRead(targetResolved)) relationTargets[key] = {
|
|
5766
|
-
path: targetPath,
|
|
5767
|
-
resolved: targetResolved
|
|
5768
|
-
};
|
|
5677
|
+
if (stringProp.validation?.unique) columnDefinition += ".unique()";
|
|
5678
|
+
break;
|
|
5679
|
+
}
|
|
5680
|
+
case "number": {
|
|
5681
|
+
const numProp = prop;
|
|
5682
|
+
const isId = isIdProperty$1(propName, prop, collection);
|
|
5683
|
+
let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
|
|
5684
|
+
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
|
|
5685
|
+
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
|
|
5686
|
+
else baseType = `${numProp.columnType}(${quote$1(colName)})`;
|
|
5687
|
+
if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
5688
|
+
else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
5689
|
+
columnDefinition = baseType;
|
|
5690
|
+
const sqlContent = numProp.isId.startsWith("sql`") && numProp.isId.endsWith("`") ? numProp.isId.substring(4, numProp.isId.length - 1) : numProp.isId;
|
|
5691
|
+
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
5692
|
+
} else columnDefinition = baseType;
|
|
5693
|
+
if (isId) columnDefinition += ".primaryKey()";
|
|
5694
|
+
if (numProp.validation?.unique) columnDefinition += ".unique()";
|
|
5695
|
+
break;
|
|
5696
|
+
}
|
|
5697
|
+
case "boolean":
|
|
5698
|
+
columnDefinition = `boolean(${quote$1(colName)})`;
|
|
5699
|
+
break;
|
|
5700
|
+
case "date": {
|
|
5701
|
+
const dateProp = prop;
|
|
5702
|
+
if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
|
|
5703
|
+
else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
|
|
5704
|
+
else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
|
|
5705
|
+
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
|
|
5706
|
+
break;
|
|
5707
|
+
}
|
|
5708
|
+
case "map":
|
|
5709
|
+
if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
5710
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
5711
|
+
break;
|
|
5712
|
+
case "geopoint":
|
|
5713
|
+
columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
5714
|
+
break;
|
|
5715
|
+
case "array": {
|
|
5716
|
+
const arrayProp = prop;
|
|
5717
|
+
let colType = arrayProp.columnType;
|
|
5718
|
+
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
5719
|
+
const ofProp = arrayProp.of;
|
|
5720
|
+
if (ofProp.type === "string") colType = "text[]";
|
|
5721
|
+
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
5722
|
+
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
5769
5723
|
}
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
if (
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
}
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5724
|
+
if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
5725
|
+
else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
|
|
5726
|
+
else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
|
|
5727
|
+
else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
|
|
5728
|
+
else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
|
|
5729
|
+
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
5730
|
+
break;
|
|
5731
|
+
}
|
|
5732
|
+
case "vector": {
|
|
5733
|
+
const vp = prop;
|
|
5734
|
+
columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
|
|
5735
|
+
break;
|
|
5736
|
+
}
|
|
5737
|
+
case "binary":
|
|
5738
|
+
columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
|
|
5739
|
+
break;
|
|
5740
|
+
case "relation": {
|
|
5741
|
+
const refProp = prop;
|
|
5742
|
+
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
5743
|
+
if (!relation || relation.kind !== "belongsTo") return null;
|
|
5744
|
+
const fkFieldKey = fieldKeyForColumn(collection, relation.localKey);
|
|
5745
|
+
if (collection.properties[fkFieldKey] && propName !== fkFieldKey) return null;
|
|
5746
|
+
let targetCollection;
|
|
5747
|
+
try {
|
|
5748
|
+
targetCollection = relation.target();
|
|
5749
|
+
} catch {
|
|
5750
|
+
return null;
|
|
5792
5751
|
}
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
searchString,
|
|
5806
|
-
vectorSearch
|
|
5807
|
-
});
|
|
5808
|
-
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
5809
|
-
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
5810
|
-
const contextForCallback = this.buildCallContext();
|
|
5811
|
-
return Promise.all(rows.map(async (row) => {
|
|
5812
|
-
let fetched = row;
|
|
5813
|
-
if (globalCallbacks?.afterRead) fetched = await globalCallbacks.afterRead({
|
|
5814
|
-
collection: resolvedCollection,
|
|
5815
|
-
path,
|
|
5816
|
-
row: fetched,
|
|
5817
|
-
context: contextForCallback
|
|
5818
|
-
});
|
|
5819
|
-
if (callbacks?.afterRead) fetched = await callbacks.afterRead({
|
|
5820
|
-
collection: resolvedCollection,
|
|
5821
|
-
path,
|
|
5822
|
-
row: fetched,
|
|
5823
|
-
context: contextForCallback
|
|
5824
|
-
}) ?? fetched;
|
|
5825
|
-
if (propertyCallbacks?.afterRead) fetched = await propertyCallbacks.afterRead({
|
|
5826
|
-
collection: resolvedCollection,
|
|
5827
|
-
path,
|
|
5828
|
-
row: fetched,
|
|
5829
|
-
context: contextForCallback
|
|
5830
|
-
});
|
|
5831
|
-
return fetched;
|
|
5832
|
-
}));
|
|
5752
|
+
const fkColumnName = relation.localKey;
|
|
5753
|
+
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
5754
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
5755
|
+
const targetIdField = pkProp.name;
|
|
5756
|
+
const baseColumn = pkProp.type === "number" ? `integer(${quote$1(fkColumnName)})` : pkProp.isUuid ? `uuid(${quote$1(fkColumnName)})` : `text(${quote$1(fkColumnName)})`;
|
|
5757
|
+
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
5758
|
+
const required = prop.validation?.required;
|
|
5759
|
+
const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
|
|
5760
|
+
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
5761
|
+
let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
|
|
5762
|
+
if (required) columnDef += ".notNull()";
|
|
5763
|
+
return ` ${propKey(fkFieldKey)}: ${columnDef}`;
|
|
5833
5764
|
}
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
};
|
|
5841
|
-
this.realtimeService.registerDataDriverSubscription(subscriptionId, {
|
|
5842
|
-
clientId: "driver",
|
|
5843
|
-
type: "collection",
|
|
5844
|
-
path,
|
|
5845
|
-
collectionRequest: {
|
|
5846
|
-
filter,
|
|
5847
|
-
orderBy,
|
|
5848
|
-
order,
|
|
5849
|
-
limit,
|
|
5850
|
-
offset,
|
|
5851
|
-
startAfter,
|
|
5852
|
-
databaseId: collection?.databaseId,
|
|
5853
|
-
searchString
|
|
5765
|
+
case "reference": {
|
|
5766
|
+
const refProp = prop;
|
|
5767
|
+
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
|
|
5768
|
+
if (!targetCollection) {
|
|
5769
|
+
columnDefinition = `text(${quote$1(colName)})`;
|
|
5770
|
+
break;
|
|
5854
5771
|
}
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
order
|
|
5867
|
-
}).then((rows) => {
|
|
5868
|
-
callbackWrapper(rows);
|
|
5869
|
-
}).catch((error) => {
|
|
5870
|
-
if (onError) onError(error);
|
|
5871
|
-
});
|
|
5872
|
-
return () => {
|
|
5873
|
-
this.realtimeService.removeSubscriptionCallback(subscriptionId);
|
|
5874
|
-
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
5875
|
-
};
|
|
5772
|
+
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
5773
|
+
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
5774
|
+
const targetIdField = pkProp.name;
|
|
5775
|
+
const baseColumn = pkProp.type === "number" ? `integer(${quote$1(colName)})` : pkProp.isUuid ? `uuid(${quote$1(colName)})` : `text(${quote$1(colName)})`;
|
|
5776
|
+
const required = prop.validation?.required;
|
|
5777
|
+
const refOptions = `{ onDelete: "${required ? "cascade" : "set null"}" }`;
|
|
5778
|
+
columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
|
|
5779
|
+
if (required) columnDefinition += ".notNull()";
|
|
5780
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
5781
|
+
}
|
|
5782
|
+
default: throw new Error(`No Postgres column mapping for property '${propName}' of type '${prop.type}' in collection '${collection.slug}'. Add a case to \`getDrizzleColumn\` (and to \`getSqlColumnType\`, which must agree).`);
|
|
5876
5783
|
}
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5784
|
+
if (prop.validation?.required) columnDefinition += ".notNull()";
|
|
5785
|
+
return ` ${propKey(propName)}: ${columnDefinition}`;
|
|
5786
|
+
};
|
|
5787
|
+
/**
|
|
5788
|
+
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
5789
|
+
*
|
|
5790
|
+
* The clause is SQL being written into a TypeScript file, so it has to survive
|
|
5791
|
+
* being read back as a template literal. Three characters do not:
|
|
5792
|
+
*
|
|
5793
|
+
* - `` ` `` closes the template early, and the rest of the clause becomes code.
|
|
5794
|
+
* - `${` opens an interpolation — the file stops compiling, or worse, compiles
|
|
5795
|
+
* against whatever identifier happens to be in scope.
|
|
5796
|
+
* - `\` is an escape, and Drizzle's `sql` tag reads the *cooked* strings, not
|
|
5797
|
+
* `.raw`. So a policy written as `email ~ '^admin\.user@corp\.com$'` reaches
|
|
5798
|
+
* the database as `^admin.user@corp.com$`, where every `\.` now matches any
|
|
5799
|
+
* character. A `USING` clause is a security boundary and that one silently
|
|
5800
|
+
* widened it — the SQL file emitted by the DDL generator kept the backslashes
|
|
5801
|
+
* while this path dropped them, so the two disagreed about who could read the
|
|
5802
|
+
* table.
|
|
5803
|
+
*
|
|
5804
|
+
* Escaping here rather than in the compiler: the clause is correct SQL, and it
|
|
5805
|
+
* is only this destination that has an opinion about backslashes.
|
|
5806
|
+
*/
|
|
5807
|
+
var wrapSql = (clause) => `sql\`${clause.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``;
|
|
5808
|
+
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
5809
|
+
const tableName = getTableName$1(collection);
|
|
5810
|
+
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
5811
|
+
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
5812
|
+
return ops.map((op, opIdx) => {
|
|
5813
|
+
return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
5814
|
+
}).join("");
|
|
5815
|
+
};
|
|
5816
|
+
/**
|
|
5817
|
+
* Generates a single pgPolicy() call for one specific operation.
|
|
5818
|
+
*/
|
|
5819
|
+
var generateSinglePolicyCode = (collection, rule, operation, policyName, resolveCollection) => {
|
|
5820
|
+
const mode = rule.mode ?? "permissive";
|
|
5821
|
+
const needsUsing = operation !== "insert";
|
|
5822
|
+
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
5823
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
5824
|
+
let usingClause = needsUsing && usingExpr ? wrapSql(policyToPostgres(usingExpr, collection, { resolveCollection })) : null;
|
|
5825
|
+
let withCheckClause = needsWithCheck && withCheckExpr ? wrapSql(policyToPostgres(withCheckExpr, collection, { resolveCollection })) : null;
|
|
5826
|
+
if (!usingClause && needsUsing) usingClause = "sql`false`";
|
|
5827
|
+
if (!withCheckClause && needsWithCheck) withCheckClause = "sql`false`";
|
|
5828
|
+
const parts = [];
|
|
5829
|
+
parts.push(`as: "${mode}"`);
|
|
5830
|
+
parts.push(`for: "${operation}"`);
|
|
5831
|
+
const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
5832
|
+
parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
|
|
5833
|
+
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
5834
|
+
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
5835
|
+
return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
|
|
5836
|
+
};
|
|
5837
|
+
/**
|
|
5838
|
+
* Computes a deterministic shared relation name for Drizzle.
|
|
5839
|
+
*
|
|
5840
|
+
* Drizzle requires both sides of a relation (owning + inverse) to use the
|
|
5841
|
+
* exact same `relationName` string so it can pair them. Each collection
|
|
5842
|
+
* definition may use a different local `relationName`, so we need a canonical
|
|
5843
|
+
* form that both sides can independently compute.
|
|
5844
|
+
*
|
|
5845
|
+
* Strategy: `{owningTable}_{foreignKey}`
|
|
5846
|
+
* - owning side → `{thisTable}_{localKey}` e.g. "jobs_company_id"
|
|
5847
|
+
* - inverse side → `{targetTable}_{foreignKeyOnTarget}` e.g. "jobs_company_id"
|
|
5848
|
+
*
|
|
5849
|
+
* For M2M with junction tables the owning relation name is already shared via
|
|
5850
|
+
* the junction table wiring, so we keep it as-is.
|
|
5851
|
+
*
|
|
5852
|
+
* Falls back to the local relation name when the counterpart can't be resolved.
|
|
5853
|
+
*/
|
|
5854
|
+
var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
5855
|
+
const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
|
|
5856
|
+
if (rel.kind === "belongsTo") {
|
|
5857
|
+
const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
|
|
5858
|
+
return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
|
|
5902
5859
|
}
|
|
5903
|
-
|
|
5904
|
-
const
|
|
5905
|
-
const
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
clientId: "driver",
|
|
5910
|
-
type: "single",
|
|
5911
|
-
path,
|
|
5912
|
-
id
|
|
5913
|
-
});
|
|
5914
|
-
this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper);
|
|
5915
|
-
this.fetchOne({
|
|
5916
|
-
path,
|
|
5917
|
-
id,
|
|
5918
|
-
collection
|
|
5919
|
-
}).then((row) => {
|
|
5920
|
-
if (row) onUpdate(row);
|
|
5921
|
-
}).catch((error) => {
|
|
5922
|
-
if (onError) onError(error);
|
|
5923
|
-
});
|
|
5924
|
-
return () => {
|
|
5925
|
-
this.realtimeService.removeSubscriptionCallback(subscriptionId);
|
|
5926
|
-
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
5927
|
-
};
|
|
5860
|
+
if (rel.kind === "hasMany" || rel.kind === "hasOne") try {
|
|
5861
|
+
const targetCollection = rel.target();
|
|
5862
|
+
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
5863
|
+
return `${getTableName$1(targetCollection)}_${normalisedFK}`;
|
|
5864
|
+
} catch {
|
|
5865
|
+
return fallback;
|
|
5928
5866
|
}
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5867
|
+
return fallback;
|
|
5868
|
+
};
|
|
5869
|
+
var generateSchema = async (allCollections, stripPolicies = false) => {
|
|
5870
|
+
const collections = sortCollectionsBySlug(relationalCollections(allCollections));
|
|
5871
|
+
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
5872
|
+
const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
|
|
5873
|
+
const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
|
|
5874
|
+
const hasBinary = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "binary"));
|
|
5875
|
+
const hasSearch = collections.some((c) => buildSearchColumnSpec(c) !== void 0);
|
|
5876
|
+
const pgCoreImports = [
|
|
5877
|
+
"primaryKey",
|
|
5878
|
+
"pgTable",
|
|
5879
|
+
"integer",
|
|
5880
|
+
"varchar",
|
|
5881
|
+
"text",
|
|
5882
|
+
"char",
|
|
5883
|
+
"boolean",
|
|
5884
|
+
"timestamp",
|
|
5885
|
+
"date",
|
|
5886
|
+
"time",
|
|
5887
|
+
"jsonb",
|
|
5888
|
+
"json",
|
|
5889
|
+
"pgEnum",
|
|
5890
|
+
"numeric",
|
|
5891
|
+
"real",
|
|
5892
|
+
"doublePrecision",
|
|
5893
|
+
"bigint",
|
|
5894
|
+
"serial",
|
|
5895
|
+
"bigserial",
|
|
5896
|
+
"pgPolicy"
|
|
5897
|
+
];
|
|
5898
|
+
if (hasUuid) pgCoreImports.push("uuid");
|
|
5899
|
+
if (hasVector) pgCoreImports.push("vector");
|
|
5900
|
+
if (hasBinary || hasSearch) pgCoreImports.push("customType");
|
|
5901
|
+
const uniqueSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
|
|
5902
|
+
if (uniqueSchemas.length > 0) pgCoreImports.push("pgSchema");
|
|
5903
|
+
schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
|
|
5904
|
+
schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
|
|
5905
|
+
uniqueSchemas.forEach((schema) => {
|
|
5906
|
+
schemaContent += `export const ${schema}Schema = pgSchema("${schema}");\n`;
|
|
5907
|
+
});
|
|
5908
|
+
if (uniqueSchemas.length > 0) schemaContent += "\n";
|
|
5909
|
+
const exportedTableVars = [];
|
|
5910
|
+
const exportedEnumVars = [];
|
|
5911
|
+
const exportedRelationVars = [];
|
|
5912
|
+
const allTablesToGenerate = /* @__PURE__ */ new Map();
|
|
5913
|
+
collections.forEach((collection) => {
|
|
5914
|
+
const collectionPath = getTableName$1(collection);
|
|
5915
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
5916
|
+
if ("enum" in prop && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
5917
|
+
const enumVarName = getEnumVarName(collectionPath, propName);
|
|
5918
|
+
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
5919
|
+
const values = Array.isArray(prop.enum) ? prop.enum.map((v) => String(typeof v === "object" && v !== null && "id" in v ? v.id : v)) : Object.keys(prop.enum);
|
|
5920
|
+
if (values.length > 0) {
|
|
5921
|
+
schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
|
|
5922
|
+
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
5923
|
+
}
|
|
5939
5924
|
}
|
|
5940
|
-
}
|
|
5941
|
-
|
|
5925
|
+
});
|
|
5926
|
+
});
|
|
5927
|
+
schemaContent += "\n";
|
|
5928
|
+
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
5929
|
+
for (const collection of collections) {
|
|
5930
|
+
const tableName = getTableName$1(collection);
|
|
5931
|
+
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
5932
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
5933
|
+
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
5934
|
+
const junctionTableName = relation.through.table;
|
|
5935
|
+
if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
|
|
5936
|
+
collection: {
|
|
5937
|
+
table: junctionTableName,
|
|
5938
|
+
properties: {}
|
|
5939
|
+
},
|
|
5940
|
+
isJunction: true,
|
|
5941
|
+
relation,
|
|
5942
|
+
sourceCollection: collection
|
|
5943
|
+
});
|
|
5942
5944
|
}
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5945
|
+
}
|
|
5946
|
+
for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
|
|
5947
|
+
const tableVarName = getTableVarName(tableName);
|
|
5948
|
+
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
5949
|
+
const targetCollection = relation.target();
|
|
5950
|
+
const tableCreator = "pgTable";
|
|
5951
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
5952
|
+
const { sourceColumn, targetColumn } = relation.through;
|
|
5953
|
+
const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
|
|
5954
|
+
const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text";
|
|
5955
|
+
const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text";
|
|
5956
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
5957
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
5958
|
+
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
5959
|
+
schemaContent += ` ${propKey(sourceColumn)}: ${sourceColType}(${quote$1(sourceColumn)}).notNull().references(() => ${member(getTableVarName(getTableName$1(sourceCollection)), sourceId)}, ${refOptions}),\n`;
|
|
5960
|
+
schemaContent += ` ${propKey(targetColumn)}: ${targetColType}(${quote$1(targetColumn)}).notNull().references(() => ${member(getTableVarName(getTableName$1(targetCollection)), targetId)}, ${refOptions}),\n`;
|
|
5961
|
+
schemaContent += "}, (table) => ([\n";
|
|
5962
|
+
schemaContent += ` primaryKey({ columns: [${member("table", sourceColumn)}, ${member("table", targetColumn)}] }),\n`;
|
|
5963
|
+
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
5964
|
+
if (!stripPolicies && junctionSpec) {
|
|
5965
|
+
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
5966
|
+
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
5967
|
+
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
5968
|
+
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
5953
5969
|
});
|
|
5954
|
-
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
5955
5970
|
}
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
5965
|
-
|
|
5966
|
-
|
|
5971
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
5972
|
+
} else if (!isJunction) {
|
|
5973
|
+
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
5974
|
+
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
5975
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
5976
|
+
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
5977
|
+
const columns = /* @__PURE__ */ new Set();
|
|
5978
|
+
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
5979
|
+
const columnString = getDrizzleColumn(propName, prop, collection, collections);
|
|
5980
|
+
if (columnString) columns.add(columnString);
|
|
5981
|
+
});
|
|
5982
|
+
const searchSpec = buildSearchColumnSpec(collection);
|
|
5983
|
+
if (searchSpec) {
|
|
5984
|
+
columns.add(` ${propKey(searchSpec.column)}: customType({ dataType() { return 'tsvector'; } })(${quote$1(searchSpec.column)}).generatedAlwaysAs(sql\`${searchSpec.expression}\`)`);
|
|
5985
|
+
if (searchSpec.fuzzy) columns.add(` ${propKey(searchSpec.fuzzy.column)}: text(${quote$1(searchSpec.fuzzy.column)}).generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`);
|
|
5967
5986
|
}
|
|
5968
|
-
if (
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
context: contextForCallback
|
|
5987
|
+
if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
|
|
5988
|
+
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
5989
|
+
const securityRules = getEffectiveSecurityRules(collection);
|
|
5990
|
+
if (!stripPolicies && securityRules.length > 0) {
|
|
5991
|
+
schemaContent += "\n}, (table) => ([\n";
|
|
5992
|
+
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
5993
|
+
securityRules.forEach((rule, idx) => {
|
|
5994
|
+
schemaContent += generatePolicyCode(collection, rule, idx, resolveCollection);
|
|
5977
5995
|
});
|
|
5978
|
-
|
|
5979
|
-
}
|
|
5996
|
+
schemaContent += "])).enableRLS();\n\n";
|
|
5997
|
+
} else schemaContent += "\n}).enableRLS();\n\n";
|
|
5980
5998
|
}
|
|
5981
|
-
if (
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
});
|
|
5999
|
+
if (!exportedTableVars.includes(tableVarName)) exportedTableVars.push(tableVarName);
|
|
6000
|
+
}
|
|
6001
|
+
for (const [tableName, { collection, isJunction }] of allTablesToGenerate.entries()) {
|
|
6002
|
+
const tableVarName = getTableVarName(tableName);
|
|
6003
|
+
const tableRelations = [];
|
|
6004
|
+
if (isJunction) {
|
|
6005
|
+
const relationInfo = Array.from(allTablesToGenerate.values()).find((v) => v.isJunction && getTableName$1(v.collection) === tableName);
|
|
6006
|
+
if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
|
|
6007
|
+
const { relation, sourceCollection } = relationInfo;
|
|
6008
|
+
const targetCollection = relation.target();
|
|
6009
|
+
const sourceTableVar = getTableVarName(getTableName$1(sourceCollection));
|
|
6010
|
+
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
6011
|
+
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
6012
|
+
const targetId = getPrimaryKeyName(targetCollection);
|
|
6013
|
+
if (!relation?.through) throw new Error("Internal, the relation should have a through property. Relations passed to this script should sanitized first with sanitizeRelation().");
|
|
6014
|
+
const owningRelationName = relation.relationName ?? toSnakeCase(getTableName$1(targetCollection));
|
|
6015
|
+
let inverseRelationName = null;
|
|
6016
|
+
try {
|
|
6017
|
+
const targetRelations = resolveCollectionRelations(targetCollection);
|
|
6018
|
+
for (const [, targetRel] of Object.entries(targetRelations)) if (targetRel.kind !== "belongsTo" && targetRel.cardinality === "many" && targetRel.relationName === owningRelationName) {
|
|
6019
|
+
inverseRelationName = targetRel.relationName ?? null;
|
|
6020
|
+
break;
|
|
6021
|
+
}
|
|
6022
|
+
} catch {}
|
|
6023
|
+
tableRelations.push(` ${quote$1(relation.through.sourceColumn)}: one(${sourceTableVar}, {\n fields: [${member(tableVarName, relation.through.sourceColumn)}],\n references: [${member(sourceTableVar, sourceId)}],\n relationName: ${quote$1(owningRelationName)}\n })`);
|
|
6024
|
+
const targetRelationName = inverseRelationName ? inverseRelationName : `${tableName}_${relation.through.targetColumn}`;
|
|
6025
|
+
tableRelations.push(` ${quote$1(relation.through.targetColumn)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, relation.through.targetColumn)}],\n references: [${member(targetTableVar, targetId)}],\n relationName: ${quote$1(targetRelationName)}\n })`);
|
|
6008
6026
|
}
|
|
6009
|
-
|
|
6010
|
-
const
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6027
|
+
} else {
|
|
6028
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6029
|
+
const emittedRelationNames = /* @__PURE__ */ new Set();
|
|
6030
|
+
for (const [relationKey, rel] of Object.entries(resolvedRelations)) try {
|
|
6031
|
+
const target = rel.target();
|
|
6032
|
+
const targetTableVar = getTableVarName(getTableName$1(target));
|
|
6033
|
+
const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
|
|
6034
|
+
const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
|
|
6035
|
+
if (emittedRelationNames.has(deduplicationKey)) continue;
|
|
6036
|
+
emittedRelationNames.add(deduplicationKey);
|
|
6037
|
+
switch (rel.kind) {
|
|
6038
|
+
case "belongsTo": {
|
|
6039
|
+
const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
|
|
6040
|
+
tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6041
|
+
break;
|
|
6042
|
+
}
|
|
6043
|
+
case "hasOne":
|
|
6044
|
+
tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6045
|
+
break;
|
|
6046
|
+
case "hasMany":
|
|
6047
|
+
tableRelations.push(` ${quote$1(relationKey)}: many(${targetTableVar}, { relationName: ${quote$1(drizzleRelationName)} })`);
|
|
6048
|
+
break;
|
|
6049
|
+
case "manyToMany": {
|
|
6050
|
+
const junctionTableVar = getTableVarName(rel.through.table);
|
|
6051
|
+
tableRelations.push(` ${quote$1(relationKey)}: many(${junctionTableVar}, { relationName: ${quote$1(drizzleRelationName)} })`);
|
|
6052
|
+
break;
|
|
6053
|
+
}
|
|
6054
|
+
case "via": break;
|
|
6055
|
+
}
|
|
6056
|
+
} catch (e) {
|
|
6057
|
+
logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
|
|
6039
6058
|
}
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6059
|
+
for (const otherCollection of collections) {
|
|
6060
|
+
if (otherCollection.slug === collection.slug) continue;
|
|
6061
|
+
const otherRelations = resolveCollectionRelations(otherCollection);
|
|
6062
|
+
for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (hasForeignKeyOnTarget(otherRel)) try {
|
|
6063
|
+
if (otherRel.target().slug === collection.slug) {
|
|
6064
|
+
const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
|
|
6065
|
+
const deduplicationKey = `${drizzleRelationName}::belongsTo`;
|
|
6066
|
+
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
6067
|
+
const otherTableVar = getTableVarName(getTableName$1(otherCollection));
|
|
6068
|
+
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
6069
|
+
const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
|
|
6070
|
+
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
6071
|
+
tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
6072
|
+
emittedRelationNames.add(deduplicationKey);
|
|
6073
|
+
}
|
|
6074
|
+
}
|
|
6075
|
+
} catch (e) {}
|
|
6076
|
+
}
|
|
6077
|
+
}
|
|
6078
|
+
if (tableRelations.length > 0) {
|
|
6079
|
+
const relVarName = `${tableVarName}Relations`;
|
|
6080
|
+
schemaContent += `export const ${relVarName} = drizzleRelations(${tableVarName}, ({ one, many }) => ({\n${tableRelations.join(",\n")}\n}));\n\n`;
|
|
6081
|
+
if (!exportedRelationVars.includes(relVarName)) exportedRelationVars.push(relVarName);
|
|
6082
|
+
}
|
|
6083
|
+
}
|
|
6084
|
+
const tablesExport = `export const tables = { ${exportedTableVars.join(", ")} };\n`;
|
|
6085
|
+
const enumsExport = `export const enums = { ${exportedEnumVars.join(", ")} };\n`;
|
|
6086
|
+
const relationsExport = `export const relations = { ${exportedRelationVars.join(", ")} };\n\n`;
|
|
6087
|
+
schemaContent += tablesExport + enumsExport + relationsExport;
|
|
6088
|
+
return schemaContent;
|
|
6089
|
+
};
|
|
6090
|
+
//#endregion
|
|
6091
|
+
//#region src/schema/classify-change.ts
|
|
6092
|
+
var VERDICT_RANK = {
|
|
6093
|
+
safe: 0,
|
|
6094
|
+
diverges: 1,
|
|
6095
|
+
"needs-migration": 2
|
|
6096
|
+
};
|
|
6097
|
+
var bySlug = (collections) => {
|
|
6098
|
+
const map = /* @__PURE__ */ new Map();
|
|
6099
|
+
for (const collection of collections) if (collection.slug) map.set(collection.slug, collection);
|
|
6100
|
+
return map;
|
|
6101
|
+
};
|
|
6102
|
+
var propertiesOf = (collection) => collection.properties ?? {};
|
|
6103
|
+
/** Enum values a string property declares, or undefined when it is not an enum. */
|
|
6104
|
+
var enumValuesOf = (prop) => {
|
|
6105
|
+
const values = prop.enum;
|
|
6106
|
+
if (!Array.isArray(values)) return void 0;
|
|
6107
|
+
return values.map((value) => typeof value === "string" ? value : String(value?.id ?? value));
|
|
6108
|
+
};
|
|
6109
|
+
var isRequired = (prop) => prop.validation?.required === true;
|
|
6110
|
+
var isIdProperty = (prop) => Boolean(prop.isId);
|
|
6111
|
+
/**
|
|
6112
|
+
* A change to any of these alters the physical column, which the ensure path
|
|
6113
|
+
* cannot do. Compared as a tuple so a change to *any* of them is caught without
|
|
6114
|
+
* this module having to re-derive the SQL type — which is the DDL generator's
|
|
6115
|
+
* job, and duplicating it here is how the two would drift.
|
|
6116
|
+
*/
|
|
6117
|
+
var physicalShapeOf = (prop) => JSON.stringify([
|
|
6118
|
+
prop.type,
|
|
6119
|
+
prop.columnType ?? null,
|
|
6120
|
+
prop.dimensions ?? null,
|
|
6121
|
+
prop.isId ?? null,
|
|
6122
|
+
prop.validation?.max ?? null
|
|
6123
|
+
]);
|
|
6124
|
+
/** Whether the table behind a collection exists and is empty. */
|
|
6125
|
+
var tableIsEmpty = (collection, facts) => {
|
|
6126
|
+
if (!facts?.populatedTables) return false;
|
|
6127
|
+
const key = qualifiedTable(collection);
|
|
6128
|
+
if (!facts.tables.has(key)) return true;
|
|
6129
|
+
return !facts.populatedTables.has(key);
|
|
6130
|
+
};
|
|
6131
|
+
var qualifiedTable = (collection) => {
|
|
6132
|
+
return `${collection.schema ?? "public"}.${getTableName$1(collection)}`;
|
|
6133
|
+
};
|
|
6134
|
+
/**
|
|
6135
|
+
* Classify the difference between two collection sets.
|
|
6136
|
+
*
|
|
6137
|
+
* `before` is what the running database was built from; `after` is what the
|
|
6138
|
+
* editor is proposing. Order within each array is irrelevant.
|
|
6139
|
+
*
|
|
6140
|
+
* `facts` is what the database actually looks like. Supplied by the live
|
|
6141
|
+
* editor; omitted by callers reasoning about collections in the abstract, who
|
|
6142
|
+
* get the conservative reading.
|
|
6143
|
+
*/
|
|
6144
|
+
function classifyCollectionChanges(before, after, facts) {
|
|
6145
|
+
const previous = bySlug(before);
|
|
6146
|
+
const next = bySlug(after);
|
|
6147
|
+
const changes = [];
|
|
6148
|
+
for (const [slug, collection] of next) {
|
|
6149
|
+
if (!previous.has(slug)) {
|
|
6150
|
+
changes.push({
|
|
6151
|
+
kind: "add-collection",
|
|
6152
|
+
verdict: "safe",
|
|
6153
|
+
collection: slug,
|
|
6154
|
+
detail: `New collection "${slug}" — creates table "${getTableName$1(collection)}".`
|
|
6047
6155
|
});
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6156
|
+
continue;
|
|
6157
|
+
}
|
|
6158
|
+
classifyProperties(previous.get(slug), collection, changes, facts);
|
|
6159
|
+
}
|
|
6160
|
+
for (const [slug, collection] of previous) {
|
|
6161
|
+
if (next.has(slug)) continue;
|
|
6162
|
+
changes.push({
|
|
6163
|
+
kind: "remove-collection",
|
|
6164
|
+
verdict: "needs-migration",
|
|
6165
|
+
collection: slug,
|
|
6166
|
+
detail: `Collection "${slug}" was removed, which would drop table "${getTableName$1(collection)}" and everything in it.`,
|
|
6167
|
+
remedy: "The ensure path never drops anything, so this cannot be applied here. Remove the collection in a migration you have read, or keep it and stop serving it."
|
|
6168
|
+
});
|
|
6169
|
+
}
|
|
6170
|
+
const verdict = changes.reduce((worst, change) => VERDICT_RANK[change.verdict] > VERDICT_RANK[worst] ? change.verdict : worst, "safe");
|
|
6171
|
+
return {
|
|
6172
|
+
changes,
|
|
6173
|
+
verdict,
|
|
6174
|
+
applicable: verdict === "safe"
|
|
6175
|
+
};
|
|
6176
|
+
}
|
|
6177
|
+
function classifyProperties(before, after, changes, facts) {
|
|
6178
|
+
const slug = after.slug ?? "";
|
|
6179
|
+
const previous = propertiesOf(before);
|
|
6180
|
+
const next = propertiesOf(after);
|
|
6181
|
+
const empty = tableIsEmpty(after, facts);
|
|
6182
|
+
for (const [name, prop] of Object.entries(next)) {
|
|
6183
|
+
const old = previous[name];
|
|
6184
|
+
if (!old) {
|
|
6185
|
+
if (isRequired(prop) && !empty) changes.push({
|
|
6186
|
+
kind: "add-property",
|
|
6187
|
+
verdict: "diverges",
|
|
6188
|
+
collection: slug,
|
|
6189
|
+
property: name,
|
|
6190
|
+
detail: `"${name}" is required, but "${getTableName$1(after)}" already holds rows, so NOT NULL would be checked against data that has no value for it yet. The column would arrive nullable.`,
|
|
6191
|
+
remedy: "Add it optional, backfill every row, then make it required — the editor will apply the constraint once no row violates it."
|
|
6053
6192
|
});
|
|
6054
|
-
else
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
collection:
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
values: updatedValues,
|
|
6063
|
-
previousValues: void 0,
|
|
6064
|
-
status,
|
|
6065
|
-
context: contextForCallback
|
|
6066
|
-
});
|
|
6067
|
-
if (callbacks?.afterSaveError) await callbacks.afterSaveError({
|
|
6068
|
-
collection: resolvedCollection,
|
|
6069
|
-
path,
|
|
6070
|
-
id: id || "unknown",
|
|
6071
|
-
values: updatedValues,
|
|
6072
|
-
previousValues: void 0,
|
|
6073
|
-
status,
|
|
6074
|
-
context: contextForCallback
|
|
6075
|
-
});
|
|
6076
|
-
if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
|
|
6077
|
-
collection: resolvedCollection,
|
|
6078
|
-
path,
|
|
6079
|
-
id: id || "unknown",
|
|
6080
|
-
values: updatedValues,
|
|
6081
|
-
previousValues: void 0,
|
|
6082
|
-
status,
|
|
6083
|
-
context: contextForCallback
|
|
6193
|
+
else {
|
|
6194
|
+
const column = resolveColumnName$1(name, prop);
|
|
6195
|
+
changes.push({
|
|
6196
|
+
kind: "add-property",
|
|
6197
|
+
verdict: "safe",
|
|
6198
|
+
collection: slug,
|
|
6199
|
+
property: name,
|
|
6200
|
+
detail: isRequired(prop) ? `New required property "${name}" — adds column "${column}" NOT NULL, which "${getTableName$1(after)}" can take because it holds no rows.` : `New optional property "${name}" — adds column "${column}".`
|
|
6084
6201
|
});
|
|
6085
6202
|
}
|
|
6086
|
-
|
|
6203
|
+
continue;
|
|
6087
6204
|
}
|
|
6205
|
+
classifyProperty(slug, after, name, old, prop, changes, facts);
|
|
6206
|
+
}
|
|
6207
|
+
for (const [name, prop] of Object.entries(previous)) {
|
|
6208
|
+
if (next[name]) continue;
|
|
6209
|
+
changes.push({
|
|
6210
|
+
kind: "remove-property",
|
|
6211
|
+
verdict: "needs-migration",
|
|
6212
|
+
collection: slug,
|
|
6213
|
+
property: name,
|
|
6214
|
+
detail: `"${name}" was removed, which would drop column "${resolveColumnName$1(name, prop)}" and its data.`,
|
|
6215
|
+
remedy: "The ensure path never drops a column. Remove it in a migration you have read, or leave the column and stop exposing the property."
|
|
6216
|
+
});
|
|
6088
6217
|
}
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6218
|
+
}
|
|
6219
|
+
function classifyProperty(slug, collection, name, before, after, changes, facts) {
|
|
6220
|
+
const beforeColumn = resolveColumnName$1(name, before);
|
|
6221
|
+
const afterColumn = resolveColumnName$1(name, after);
|
|
6222
|
+
if (beforeColumn !== afterColumn) changes.push({
|
|
6223
|
+
kind: "rename-column",
|
|
6224
|
+
verdict: "needs-migration",
|
|
6225
|
+
collection: slug,
|
|
6226
|
+
property: name,
|
|
6227
|
+
detail: `"${name}" changes column from "${beforeColumn}" to "${afterColumn}".`,
|
|
6228
|
+
remedy: "The ensure path only renames a column through its legacy-name path, which this is not. Rename it in a migration, or the old column stays and the new one is created empty beside it."
|
|
6229
|
+
});
|
|
6230
|
+
if (isIdProperty(before) !== isIdProperty(after)) {
|
|
6231
|
+
changes.push({
|
|
6232
|
+
kind: "change-primary-key",
|
|
6233
|
+
verdict: "needs-migration",
|
|
6234
|
+
collection: slug,
|
|
6235
|
+
property: name,
|
|
6236
|
+
detail: `"${name}" changes whether it is the primary key.`,
|
|
6237
|
+
remedy: "A primary key change rewrites the table and every foreign key into it. Migration only."
|
|
6238
|
+
});
|
|
6239
|
+
return;
|
|
6240
|
+
}
|
|
6241
|
+
if (physicalShapeOf(before) !== physicalShapeOf(after)) changes.push({
|
|
6242
|
+
kind: "change-property-type",
|
|
6243
|
+
verdict: "needs-migration",
|
|
6244
|
+
collection: slug,
|
|
6245
|
+
property: name,
|
|
6246
|
+
detail: `"${name}" changes physical type — ${before.type} to ${after.type}.`,
|
|
6247
|
+
remedy: "There is no ALTER COLUMN TYPE in the ensure path, and a cast can fail on data that is already there. Change it in a migration."
|
|
6248
|
+
});
|
|
6249
|
+
if (!isRequired(before) && isRequired(after)) {
|
|
6250
|
+
const empty = tableIsEmpty(collection, facts);
|
|
6251
|
+
changes.push(empty ? {
|
|
6252
|
+
kind: "change-required",
|
|
6253
|
+
verdict: "safe",
|
|
6254
|
+
collection: slug,
|
|
6255
|
+
property: name,
|
|
6256
|
+
detail: `"${name}" became required — sets NOT NULL on "${afterColumn}", which "${getTableName$1(collection)}" can take because it holds no rows.`
|
|
6257
|
+
} : {
|
|
6258
|
+
kind: "change-required",
|
|
6259
|
+
verdict: "diverges",
|
|
6260
|
+
collection: slug,
|
|
6261
|
+
property: name,
|
|
6262
|
+
detail: `"${name}" became required, but "${getTableName$1(collection)}" holds rows and SET NOT NULL is checked against every one of them. The database would keep accepting nulls.`,
|
|
6263
|
+
remedy: `Backfill first — UPDATE the rows where "${afterColumn}" IS NULL — then apply this again.`
|
|
6130
6264
|
});
|
|
6131
6265
|
}
|
|
6266
|
+
if (isRequired(before) && !isRequired(after)) changes.push({
|
|
6267
|
+
kind: "change-required",
|
|
6268
|
+
verdict: "safe",
|
|
6269
|
+
collection: slug,
|
|
6270
|
+
property: name,
|
|
6271
|
+
detail: `"${name}" is no longer required — drops NOT NULL from "${afterColumn}".`
|
|
6272
|
+
});
|
|
6273
|
+
classifyEnum(slug, collection, name, before, after, changes, facts);
|
|
6274
|
+
}
|
|
6275
|
+
function classifyEnum(slug, collection, name, before, after, changes, facts) {
|
|
6276
|
+
const oldValues = enumValuesOf(before);
|
|
6277
|
+
const newValues = enumValuesOf(after);
|
|
6278
|
+
if (!oldValues || !newValues) return;
|
|
6279
|
+
const added = newValues.filter((value) => !oldValues.includes(value));
|
|
6280
|
+
const removed = oldValues.filter((value) => !newValues.includes(value));
|
|
6281
|
+
if (added.length > 0) {
|
|
6282
|
+
const seesTheType = facts?.enumValues !== void 0;
|
|
6283
|
+
changes.push(seesTheType ? {
|
|
6284
|
+
kind: "add-enum-value",
|
|
6285
|
+
verdict: "safe",
|
|
6286
|
+
collection: slug,
|
|
6287
|
+
property: name,
|
|
6288
|
+
detail: `"${name}" gains ${added.map((v) => `"${v}"`).join(", ")} — ALTER TYPE … ADD VALUE.`
|
|
6289
|
+
} : {
|
|
6290
|
+
kind: "add-enum-value",
|
|
6291
|
+
verdict: "diverges",
|
|
6292
|
+
collection: slug,
|
|
6293
|
+
property: name,
|
|
6294
|
+
detail: `"${name}" gains ${added.map((v) => `"${v}"`).join(", ")}, and the values this type already has are not known here, so whether they would land cannot be said.`,
|
|
6295
|
+
remedy: "Plan this against the database it is destined for."
|
|
6296
|
+
});
|
|
6297
|
+
}
|
|
6298
|
+
if (removed.length > 0) changes.push({
|
|
6299
|
+
kind: "remove-enum-value",
|
|
6300
|
+
verdict: "needs-migration",
|
|
6301
|
+
collection: slug,
|
|
6302
|
+
property: name,
|
|
6303
|
+
detail: `"${name}" drops ${removed.map((v) => `"${v}"`).join(", ")}.`,
|
|
6304
|
+
remedy: "Postgres cannot remove a value from an enum type. Recreate the type in a migration, after rewriting every row still using the value."
|
|
6305
|
+
});
|
|
6306
|
+
}
|
|
6307
|
+
//#endregion
|
|
6308
|
+
//#region src/schema/generate-schema-commit.ts
|
|
6309
|
+
/**
|
|
6310
|
+
* Everything a schema change has to write, as file contents.
|
|
6311
|
+
*
|
|
6312
|
+
* A live schema editor that only edits the collection source produces a repo
|
|
6313
|
+
* that does not build: `backend/src/schema.generated.ts` is a committed
|
|
6314
|
+
* artifact, and a stale one has broken every deploy at least once. So the unit
|
|
6315
|
+
* of a schema change is not a file, it is a **commit** — and this module
|
|
6316
|
+
* produces one, without touching a disk, a database or a network.
|
|
6317
|
+
*
|
|
6318
|
+
* Pure on purpose. The risky half of "commit, then apply" is generating a
|
|
6319
|
+
* correct commit; keeping it a function from collections to file contents is
|
|
6320
|
+
* what lets that half be tested by building a database from the result and
|
|
6321
|
+
* comparing it to the one the change describes.
|
|
6322
|
+
*
|
|
6323
|
+
* ## Where the migration comes from, and why not from Atlas
|
|
6324
|
+
*
|
|
6325
|
+
* `rebase db generate` mints migrations by running Atlas over the generated
|
|
6326
|
+
* `schema.sql`. Atlas is an external binary, it wants a dev database, and it
|
|
6327
|
+
* maintains an `atlas.sum` integrity file whose hash this module would have to
|
|
6328
|
+
* reproduce byte-for-byte to stay valid.
|
|
6329
|
+
*
|
|
6330
|
+
* None of that is necessary here, because of what the editor is allowed to do.
|
|
6331
|
+
* `classify-change.ts` refuses anything the boot-time ensure path cannot
|
|
6332
|
+
* express, which leaves only additive statements — and those are computable as
|
|
6333
|
+
* a plain difference between two ensure plans:
|
|
6334
|
+
*
|
|
6335
|
+
* plan(after, nothing) − plan(before, nothing)
|
|
6336
|
+
*
|
|
6337
|
+
* Both plans are pure functions of the collections, every statement is
|
|
6338
|
+
* idempotent, and the difference is exactly what the change adds. No diff
|
|
6339
|
+
* engine, no database, no binary.
|
|
6340
|
+
*
|
|
6341
|
+
* The statements are *returned* rather than written into a migration file. A
|
|
6342
|
+
* project provisioned by boot-ensure needs no migration at all — its
|
|
6343
|
+
* collections are the schema — while a project provisioned by migrations needs
|
|
6344
|
+
* the file to carry an Atlas hash, which only Atlas can mint. Writing a
|
|
6345
|
+
* migration this module cannot make valid would be worse than handing the
|
|
6346
|
+
* statements to a caller who knows which kind of project it is.
|
|
6347
|
+
*/
|
|
6348
|
+
var SchemaCommitError = class extends Error {
|
|
6349
|
+
classified;
|
|
6350
|
+
constructor(message, classified) {
|
|
6351
|
+
super(message);
|
|
6352
|
+
this.classified = classified;
|
|
6353
|
+
this.name = "SchemaCommitError";
|
|
6354
|
+
}
|
|
6355
|
+
};
|
|
6356
|
+
/** An `ExistingSchema` describing a database that has nothing in it. */
|
|
6357
|
+
var nothing = () => ({
|
|
6358
|
+
tables: /* @__PURE__ */ new Map(),
|
|
6359
|
+
enums: /* @__PURE__ */ new Set()
|
|
6360
|
+
});
|
|
6361
|
+
/** A commit message that says what changed rather than that something did. */
|
|
6362
|
+
function commitMessage(classified) {
|
|
6363
|
+
const { changes } = classified;
|
|
6364
|
+
if (changes.length === 0) return "chore(schema): no change";
|
|
6365
|
+
const collections = [...new Set(changes.map((change) => change.collection))].sort();
|
|
6366
|
+
const added = changes.filter((c) => c.kind === "add-collection").map((c) => c.collection);
|
|
6367
|
+
const properties = changes.filter((c) => c.kind === "add-property");
|
|
6368
|
+
let subject;
|
|
6369
|
+
if (added.length === 1 && changes.length === 1) subject = `add the ${added[0]} collection`;
|
|
6370
|
+
else if (properties.length === 1 && changes.length === 1) subject = `add ${properties[0].property} to ${properties[0].collection}`;
|
|
6371
|
+
else if (collections.length === 1) subject = `${changes.length} change(s) to ${collections[0]}`;
|
|
6372
|
+
else subject = `${changes.length} change(s) across ${collections.length} collections`;
|
|
6373
|
+
const body = changes.map((change) => `- ${change.detail}`).join("\n");
|
|
6374
|
+
return `feat(schema): ${subject}\n\n${body}\n`;
|
|
6375
|
+
}
|
|
6376
|
+
/**
|
|
6377
|
+
* Build the commit.
|
|
6378
|
+
*
|
|
6379
|
+
* Refuses when the change is not applicable — a commit describing a schema the
|
|
6380
|
+
* ensure path will not produce is a commit that makes the repository lie about
|
|
6381
|
+
* the database. The classification travels on the error so a caller can show
|
|
6382
|
+
* exactly which change was the problem.
|
|
6383
|
+
*/
|
|
6384
|
+
async function generateSchemaCommit(input) {
|
|
6385
|
+
const paths = {
|
|
6386
|
+
...DEFAULT_COMMIT_PATHS,
|
|
6387
|
+
...input.paths
|
|
6388
|
+
};
|
|
6389
|
+
const existing = input.existing ?? nothing();
|
|
6390
|
+
const options = { constraints: "converge" };
|
|
6391
|
+
const classified = classifyCollectionChanges(input.before, input.after, input.existing);
|
|
6392
|
+
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);
|
|
6393
|
+
const [schema, ddl, policies, search] = await Promise.all([
|
|
6394
|
+
generateSchema(input.after),
|
|
6395
|
+
generatePostgresDdl(input.after),
|
|
6396
|
+
Promise.resolve(generatePostgresPoliciesDdl(input.after)),
|
|
6397
|
+
Promise.resolve(generatePostgresSearchDdl(input.after))
|
|
6398
|
+
]);
|
|
6399
|
+
const generated = [
|
|
6400
|
+
{
|
|
6401
|
+
path: paths.schemaFile,
|
|
6402
|
+
contents: schema
|
|
6403
|
+
},
|
|
6404
|
+
{
|
|
6405
|
+
path: paths.ddlFile,
|
|
6406
|
+
contents: ddl
|
|
6407
|
+
},
|
|
6408
|
+
{
|
|
6409
|
+
path: paths.policiesFile,
|
|
6410
|
+
contents: policies
|
|
6411
|
+
},
|
|
6412
|
+
{
|
|
6413
|
+
path: paths.searchFile,
|
|
6414
|
+
contents: search
|
|
6415
|
+
}
|
|
6416
|
+
];
|
|
6417
|
+
const previous = planCollectionSchemaEnsure(input.before, existing, options);
|
|
6418
|
+
const next = planCollectionSchemaEnsure(input.after, existing, options);
|
|
6419
|
+
const already = new Set(previous.statements);
|
|
6420
|
+
return {
|
|
6421
|
+
files: [...input.sourceFiles ?? [], ...generated],
|
|
6422
|
+
statements: next.statements.filter((statement) => !already.has(statement)),
|
|
6423
|
+
classified,
|
|
6424
|
+
message: commitMessage(classified),
|
|
6425
|
+
withheldConstraints: next.withheldConstraints
|
|
6426
|
+
};
|
|
6427
|
+
}
|
|
6428
|
+
//#endregion
|
|
6429
|
+
//#region src/PostgresBackendDriver.ts
|
|
6430
|
+
var PostgresBackendDriver = class PostgresBackendDriver {
|
|
6431
|
+
db;
|
|
6432
|
+
registry;
|
|
6433
|
+
poolManager;
|
|
6434
|
+
key = "postgres";
|
|
6435
|
+
initialised = true;
|
|
6436
|
+
dataService;
|
|
6437
|
+
realtimeService;
|
|
6438
|
+
historyService;
|
|
6439
|
+
branchService;
|
|
6440
|
+
user;
|
|
6441
|
+
data;
|
|
6442
|
+
client;
|
|
6132
6443
|
/**
|
|
6133
|
-
*
|
|
6134
|
-
*
|
|
6135
|
-
*
|
|
6136
|
-
*
|
|
6137
|
-
* same deferred notifications, same per-row error labelling — but it calls
|
|
6138
|
-
* `save` with an explicit `id` and `status: "existing"`, which is precisely
|
|
6139
|
-
* what `saveMany` cannot do: that one passes `status: "new"` and keeps the
|
|
6140
|
-
* key inside `values`, so it inserts or upserts and can never target a
|
|
6141
|
-
* particular row.
|
|
6142
|
-
*
|
|
6143
|
-
* All-or-nothing, so an id matching no row aborts the batch. A partial
|
|
6144
|
-
* update is the outcome with no good recovery: the caller cannot tell which
|
|
6145
|
-
* half landed without re-reading everything.
|
|
6444
|
+
* Auto-set to `true` when a SET LOCAL ROLE fails with insufficient
|
|
6445
|
+
* privileges, so subsequent queries skip the doomed attempt.
|
|
6446
|
+
* Mirrors the static `DISABLE_DB_ROLE_SWITCHING` env var but is
|
|
6447
|
+
* learned at runtime.
|
|
6146
6448
|
*/
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
6175
|
-
statusCode: error?.statusCode,
|
|
6176
|
-
code: error?.code,
|
|
6177
|
-
name: error?.name
|
|
6178
|
-
});
|
|
6179
|
-
}
|
|
6180
|
-
}
|
|
6181
|
-
return saved;
|
|
6182
|
-
});
|
|
6449
|
+
_roleSwitchingDisabled = false;
|
|
6450
|
+
/**
|
|
6451
|
+
* Restricted role that authenticated (user-context) requests run as (via
|
|
6452
|
+
* `SET LOCAL ROLE`) so RLS binds every statement — reads *and* writes. Set
|
|
6453
|
+
* by the bootstrapper after posture detection: defined when the connection
|
|
6454
|
+
* would otherwise bypass RLS (superuser / BYPASSRLS / table owner),
|
|
6455
|
+
* undefined when RLS already applies natively. The base (server-context)
|
|
6456
|
+
* driver never switches — it is the trusted owner plane (auth flows,
|
|
6457
|
+
* migrations, `dataAsAdmin`).
|
|
6458
|
+
*/
|
|
6459
|
+
rlsUserRole;
|
|
6460
|
+
/**
|
|
6461
|
+
* When true, realtime notifications are deferred until after the
|
|
6462
|
+
* wrapping transaction commits. Set by `withAuth` → `withTransaction`.
|
|
6463
|
+
*/
|
|
6464
|
+
_deferNotifications = false;
|
|
6465
|
+
_pendingNotifications = [];
|
|
6466
|
+
constructor(db, realtimeService, registry, user, poolManager, historyService) {
|
|
6467
|
+
this.db = db;
|
|
6468
|
+
this.registry = registry;
|
|
6469
|
+
this.poolManager = poolManager;
|
|
6470
|
+
this.dataService = new DataService(db, registry);
|
|
6471
|
+
this.realtimeService = realtimeService;
|
|
6472
|
+
this.historyService = historyService;
|
|
6473
|
+
this.user = user;
|
|
6474
|
+
this.data = buildSdkData(this);
|
|
6475
|
+
if (poolManager) this.branchService = new BranchService(db, poolManager);
|
|
6183
6476
|
}
|
|
6184
6477
|
/**
|
|
6185
|
-
*
|
|
6186
|
-
*
|
|
6478
|
+
* Typed admin capabilities (SQLAdmin + SchemaAdmin + BranchAdmin).
|
|
6479
|
+
* Implemented as a getter so method references are resolved at call-time,
|
|
6480
|
+
* allowing test spies applied after construction to take effect.
|
|
6481
|
+
*/
|
|
6482
|
+
get admin() {
|
|
6483
|
+
return {
|
|
6484
|
+
executeSql: (...args) => this.executeSql(...args),
|
|
6485
|
+
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
6486
|
+
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
6487
|
+
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
6488
|
+
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
6489
|
+
fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
|
|
6490
|
+
fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
|
|
6491
|
+
planSchemaChange: async (before, after, options) => generateSchemaCommit({
|
|
6492
|
+
before,
|
|
6493
|
+
after,
|
|
6494
|
+
paths: options?.paths,
|
|
6495
|
+
existing: await readSchemaFactsFor(this.schemaFactsQueryable(), after)
|
|
6496
|
+
}),
|
|
6497
|
+
...this.branchService ? {
|
|
6498
|
+
createBranch: this.branchService.createBranch.bind(this.branchService),
|
|
6499
|
+
deleteBranch: this.branchService.deleteBranch.bind(this.branchService),
|
|
6500
|
+
listBranches: this.branchService.listBranches.bind(this.branchService),
|
|
6501
|
+
getBranchInfo: this.branchService.getBranchInfo.bind(this.branchService)
|
|
6502
|
+
} : {}
|
|
6503
|
+
};
|
|
6504
|
+
}
|
|
6505
|
+
/**
|
|
6506
|
+
* The catalogue-reading shim the schema planner wants.
|
|
6187
6507
|
*
|
|
6188
|
-
*
|
|
6189
|
-
*
|
|
6190
|
-
*
|
|
6191
|
-
*
|
|
6192
|
-
* dependents would behave differently depending on how many rows the caller
|
|
6193
|
-
* happened to delete at once. Same pipeline, one transaction.
|
|
6508
|
+
* Text in, rows out — every statement it issues is a catalogue read keyed by
|
|
6509
|
+
* schema name, and schema names are identifiers rather than bindable values,
|
|
6510
|
+
* so there is nothing to parameterise. Runs on the driver's own handle, which
|
|
6511
|
+
* is the connection whose privileges are already known to work.
|
|
6194
6512
|
*/
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
const
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
collection
|
|
6221
|
-
});
|
|
6222
|
-
} catch (error) {
|
|
6223
|
-
throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
6224
|
-
statusCode: error?.statusCode,
|
|
6225
|
-
code: error?.code,
|
|
6226
|
-
name: error?.name
|
|
6227
|
-
});
|
|
6228
|
-
}
|
|
6513
|
+
schemaFactsQueryable() {
|
|
6514
|
+
return { query: async (text) => {
|
|
6515
|
+
const result = await this.db.execute(sql.raw(text));
|
|
6516
|
+
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
6517
|
+
} };
|
|
6518
|
+
}
|
|
6519
|
+
/**
|
|
6520
|
+
* REST-optimised fetch service (include-aware eager-loading).
|
|
6521
|
+
* Delegates to the underlying FetchService (include-aware eager loading),
|
|
6522
|
+
* then runs the afterRead pipeline on the results. The raw FetchService does
|
|
6523
|
+
* NOT run callbacks, so masking must be applied here — otherwise every
|
|
6524
|
+
* REST/SDK read leaks unmasked data (see {@link applyAfterReadForRest}).
|
|
6525
|
+
*/
|
|
6526
|
+
get restFetchService() {
|
|
6527
|
+
const raw = this.dataService.getFetchService();
|
|
6528
|
+
return {
|
|
6529
|
+
fetchCollectionForRest: async (collectionPath, options, include) => {
|
|
6530
|
+
const rows = await raw.fetchCollectionForRest(collectionPath, options, include);
|
|
6531
|
+
return this.applyAfterReadForRest(rows, collectionPath);
|
|
6532
|
+
},
|
|
6533
|
+
fetchOneForRest: async (collectionPath, id, include, databaseId) => {
|
|
6534
|
+
const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId);
|
|
6535
|
+
if (!row) return row;
|
|
6536
|
+
const [masked] = await this.applyAfterReadForRest([row], collectionPath);
|
|
6537
|
+
return masked;
|
|
6229
6538
|
}
|
|
6230
|
-
}
|
|
6539
|
+
};
|
|
6231
6540
|
}
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6541
|
+
/**
|
|
6542
|
+
* Build the context handed to every collection callback.
|
|
6543
|
+
*
|
|
6544
|
+
* Note `data: this.data` — `this` is whichever driver is running the
|
|
6545
|
+
* operation, so the callback's data plane inherits that driver's privilege.
|
|
6546
|
+
* On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
|
|
6547
|
+
* constructs a fresh base driver bound to the RLS-scoped transaction and
|
|
6548
|
+
* runs the operation on it, so `this.data` speaks through that connection
|
|
6549
|
+
* and policies apply. On server-context work `this` is the base driver on
|
|
6550
|
+
* the owner connection, and they do not. Pinned by the
|
|
6551
|
+
* `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
|
|
6552
|
+
* suite, because it is the kind of property that is easy to break from a
|
|
6553
|
+
* distance and impossible to notice.
|
|
6554
|
+
*
|
|
6555
|
+
* Previously returned through `as unknown as RebaseCallContext`, which
|
|
6556
|
+
* disabled checking for the whole object and let `driver` — documented in
|
|
6557
|
+
* the callbacks guide — sit on the runtime context while absent from the
|
|
6558
|
+
* contract. Both are declared now, so this is a plain typed return.
|
|
6559
|
+
*/
|
|
6560
|
+
buildCallContext() {
|
|
6561
|
+
return {
|
|
6562
|
+
user: this.user,
|
|
6563
|
+
driver: this,
|
|
6564
|
+
data: this.data,
|
|
6565
|
+
client: this.client,
|
|
6566
|
+
storageSource: this.client?.storage
|
|
6567
|
+
};
|
|
6568
|
+
}
|
|
6569
|
+
resolveCollectionCallbacks(collection, path) {
|
|
6570
|
+
if (!collection && !path) return {
|
|
6571
|
+
collection: void 0,
|
|
6572
|
+
callbacks: void 0,
|
|
6573
|
+
globalCallbacks: void 0,
|
|
6574
|
+
propertyCallbacks: void 0
|
|
6575
|
+
};
|
|
6576
|
+
const registryCollection = this.registry?.getCollectionByPath(path);
|
|
6577
|
+
const resolvedCollection = registryCollection ? {
|
|
6578
|
+
...collection,
|
|
6579
|
+
...registryCollection
|
|
6580
|
+
} : collection;
|
|
6581
|
+
const callbacks = resolvedCollection?.callbacks;
|
|
6582
|
+
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
6583
|
+
const properties = resolvedCollection?.properties;
|
|
6584
|
+
let propertyCallbacks;
|
|
6585
|
+
if (properties) propertyCallbacks = buildPropertyCallbacks(properties);
|
|
6586
|
+
return {
|
|
6587
|
+
collection: resolvedCollection,
|
|
6588
|
+
callbacks,
|
|
6589
|
+
globalCallbacks,
|
|
6590
|
+
propertyCallbacks
|
|
6591
|
+
};
|
|
6592
|
+
}
|
|
6593
|
+
/**
|
|
6594
|
+
* Run the three-tier afterRead pipeline (global → collection → property) on a
|
|
6595
|
+
* single row for a collection whose callbacks have already been resolved.
|
|
6596
|
+
*/
|
|
6597
|
+
async applyAfterReadToRow(row, path, resolved, contextForCallback) {
|
|
6598
|
+
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = resolved;
|
|
6599
|
+
let out = row;
|
|
6600
|
+
if (globalCallbacks?.afterRead) out = await globalCallbacks.afterRead({
|
|
6601
|
+
collection: resolvedCollection,
|
|
6602
|
+
path,
|
|
6603
|
+
row: out,
|
|
6604
|
+
context: contextForCallback
|
|
6605
|
+
}) ?? out;
|
|
6606
|
+
if (callbacks?.afterRead) out = await callbacks.afterRead({
|
|
6607
|
+
collection: resolvedCollection,
|
|
6608
|
+
path,
|
|
6609
|
+
row: out,
|
|
6610
|
+
context: contextForCallback
|
|
6611
|
+
}) ?? out;
|
|
6612
|
+
if (propertyCallbacks?.afterRead) out = await propertyCallbacks.afterRead({
|
|
6613
|
+
collection: resolvedCollection,
|
|
6614
|
+
path,
|
|
6615
|
+
row: out,
|
|
6616
|
+
context: contextForCallback
|
|
6617
|
+
}) ?? out;
|
|
6618
|
+
return out;
|
|
6619
|
+
}
|
|
6620
|
+
static hasAfterRead(resolved) {
|
|
6621
|
+
return !!(resolved.globalCallbacks?.afterRead || resolved.callbacks?.afterRead || resolved.propertyCallbacks?.afterRead);
|
|
6622
|
+
}
|
|
6623
|
+
/**
|
|
6624
|
+
* Apply afterRead to REST/SDK read results.
|
|
6625
|
+
*
|
|
6626
|
+
* The REST / `include` path fetches rows through the raw fetch service, which
|
|
6627
|
+
* does NOT run callbacks — so without this, `afterRead` transforms (e.g. PII
|
|
6628
|
+
* masking) are silently skipped on every SDK/REST read, leaking raw data.
|
|
6629
|
+
* This choke point guarantees afterRead runs there too, matching the driver's
|
|
6630
|
+
* fetchCollection/fetchOne paths.
|
|
6631
|
+
*
|
|
6632
|
+
* It also masks embedded relation data one level deep by running the TARGET
|
|
6633
|
+
* collection's afterRead (so `post.author.email` is masked by the authors
|
|
6634
|
+
* collection, not left raw).
|
|
6635
|
+
*/
|
|
6636
|
+
async applyAfterReadForRest(rows, path) {
|
|
6637
|
+
if (!rows || rows.length === 0) return rows;
|
|
6638
|
+
const resolved = this.resolveCollectionCallbacks(void 0, path);
|
|
6639
|
+
const contextForCallback = this.buildCallContext();
|
|
6640
|
+
const hasOwn = PostgresBackendDriver.hasAfterRead(resolved);
|
|
6641
|
+
const relationTargets = {};
|
|
6642
|
+
if (resolved.collection) try {
|
|
6643
|
+
const rels = resolveCollectionRelations(resolved.collection);
|
|
6644
|
+
for (const [key, rel] of Object.entries(rels)) {
|
|
6645
|
+
const targetPath = (typeof rel.target === "function" ? rel.target() : void 0)?.slug;
|
|
6646
|
+
if (!targetPath) continue;
|
|
6647
|
+
const targetResolved = this.resolveCollectionCallbacks(void 0, targetPath);
|
|
6648
|
+
if (PostgresBackendDriver.hasAfterRead(targetResolved)) relationTargets[key] = {
|
|
6649
|
+
path: targetPath,
|
|
6650
|
+
resolved: targetResolved
|
|
6651
|
+
};
|
|
6247
6652
|
}
|
|
6248
|
-
|
|
6249
|
-
|
|
6653
|
+
} catch {}
|
|
6654
|
+
const relKeys = Object.keys(relationTargets);
|
|
6655
|
+
if (!hasOwn && relKeys.length === 0) return rows;
|
|
6656
|
+
const maskEmbedded = async (value, target) => {
|
|
6657
|
+
if (Array.isArray(value)) return Promise.all(value.map((v) => maskEmbedded(v, target)));
|
|
6658
|
+
if (!value || typeof value !== "object") return value;
|
|
6659
|
+
const obj = value;
|
|
6660
|
+
if (obj.__type === "relation" && obj.data && typeof obj.data === "object") return {
|
|
6661
|
+
...obj,
|
|
6662
|
+
data: await this.applyAfterReadToRow(obj.data, target.path, target.resolved, contextForCallback)
|
|
6663
|
+
};
|
|
6664
|
+
if (obj.__type === "reference") return obj;
|
|
6665
|
+
return this.applyAfterReadToRow(obj, target.path, target.resolved, contextForCallback);
|
|
6666
|
+
};
|
|
6667
|
+
return Promise.all(rows.map(async (row) => {
|
|
6668
|
+
let out = hasOwn ? await this.applyAfterReadToRow(row, path, resolved, contextForCallback) : row;
|
|
6669
|
+
for (const key of relKeys) {
|
|
6670
|
+
if (out[key] === void 0 || out[key] === null) continue;
|
|
6671
|
+
out = {
|
|
6672
|
+
...out,
|
|
6673
|
+
[key]: await maskEmbedded(out[key], relationTargets[key])
|
|
6674
|
+
};
|
|
6675
|
+
}
|
|
6676
|
+
return out;
|
|
6677
|
+
}));
|
|
6678
|
+
}
|
|
6679
|
+
async fetchCollection({ path, collection, filter, limit, offset, startAfter, orderBy, searchString, order, vectorSearch }) {
|
|
6680
|
+
const rows = await this.dataService.fetchCollection(path, {
|
|
6681
|
+
filter,
|
|
6682
|
+
orderBy,
|
|
6683
|
+
order,
|
|
6684
|
+
limit,
|
|
6685
|
+
offset,
|
|
6686
|
+
startAfter,
|
|
6687
|
+
databaseId: collection?.databaseId,
|
|
6688
|
+
searchString,
|
|
6689
|
+
vectorSearch
|
|
6690
|
+
});
|
|
6691
|
+
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6692
|
+
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
6693
|
+
const contextForCallback = this.buildCallContext();
|
|
6694
|
+
return Promise.all(rows.map(async (row) => {
|
|
6695
|
+
let fetched = row;
|
|
6696
|
+
if (globalCallbacks?.afterRead) fetched = await globalCallbacks.afterRead({
|
|
6250
6697
|
collection: resolvedCollection,
|
|
6251
|
-
path
|
|
6252
|
-
|
|
6253
|
-
row: targetRow,
|
|
6698
|
+
path,
|
|
6699
|
+
row: fetched,
|
|
6254
6700
|
context: contextForCallback
|
|
6255
|
-
})
|
|
6256
|
-
|
|
6257
|
-
if (propertyCallbacks?.beforeDelete) {
|
|
6258
|
-
if (await propertyCallbacks.beforeDelete({
|
|
6701
|
+
});
|
|
6702
|
+
if (callbacks?.afterRead) fetched = await callbacks.afterRead({
|
|
6259
6703
|
collection: resolvedCollection,
|
|
6260
|
-
path
|
|
6261
|
-
|
|
6262
|
-
row: targetRow,
|
|
6704
|
+
path,
|
|
6705
|
+
row: fetched,
|
|
6263
6706
|
context: contextForCallback
|
|
6264
|
-
})
|
|
6265
|
-
|
|
6266
|
-
|
|
6707
|
+
}) ?? fetched;
|
|
6708
|
+
if (propertyCallbacks?.afterRead) fetched = await propertyCallbacks.afterRead({
|
|
6709
|
+
collection: resolvedCollection,
|
|
6710
|
+
path,
|
|
6711
|
+
row: fetched,
|
|
6712
|
+
context: contextForCallback
|
|
6713
|
+
});
|
|
6714
|
+
return fetched;
|
|
6715
|
+
}));
|
|
6267
6716
|
}
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6717
|
+
return rows;
|
|
6718
|
+
}
|
|
6719
|
+
listenCollection({ path, collection, filter, limit, offset, startAfter, orderBy, searchString, order, onUpdate, onError }) {
|
|
6720
|
+
const subscriptionId = this.generateSubscriptionId();
|
|
6721
|
+
const callbackWrapper = (rows) => {
|
|
6722
|
+
onUpdate(rows);
|
|
6723
|
+
};
|
|
6724
|
+
this.realtimeService.registerDataDriverSubscription(subscriptionId, {
|
|
6725
|
+
clientId: "driver",
|
|
6726
|
+
type: "collection",
|
|
6727
|
+
path,
|
|
6728
|
+
collectionRequest: {
|
|
6729
|
+
filter,
|
|
6730
|
+
orderBy,
|
|
6731
|
+
order,
|
|
6732
|
+
limit,
|
|
6733
|
+
offset,
|
|
6734
|
+
startAfter,
|
|
6735
|
+
databaseId: collection?.databaseId,
|
|
6736
|
+
searchString
|
|
6737
|
+
}
|
|
6738
|
+
});
|
|
6739
|
+
this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper);
|
|
6740
|
+
this.fetchCollection({
|
|
6741
|
+
path,
|
|
6742
|
+
collection,
|
|
6743
|
+
filter,
|
|
6744
|
+
limit,
|
|
6745
|
+
offset,
|
|
6746
|
+
startAfter,
|
|
6747
|
+
orderBy,
|
|
6748
|
+
searchString,
|
|
6749
|
+
order
|
|
6750
|
+
}).then((rows) => {
|
|
6751
|
+
callbackWrapper(rows);
|
|
6752
|
+
}).catch((error) => {
|
|
6753
|
+
if (onError) onError(error);
|
|
6754
|
+
});
|
|
6755
|
+
return () => {
|
|
6756
|
+
this.realtimeService.removeSubscriptionCallback(subscriptionId);
|
|
6757
|
+
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
6758
|
+
};
|
|
6759
|
+
}
|
|
6760
|
+
async fetchOne({ path, id, databaseId, collection }) {
|
|
6761
|
+
let row = await this.dataService.fetchOne(path, id, databaseId || collection?.databaseId);
|
|
6762
|
+
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6763
|
+
if (row && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
6764
|
+
const contextForCallback = this.buildCallContext();
|
|
6765
|
+
if (globalCallbacks?.afterRead) row = await globalCallbacks.afterRead({
|
|
6271
6766
|
collection: resolvedCollection,
|
|
6272
|
-
path
|
|
6273
|
-
|
|
6274
|
-
row: targetRow,
|
|
6767
|
+
path,
|
|
6768
|
+
row,
|
|
6275
6769
|
context: contextForCallback
|
|
6276
6770
|
});
|
|
6277
|
-
if (callbacks?.
|
|
6771
|
+
if (callbacks?.afterRead) row = await callbacks.afterRead({
|
|
6278
6772
|
collection: resolvedCollection,
|
|
6279
|
-
path
|
|
6280
|
-
|
|
6281
|
-
row: targetRow,
|
|
6773
|
+
path,
|
|
6774
|
+
row,
|
|
6282
6775
|
context: contextForCallback
|
|
6283
|
-
});
|
|
6284
|
-
if (propertyCallbacks?.
|
|
6776
|
+
}) ?? row;
|
|
6777
|
+
if (propertyCallbacks?.afterRead) row = await propertyCallbacks.afterRead({
|
|
6285
6778
|
collection: resolvedCollection,
|
|
6286
|
-
path
|
|
6287
|
-
|
|
6288
|
-
row: targetRow,
|
|
6779
|
+
path,
|
|
6780
|
+
row,
|
|
6289
6781
|
context: contextForCallback
|
|
6290
6782
|
});
|
|
6291
6783
|
}
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6784
|
+
return row;
|
|
6785
|
+
}
|
|
6786
|
+
listenOne({ path, id, collection, onUpdate, onError }) {
|
|
6787
|
+
const subscriptionId = this.generateSubscriptionId();
|
|
6788
|
+
const callbackWrapper = (row) => {
|
|
6789
|
+
if (row) onUpdate(row);
|
|
6790
|
+
};
|
|
6791
|
+
this.realtimeService.registerDataDriverSubscription(subscriptionId, {
|
|
6792
|
+
clientId: "driver",
|
|
6793
|
+
type: "single",
|
|
6794
|
+
path,
|
|
6795
|
+
id
|
|
6298
6796
|
});
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6797
|
+
this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper);
|
|
6798
|
+
this.fetchOne({
|
|
6799
|
+
path,
|
|
6800
|
+
id,
|
|
6801
|
+
collection
|
|
6802
|
+
}).then((row) => {
|
|
6803
|
+
if (row) onUpdate(row);
|
|
6804
|
+
}).catch((error) => {
|
|
6805
|
+
if (onError) onError(error);
|
|
6304
6806
|
});
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
await this.realtimeService.notifyUpdate(path, "*", null);
|
|
6310
|
-
}
|
|
6311
|
-
async checkUniqueField(path, name, value, id, collection) {
|
|
6312
|
-
return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
|
|
6807
|
+
return () => {
|
|
6808
|
+
this.realtimeService.removeSubscriptionCallback(subscriptionId);
|
|
6809
|
+
this.realtimeService.subscriptions.delete(subscriptionId);
|
|
6810
|
+
};
|
|
6313
6811
|
}
|
|
6314
|
-
async
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6812
|
+
async save({ path, id, values, collection, status, upsert }) {
|
|
6813
|
+
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6814
|
+
let updatedValues = values;
|
|
6815
|
+
const contextForCallback = this.buildCallContext();
|
|
6816
|
+
let previousValuesForHistory;
|
|
6817
|
+
if (status === "existing" && id) try {
|
|
6818
|
+
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
6819
|
+
if (existing) {
|
|
6820
|
+
const { id: _existingId, ...existingValues } = existing;
|
|
6821
|
+
previousValuesForHistory = existingValues;
|
|
6822
|
+
}
|
|
6823
|
+
} catch (err) {
|
|
6824
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6825
|
+
}
|
|
6826
|
+
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
6827
|
+
if (globalCallbacks?.beforeSave) {
|
|
6828
|
+
const result = await globalCallbacks.beforeSave({
|
|
6829
|
+
collection: resolvedCollection,
|
|
6830
|
+
path,
|
|
6831
|
+
id,
|
|
6832
|
+
values: updatedValues,
|
|
6833
|
+
previousValues: previousValuesForHistory,
|
|
6834
|
+
status,
|
|
6835
|
+
context: contextForCallback
|
|
6836
|
+
});
|
|
6837
|
+
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
6838
|
+
}
|
|
6839
|
+
if (callbacks?.beforeSave) {
|
|
6840
|
+
const result = await callbacks.beforeSave({
|
|
6841
|
+
collection: resolvedCollection,
|
|
6842
|
+
path,
|
|
6843
|
+
id,
|
|
6844
|
+
values: updatedValues,
|
|
6845
|
+
previousValues: previousValuesForHistory,
|
|
6846
|
+
status,
|
|
6847
|
+
context: contextForCallback
|
|
6848
|
+
});
|
|
6849
|
+
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
6850
|
+
}
|
|
6851
|
+
if (propertyCallbacks?.beforeSave) {
|
|
6852
|
+
const result = await propertyCallbacks.beforeSave({
|
|
6853
|
+
collection: resolvedCollection,
|
|
6854
|
+
path,
|
|
6855
|
+
id,
|
|
6856
|
+
values: updatedValues,
|
|
6857
|
+
previousValues: previousValuesForHistory,
|
|
6858
|
+
status,
|
|
6859
|
+
context: contextForCallback
|
|
6860
|
+
});
|
|
6861
|
+
if (result) updatedValues = mergeDeep(updatedValues, result);
|
|
6862
|
+
}
|
|
6863
|
+
}
|
|
6864
|
+
if (resolvedCollection?.properties) updatedValues = updateDateAutoValues({
|
|
6865
|
+
inputValues: updatedValues,
|
|
6866
|
+
properties: resolvedCollection.properties,
|
|
6867
|
+
status: status ?? "new",
|
|
6868
|
+
timestampNowValue: /* @__PURE__ */ new Date()
|
|
6320
6869
|
});
|
|
6321
|
-
}
|
|
6322
|
-
getTargetDb(databaseName) {
|
|
6323
|
-
if (!databaseName || databaseName === this.poolManager?.defaultDatabaseName) return this.db;
|
|
6324
|
-
if (!this.poolManager) throw new Error("Cross-database execution requires adminConnectionString to be configured in the backend.");
|
|
6325
|
-
return this.poolManager.getDrizzle(databaseName);
|
|
6326
|
-
}
|
|
6327
|
-
async executeSql(sqlText, options) {
|
|
6328
|
-
if (!options?.database && !options?.role) return this.dataService.executeSql(sqlText, options?.params);
|
|
6329
|
-
const targetDb = this.getTargetDb(options?.database);
|
|
6330
6870
|
try {
|
|
6331
|
-
let
|
|
6332
|
-
if (
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6871
|
+
let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
|
|
6872
|
+
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
6873
|
+
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
6874
|
+
collection: resolvedCollection,
|
|
6875
|
+
path,
|
|
6876
|
+
row: savedRow,
|
|
6877
|
+
context: contextForCallback
|
|
6878
|
+
});
|
|
6879
|
+
if (callbacks?.afterRead) savedRow = await callbacks.afterRead({
|
|
6880
|
+
collection: resolvedCollection,
|
|
6881
|
+
path,
|
|
6882
|
+
row: savedRow,
|
|
6883
|
+
context: contextForCallback
|
|
6884
|
+
}) ?? savedRow;
|
|
6885
|
+
if (propertyCallbacks?.afterRead) savedRow = await propertyCallbacks.afterRead({
|
|
6886
|
+
collection: resolvedCollection,
|
|
6887
|
+
path,
|
|
6888
|
+
row: savedRow,
|
|
6889
|
+
context: contextForCallback
|
|
6890
|
+
});
|
|
6337
6891
|
}
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6892
|
+
const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
|
|
6893
|
+
const savedValues = savedRow;
|
|
6894
|
+
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
6895
|
+
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
6896
|
+
collection: resolvedCollection,
|
|
6897
|
+
path,
|
|
6898
|
+
id: savedId,
|
|
6899
|
+
values: savedValues,
|
|
6900
|
+
previousValues: previousValuesForHistory,
|
|
6901
|
+
status,
|
|
6902
|
+
context: contextForCallback
|
|
6903
|
+
});
|
|
6904
|
+
if (callbacks?.afterSave) await callbacks.afterSave({
|
|
6905
|
+
collection: resolvedCollection,
|
|
6906
|
+
path,
|
|
6907
|
+
id: savedId,
|
|
6908
|
+
values: savedValues,
|
|
6909
|
+
previousValues: previousValuesForHistory,
|
|
6910
|
+
status,
|
|
6911
|
+
context: contextForCallback
|
|
6912
|
+
});
|
|
6913
|
+
if (propertyCallbacks?.afterSave) await propertyCallbacks.afterSave({
|
|
6914
|
+
collection: resolvedCollection,
|
|
6915
|
+
path,
|
|
6916
|
+
id: savedId,
|
|
6917
|
+
values: savedValues,
|
|
6918
|
+
previousValues: previousValuesForHistory,
|
|
6919
|
+
status,
|
|
6920
|
+
context: contextForCallback
|
|
6921
|
+
});
|
|
6360
6922
|
}
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6923
|
+
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
6924
|
+
tableName: path,
|
|
6925
|
+
id: savedId,
|
|
6926
|
+
action: status === "new" ? "create" : "update",
|
|
6927
|
+
values: savedValues,
|
|
6928
|
+
previousValues: previousValuesForHistory,
|
|
6929
|
+
updatedBy: this.user?.uid
|
|
6930
|
+
});
|
|
6931
|
+
if (this._deferNotifications) this._pendingNotifications.push({
|
|
6932
|
+
path,
|
|
6933
|
+
id: savedId,
|
|
6934
|
+
row: savedRow,
|
|
6935
|
+
databaseId: resolvedCollection?.databaseId
|
|
6936
|
+
});
|
|
6937
|
+
else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
|
|
6938
|
+
return savedRow;
|
|
6371
6939
|
} catch (error) {
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6940
|
+
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
6941
|
+
if (globalCallbacks?.afterSaveError) await globalCallbacks.afterSaveError({
|
|
6942
|
+
collection: resolvedCollection,
|
|
6943
|
+
path,
|
|
6944
|
+
id: id || "unknown",
|
|
6945
|
+
values: updatedValues,
|
|
6946
|
+
previousValues: void 0,
|
|
6947
|
+
status,
|
|
6948
|
+
context: contextForCallback
|
|
6949
|
+
});
|
|
6950
|
+
if (callbacks?.afterSaveError) await callbacks.afterSaveError({
|
|
6951
|
+
collection: resolvedCollection,
|
|
6952
|
+
path,
|
|
6953
|
+
id: id || "unknown",
|
|
6954
|
+
values: updatedValues,
|
|
6955
|
+
previousValues: void 0,
|
|
6956
|
+
status,
|
|
6957
|
+
context: contextForCallback
|
|
6958
|
+
});
|
|
6959
|
+
if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
|
|
6960
|
+
collection: resolvedCollection,
|
|
6961
|
+
path,
|
|
6962
|
+
id: id || "unknown",
|
|
6963
|
+
values: updatedValues,
|
|
6964
|
+
previousValues: void 0,
|
|
6965
|
+
status,
|
|
6966
|
+
context: contextForCallback
|
|
6967
|
+
});
|
|
6376
6968
|
}
|
|
6377
6969
|
throw error;
|
|
6378
6970
|
}
|
|
6379
6971
|
}
|
|
6380
|
-
async fetchAvailableDatabases() {
|
|
6381
|
-
const databases = (await this.executeSql(`SELECT datname FROM pg_database
|
|
6382
|
-
WHERE datistemplate = false
|
|
6383
|
-
AND datname NOT IN ('postgres', 'cloudsqladmin', '_cloudsqladmin')
|
|
6384
|
-
ORDER BY datname;`)).map((r) => r.datname);
|
|
6385
|
-
const currentDb = this.poolManager?.defaultDatabaseName;
|
|
6386
|
-
if (currentDb && !databases.includes(currentDb)) databases.unshift(currentDb);
|
|
6387
|
-
else if (currentDb) {
|
|
6388
|
-
const idx = databases.indexOf(currentDb);
|
|
6389
|
-
if (idx > 0) {
|
|
6390
|
-
databases.splice(idx, 1);
|
|
6391
|
-
databases.unshift(currentDb);
|
|
6392
|
-
}
|
|
6393
|
-
}
|
|
6394
|
-
return databases;
|
|
6395
|
-
}
|
|
6396
|
-
async fetchAvailableRoles() {
|
|
6397
|
-
return (await this.executeSql("SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;")).map((r) => r.rolname);
|
|
6398
|
-
}
|
|
6399
6972
|
/**
|
|
6400
|
-
*
|
|
6973
|
+
* Write many rows through the same pipeline as {@link save}.
|
|
6401
6974
|
*
|
|
6402
|
-
*
|
|
6403
|
-
*
|
|
6404
|
-
*
|
|
6405
|
-
*
|
|
6406
|
-
*
|
|
6407
|
-
* into a `SecurityRule.roles` field produces a condition no user can ever
|
|
6408
|
-
* satisfy, so the two must not be conflated.
|
|
6975
|
+
* The batch runs in one transaction of its own, so a failure part-way leaves
|
|
6976
|
+
* nothing behind — the point of a batch is that a re-run starts from a known
|
|
6977
|
+
* state. When this driver is already inside a transaction (the authenticated
|
|
6978
|
+
* path, via `withTransaction`) the nested call becomes a savepoint, which is
|
|
6979
|
+
* still atomic and still commits once.
|
|
6409
6980
|
*
|
|
6410
|
-
*
|
|
6411
|
-
*
|
|
6412
|
-
* set is derived from what is assigned. A role that is declared in a policy
|
|
6413
|
-
* but held by nobody yet cannot be discovered here; callers that need it
|
|
6414
|
-
* should union in the roles they already know about.
|
|
6415
|
-
*/
|
|
6416
|
-
async fetchApplicationRoles() {
|
|
6417
|
-
const located = await this.executeSql(`
|
|
6418
|
-
SELECT table_schema, table_name
|
|
6419
|
-
FROM information_schema.columns
|
|
6420
|
-
WHERE column_name = 'roles'
|
|
6421
|
-
AND data_type = 'ARRAY'
|
|
6422
|
-
AND table_name = 'users'
|
|
6423
|
-
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
6424
|
-
ORDER BY (table_schema = 'rebase') DESC, table_schema
|
|
6425
|
-
LIMIT 1;
|
|
6426
|
-
`);
|
|
6427
|
-
if (located.length === 0) return [];
|
|
6428
|
-
const schema = located[0].table_schema;
|
|
6429
|
-
const table = located[0].table_name;
|
|
6430
|
-
const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
|
|
6431
|
-
return (await this.executeSql(`
|
|
6432
|
-
SELECT DISTINCT unnest(roles) AS role
|
|
6433
|
-
FROM ${qualified}
|
|
6434
|
-
WHERE roles IS NOT NULL
|
|
6435
|
-
ORDER BY role;
|
|
6436
|
-
`)).map((r) => r.role).filter((r) => typeof r === "string" && r.length > 0);
|
|
6437
|
-
}
|
|
6438
|
-
async fetchCurrentDatabase() {
|
|
6439
|
-
return this.poolManager?.defaultDatabaseName;
|
|
6440
|
-
}
|
|
6441
|
-
/**
|
|
6442
|
-
* Fetch public tables that are not yet mapped to a collection.
|
|
6443
|
-
* Excludes internal tables (_rebase_*, _auth_*, auth tables, etc.)
|
|
6444
|
-
* and junction/connection tables used for many-to-many relations.
|
|
6981
|
+
* Rows are applied in order, so a batch that touches the same key twice ends
|
|
6982
|
+
* with the last write winning, exactly as separate calls would.
|
|
6445
6983
|
*/
|
|
6446
|
-
async
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6984
|
+
async saveMany({ path, rows, collection, upsert }) {
|
|
6985
|
+
return this.db.transaction(async (tx) => {
|
|
6986
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
6987
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
6988
|
+
txDriver.client = this.client;
|
|
6989
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
6990
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
6991
|
+
const saved = [];
|
|
6992
|
+
for (let i = 0; i < rows.length; i++) {
|
|
6993
|
+
const values = rows[i];
|
|
6994
|
+
const id = values?.id;
|
|
6995
|
+
try {
|
|
6996
|
+
saved.push(await txDriver.save({
|
|
6997
|
+
path,
|
|
6998
|
+
values,
|
|
6999
|
+
collection,
|
|
7000
|
+
status: "new",
|
|
7001
|
+
upsert
|
|
7002
|
+
}));
|
|
7003
|
+
} catch (error) {
|
|
7004
|
+
const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
|
|
7005
|
+
throw Object.assign(new Error(`Row ${i} of ${rows.length} (${label}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
7006
|
+
statusCode: error?.statusCode,
|
|
7007
|
+
code: error?.code,
|
|
7008
|
+
name: error?.name
|
|
7009
|
+
});
|
|
7010
|
+
}
|
|
7011
|
+
}
|
|
7012
|
+
return saved;
|
|
7013
|
+
});
|
|
6464
7014
|
}
|
|
6465
7015
|
/**
|
|
6466
|
-
*
|
|
7016
|
+
* Update many rows through the same pipeline as {@link save}, in one
|
|
7017
|
+
* transaction.
|
|
7018
|
+
*
|
|
7019
|
+
* Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
|
|
7020
|
+
* same deferred notifications, same per-row error labelling — but it calls
|
|
7021
|
+
* `save` with an explicit `id` and `status: "existing"`, which is precisely
|
|
7022
|
+
* what `saveMany` cannot do: that one passes `status: "new"` and keeps the
|
|
7023
|
+
* key inside `values`, so it inserts or upserts and can never target a
|
|
7024
|
+
* particular row.
|
|
7025
|
+
*
|
|
7026
|
+
* All-or-nothing, so an id matching no row aborts the batch. A partial
|
|
7027
|
+
* update is the outcome with no good recovery: the caller cannot tell which
|
|
7028
|
+
* half landed without re-reading everything.
|
|
6467
7029
|
*/
|
|
6468
|
-
async
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ${safeName};
|
|
6505
|
-
`)).rows,
|
|
6506
|
-
junctions: (await this.db.execute(sql`
|
|
6507
|
-
SELECT
|
|
6508
|
-
tc1.table_name as junction_table_name,
|
|
6509
|
-
kcu1.column_name as source_column_name,
|
|
6510
|
-
ccu2.table_name as target_table_name,
|
|
6511
|
-
kcu2.column_name as target_column_name
|
|
6512
|
-
FROM information_schema.table_constraints tc1
|
|
6513
|
-
JOIN information_schema.key_column_usage kcu1 ON tc1.constraint_name = kcu1.constraint_name
|
|
6514
|
-
JOIN information_schema.constraint_column_usage ccu1 ON ccu1.constraint_name = tc1.constraint_name
|
|
6515
|
-
JOIN information_schema.table_constraints tc2 ON tc1.table_name = tc2.table_name AND tc2.constraint_type = 'FOREIGN KEY'
|
|
6516
|
-
JOIN information_schema.key_column_usage kcu2 ON tc2.constraint_name = kcu2.constraint_name
|
|
6517
|
-
JOIN information_schema.constraint_column_usage ccu2 ON ccu2.constraint_name = tc2.constraint_name
|
|
6518
|
-
WHERE tc1.constraint_type = 'FOREIGN KEY'
|
|
6519
|
-
AND ccu1.table_name = ${safeName}
|
|
6520
|
-
AND ccu2.table_name != ${safeName};
|
|
6521
|
-
`)).rows,
|
|
6522
|
-
policies: (await this.db.execute(sql`
|
|
6523
|
-
SELECT
|
|
6524
|
-
polname as policy_name,
|
|
6525
|
-
polcmd as cmd,
|
|
6526
|
-
polroles::regrole[]::text[] as roles,
|
|
6527
|
-
pg_get_expr(polqual, polrelid) as qual,
|
|
6528
|
-
pg_get_expr(polwithcheck, polrelid) as with_check
|
|
6529
|
-
FROM pg_policy
|
|
6530
|
-
WHERE polrelid = (SELECT oid FROM pg_class WHERE relname = ${safeName} AND relnamespace = 'public'::regnamespace);
|
|
6531
|
-
`)).rows
|
|
6532
|
-
};
|
|
6533
|
-
}
|
|
6534
|
-
generateSubscriptionId() {
|
|
6535
|
-
return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
7030
|
+
async updateMany({ path, updates, collection }) {
|
|
7031
|
+
return this.db.transaction(async (tx) => {
|
|
7032
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
7033
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
7034
|
+
txDriver.client = this.client;
|
|
7035
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
7036
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
7037
|
+
const saved = [];
|
|
7038
|
+
for (let i = 0; i < updates.length; i++) {
|
|
7039
|
+
const { id, values } = updates[i];
|
|
7040
|
+
try {
|
|
7041
|
+
if (!await txDriver.fetchOne({
|
|
7042
|
+
path,
|
|
7043
|
+
id: String(id),
|
|
7044
|
+
collection
|
|
7045
|
+
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
7046
|
+
statusCode: 404,
|
|
7047
|
+
code: "NOT_FOUND"
|
|
7048
|
+
});
|
|
7049
|
+
saved.push(await txDriver.save({
|
|
7050
|
+
path,
|
|
7051
|
+
id: String(id),
|
|
7052
|
+
values,
|
|
7053
|
+
collection,
|
|
7054
|
+
status: "existing"
|
|
7055
|
+
}));
|
|
7056
|
+
} catch (error) {
|
|
7057
|
+
throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
7058
|
+
statusCode: error?.statusCode,
|
|
7059
|
+
code: error?.code,
|
|
7060
|
+
name: error?.name
|
|
7061
|
+
});
|
|
7062
|
+
}
|
|
7063
|
+
}
|
|
7064
|
+
return saved;
|
|
7065
|
+
});
|
|
6536
7066
|
}
|
|
6537
7067
|
/**
|
|
6538
|
-
*
|
|
6539
|
-
*
|
|
6540
|
-
*
|
|
7068
|
+
* Delete many rows in one transaction, running the full delete pipeline —
|
|
7069
|
+
* `beforeDelete`, the delete, `afterDelete` — for each.
|
|
7070
|
+
*
|
|
7071
|
+
* Looping the single-row {@link delete} rather than emitting one
|
|
7072
|
+
* `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
|
|
7073
|
+
* statement would be faster and would skip every callback, so a collection
|
|
7074
|
+
* relying on `beforeDelete` to veto or on `afterDelete` to clean up
|
|
7075
|
+
* dependents would behave differently depending on how many rows the caller
|
|
7076
|
+
* happened to delete at once. Same pipeline, one transaction.
|
|
6541
7077
|
*/
|
|
6542
|
-
async
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
7078
|
+
async deleteMany({ path, ids, collection }) {
|
|
7079
|
+
await this.db.transaction(async (tx) => {
|
|
7080
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
7081
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
7082
|
+
txDriver.client = this.client;
|
|
7083
|
+
txDriver._deferNotifications = this._deferNotifications;
|
|
7084
|
+
txDriver._pendingNotifications = this._pendingNotifications;
|
|
7085
|
+
for (let i = 0; i < ids.length; i++) {
|
|
7086
|
+
const id = ids[i];
|
|
7087
|
+
try {
|
|
7088
|
+
const existing = await txDriver.fetchOne({
|
|
7089
|
+
path,
|
|
7090
|
+
id: String(id),
|
|
7091
|
+
collection
|
|
7092
|
+
});
|
|
7093
|
+
if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
7094
|
+
statusCode: 404,
|
|
7095
|
+
code: "NOT_FOUND"
|
|
7096
|
+
});
|
|
7097
|
+
await txDriver.delete({
|
|
7098
|
+
row: {
|
|
7099
|
+
id: String(id),
|
|
7100
|
+
path,
|
|
7101
|
+
values: existing
|
|
7102
|
+
},
|
|
7103
|
+
collection
|
|
7104
|
+
});
|
|
7105
|
+
} catch (error) {
|
|
7106
|
+
throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
|
|
7107
|
+
statusCode: error?.statusCode,
|
|
7108
|
+
code: error?.code,
|
|
7109
|
+
name: error?.name
|
|
7110
|
+
});
|
|
7111
|
+
}
|
|
6573
7112
|
}
|
|
6574
|
-
};
|
|
7113
|
+
});
|
|
6575
7114
|
}
|
|
6576
|
-
async
|
|
6577
|
-
const
|
|
6578
|
-
const
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
7115
|
+
async delete({ row, collection }) {
|
|
7116
|
+
const targetPath = row.path;
|
|
7117
|
+
const targetRow = { ...row.values ?? {} };
|
|
7118
|
+
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
|
|
7119
|
+
const contextForCallback = this.buildCallContext();
|
|
7120
|
+
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
7121
|
+
let preventDefault = false;
|
|
7122
|
+
if (globalCallbacks?.beforeDelete) {
|
|
7123
|
+
if (await globalCallbacks.beforeDelete({
|
|
7124
|
+
collection: resolvedCollection,
|
|
7125
|
+
path: targetPath,
|
|
7126
|
+
id: row.id,
|
|
7127
|
+
row: targetRow,
|
|
7128
|
+
context: contextForCallback
|
|
7129
|
+
}) === false) preventDefault = true;
|
|
6583
7130
|
}
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
7131
|
+
if (callbacks?.beforeDelete) {
|
|
7132
|
+
if (await callbacks.beforeDelete({
|
|
7133
|
+
collection: resolvedCollection,
|
|
7134
|
+
path: targetPath,
|
|
7135
|
+
id: row.id,
|
|
7136
|
+
row: targetRow,
|
|
7137
|
+
context: contextForCallback
|
|
7138
|
+
}) === false) preventDefault = true;
|
|
7139
|
+
}
|
|
7140
|
+
if (propertyCallbacks?.beforeDelete) {
|
|
7141
|
+
if (await propertyCallbacks.beforeDelete({
|
|
7142
|
+
collection: resolvedCollection,
|
|
7143
|
+
path: targetPath,
|
|
7144
|
+
id: row.id,
|
|
7145
|
+
row: targetRow,
|
|
7146
|
+
context: contextForCallback
|
|
7147
|
+
}) === false) preventDefault = true;
|
|
7148
|
+
}
|
|
7149
|
+
if (preventDefault) return;
|
|
6602
7150
|
}
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
6642
|
-
*/
|
|
6643
|
-
async saveMany(props) {
|
|
6644
|
-
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
6645
|
-
}
|
|
6646
|
-
async delete(props) {
|
|
6647
|
-
return this.withTransaction((delegate) => delegate.delete(props));
|
|
7151
|
+
await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
|
|
7152
|
+
if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
|
|
7153
|
+
if (globalCallbacks?.afterDelete) await globalCallbacks.afterDelete({
|
|
7154
|
+
collection: resolvedCollection,
|
|
7155
|
+
path: targetPath,
|
|
7156
|
+
id: row.id,
|
|
7157
|
+
row: targetRow,
|
|
7158
|
+
context: contextForCallback
|
|
7159
|
+
});
|
|
7160
|
+
if (callbacks?.afterDelete) await callbacks.afterDelete({
|
|
7161
|
+
collection: resolvedCollection,
|
|
7162
|
+
path: targetPath,
|
|
7163
|
+
id: row.id,
|
|
7164
|
+
row: targetRow,
|
|
7165
|
+
context: contextForCallback
|
|
7166
|
+
});
|
|
7167
|
+
if (propertyCallbacks?.afterDelete) await propertyCallbacks.afterDelete({
|
|
7168
|
+
collection: resolvedCollection,
|
|
7169
|
+
path: targetPath,
|
|
7170
|
+
id: row.id,
|
|
7171
|
+
row: targetRow,
|
|
7172
|
+
context: contextForCallback
|
|
7173
|
+
});
|
|
7174
|
+
}
|
|
7175
|
+
if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
|
|
7176
|
+
tableName: targetPath,
|
|
7177
|
+
id: row.id.toString(),
|
|
7178
|
+
action: "delete",
|
|
7179
|
+
values: row.values ?? {},
|
|
7180
|
+
updatedBy: this.user?.uid
|
|
7181
|
+
});
|
|
7182
|
+
if (this._deferNotifications) this._pendingNotifications.push({
|
|
7183
|
+
path: targetPath,
|
|
7184
|
+
id: row.id.toString(),
|
|
7185
|
+
row: null,
|
|
7186
|
+
databaseId: resolvedCollection?.databaseId
|
|
7187
|
+
});
|
|
7188
|
+
else await this.realtimeService.notifyUpdate(targetPath, row.id.toString(), null, resolvedCollection?.databaseId);
|
|
6648
7189
|
}
|
|
6649
7190
|
async deleteAll(path) {
|
|
6650
|
-
|
|
7191
|
+
await this.dataService.deleteAll(path);
|
|
7192
|
+
await this.realtimeService.notifyUpdate(path, "*", null);
|
|
6651
7193
|
}
|
|
6652
7194
|
async checkUniqueField(path, name, value, id, collection) {
|
|
6653
|
-
return this.
|
|
6654
|
-
}
|
|
6655
|
-
async count(props) {
|
|
6656
|
-
return this.withTransaction((delegate) => delegate.count(props), { accessMode: "read only" });
|
|
6657
|
-
}
|
|
6658
|
-
};
|
|
6659
|
-
//#endregion
|
|
6660
|
-
//#region src/databasePoolManager.ts
|
|
6661
|
-
var DatabasePoolManager = class {
|
|
6662
|
-
pools = /* @__PURE__ */ new Map();
|
|
6663
|
-
drizzleInstances = /* @__PURE__ */ new Map();
|
|
6664
|
-
defaultDatabaseName;
|
|
6665
|
-
rootConnectionString;
|
|
6666
|
-
constructor(adminConnectionString) {
|
|
6667
|
-
this.rootConnectionString = adminConnectionString;
|
|
6668
|
-
try {
|
|
6669
|
-
const url = new URL(adminConnectionString);
|
|
6670
|
-
this.defaultDatabaseName = url.pathname.slice(1);
|
|
6671
|
-
} catch (e) {
|
|
6672
|
-
throw new Error(`Invalid adminConnectionString provided: ${e}`);
|
|
6673
|
-
}
|
|
6674
|
-
}
|
|
6675
|
-
getDrizzle(databaseName) {
|
|
6676
|
-
const existing = this.drizzleInstances.get(databaseName);
|
|
6677
|
-
if (existing) return existing;
|
|
6678
|
-
const db = drizzle(this.getPool(databaseName));
|
|
6679
|
-
this.drizzleInstances.set(databaseName, db);
|
|
6680
|
-
return db;
|
|
7195
|
+
return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
|
|
6681
7196
|
}
|
|
6682
|
-
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
max: 10,
|
|
6689
|
-
idleTimeoutMillis: 1e4,
|
|
6690
|
-
allowExitOnIdle: true
|
|
6691
|
-
});
|
|
6692
|
-
pool.on("error", (err) => {
|
|
6693
|
-
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
7197
|
+
async count({ path, collection, filter, logical, searchString, vectorSearch }) {
|
|
7198
|
+
return this.dataService.count(path, {
|
|
7199
|
+
filter,
|
|
7200
|
+
logical,
|
|
7201
|
+
searchString,
|
|
7202
|
+
vectorSearch
|
|
6694
7203
|
});
|
|
6695
|
-
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
6696
|
-
this.pools.set(databaseName, pool);
|
|
6697
|
-
return pool;
|
|
6698
7204
|
}
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
*/
|
|
6704
|
-
async disconnectDatabase(databaseName) {
|
|
6705
|
-
const pool = this.pools.get(databaseName);
|
|
6706
|
-
if (pool) {
|
|
6707
|
-
await pool.end();
|
|
6708
|
-
this.pools.delete(databaseName);
|
|
6709
|
-
this.drizzleInstances.delete(databaseName);
|
|
6710
|
-
}
|
|
7205
|
+
getTargetDb(databaseName) {
|
|
7206
|
+
if (!databaseName || databaseName === this.poolManager?.defaultDatabaseName) return this.db;
|
|
7207
|
+
if (!this.poolManager) throw new Error("Cross-database execution requires adminConnectionString to be configured in the backend.");
|
|
7208
|
+
return this.poolManager.getDrizzle(databaseName);
|
|
6711
7209
|
}
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
7210
|
+
async executeSql(sqlText, options) {
|
|
7211
|
+
if (!options?.database && !options?.role) return this.dataService.executeSql(sqlText, options?.params);
|
|
7212
|
+
const targetDb = this.getTargetDb(options?.database);
|
|
7213
|
+
try {
|
|
7214
|
+
let needsRoleSwitch = false;
|
|
7215
|
+
if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true" && !this._roleSwitchingDisabled) try {
|
|
7216
|
+
const currentRole = ((await targetDb.execute(sql.raw("SELECT current_user AS role"))).rows?.[0])?.role;
|
|
7217
|
+
needsRoleSwitch = !!currentRole && currentRole !== options.role;
|
|
7218
|
+
} catch {
|
|
7219
|
+
needsRoleSwitch = true;
|
|
7220
|
+
}
|
|
7221
|
+
if (needsRoleSwitch && options?.role) {
|
|
7222
|
+
const safeRole = options.role.replace(/"/g, "\"\"");
|
|
7223
|
+
try {
|
|
7224
|
+
return await targetDb.transaction(async (tx) => {
|
|
7225
|
+
await tx.execute(sql.raw(`SET LOCAL ROLE "${safeRole}"`));
|
|
7226
|
+
let result;
|
|
7227
|
+
if (options?.params && options.params.length > 0) {
|
|
7228
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
7229
|
+
const chunks = [];
|
|
7230
|
+
for (let i = 0; i < parts.length; i++) if (i % 2 === 0) {
|
|
7231
|
+
if (parts[i].length > 0) chunks.push(sql.raw(parts[i]));
|
|
7232
|
+
} else chunks.push(sql.param(options.params[Number(parts[i]) - 1]));
|
|
7233
|
+
result = await tx.execute(sql.join(chunks, sql.raw("")));
|
|
7234
|
+
} else result = await tx.execute(sql.raw(sqlText));
|
|
7235
|
+
return result.rows;
|
|
7236
|
+
});
|
|
7237
|
+
} catch (roleError) {
|
|
7238
|
+
if (isRoleSwitchingPermissionError(roleError)) {
|
|
7239
|
+
logger.warn(`[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — the connection user lacks permission. Falling back to executing without role switching. To suppress this warning, set DISABLE_DB_ROLE_SWITCHING=true in your .env file.`);
|
|
7240
|
+
this._roleSwitchingDisabled = true;
|
|
7241
|
+
} else throw roleError;
|
|
7242
|
+
}
|
|
7243
|
+
}
|
|
7244
|
+
let result;
|
|
7245
|
+
if (options?.params && options.params.length > 0) {
|
|
7246
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
7247
|
+
const chunks = [];
|
|
7248
|
+
for (let i = 0; i < parts.length; i++) if (i % 2 === 0) {
|
|
7249
|
+
if (parts[i].length > 0) chunks.push(sql.raw(parts[i]));
|
|
7250
|
+
} else chunks.push(sql.param(options.params[Number(parts[i]) - 1]));
|
|
7251
|
+
result = await targetDb.execute(sql.join(chunks, sql.raw("")));
|
|
7252
|
+
} else result = await targetDb.execute(sql.raw(sqlText));
|
|
7253
|
+
return result.rows;
|
|
7254
|
+
} catch (error) {
|
|
7255
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
7256
|
+
if (msg.includes("pg_hba.conf") || msg.includes("no encryption") || msg.includes("connection refused")) {
|
|
7257
|
+
const dbName = options?.database || "unknown";
|
|
7258
|
+
throw new Error(`Cannot connect to database "${dbName}": the server rejected the connection. This database may require SSL or is not accessible from this host.`);
|
|
7259
|
+
}
|
|
7260
|
+
throw error;
|
|
7261
|
+
}
|
|
6715
7262
|
}
|
|
6716
|
-
async
|
|
6717
|
-
const
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
7263
|
+
async fetchAvailableDatabases() {
|
|
7264
|
+
const databases = (await this.executeSql(`SELECT datname FROM pg_database
|
|
7265
|
+
WHERE datistemplate = false
|
|
7266
|
+
AND datname NOT IN ('postgres', 'cloudsqladmin', '_cloudsqladmin')
|
|
7267
|
+
ORDER BY datname;`)).map((r) => r.datname);
|
|
7268
|
+
const currentDb = this.poolManager?.defaultDatabaseName;
|
|
7269
|
+
if (currentDb && !databases.includes(currentDb)) databases.unshift(currentDb);
|
|
7270
|
+
else if (currentDb) {
|
|
7271
|
+
const idx = databases.indexOf(currentDb);
|
|
7272
|
+
if (idx > 0) {
|
|
7273
|
+
databases.splice(idx, 1);
|
|
7274
|
+
databases.unshift(currentDb);
|
|
7275
|
+
}
|
|
6721
7276
|
}
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
|
|
7277
|
+
return databases;
|
|
7278
|
+
}
|
|
7279
|
+
async fetchAvailableRoles() {
|
|
7280
|
+
return (await this.executeSql("SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;")).map((r) => r.rolname);
|
|
6725
7281
|
}
|
|
6726
|
-
};
|
|
6727
|
-
//#endregion
|
|
6728
|
-
//#region src/schema/auth-schema.ts
|
|
6729
|
-
/**
|
|
6730
|
-
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
6731
|
-
*
|
|
6732
|
-
* This module builds queries; it does not create tables. `ensureAuthTablesExist`
|
|
6733
|
-
* owns the DDL, which makes everything here a *claim* about a database it cannot
|
|
6734
|
-
* enforce — and the claims drifted. Every column below was declared
|
|
6735
|
-
* `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
|
|
6736
|
-
* `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
|
|
6737
|
-
* `token_hash` as varchar(255). None of it was true of any database this
|
|
6738
|
-
* framework ever provisioned. Harmless at runtime — drizzle does not enforce a
|
|
6739
|
-
* length client-side, so the widths only ever misled the next reader — but a
|
|
6740
|
-
* schema module that describes columns that do not exist is worse than no
|
|
6741
|
-
* schema module. They are `text` here now because they are TEXT there.
|
|
6742
|
-
*/
|
|
6743
|
-
function createAuthSchema(usersSchemaName = "rebase") {
|
|
6744
|
-
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
6745
|
-
const tableCreator = usersSchema ? usersSchema.table.bind(usersSchema) : pgTable;
|
|
6746
|
-
/**
|
|
6747
|
-
* Users table - stores both email/password and OAuth users
|
|
6748
|
-
*/
|
|
6749
|
-
const users = tableCreator("users", {
|
|
6750
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
6751
|
-
email: text("email").notNull().unique(),
|
|
6752
|
-
passwordHash: text("password_hash"),
|
|
6753
|
-
displayName: text("display_name"),
|
|
6754
|
-
photoUrl: text("photo_url"),
|
|
6755
|
-
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
6756
|
-
emailVerificationToken: text("email_verification_token"),
|
|
6757
|
-
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
6758
|
-
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
6759
|
-
roles: text("roles").array().default([]).notNull(),
|
|
6760
|
-
metadata: jsonb("metadata").$type().default({}).notNull(),
|
|
6761
|
-
/**
|
|
6762
|
-
* Sessions that began before this instant are dead, whatever tokens
|
|
6763
|
-
* they still hold. Password resets and admin revocations stamp it.
|
|
6764
|
-
*
|
|
6765
|
-
* Deleting the user's refresh-token rows (which we also do) is not
|
|
6766
|
-
* sufficient on its own: a request already in flight can insert a
|
|
6767
|
-
* freshly rotated row microseconds after the delete and survive it.
|
|
6768
|
-
* This timestamp cannot be outrun that way — it is checked against
|
|
6769
|
-
* `refresh_tokens.session_started_at`, which rotation carries forward.
|
|
6770
|
-
*/
|
|
6771
|
-
tokensValidAfter: timestamp("tokens_valid_after"),
|
|
6772
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
6773
|
-
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
6774
|
-
});
|
|
6775
7282
|
/**
|
|
6776
|
-
*
|
|
7283
|
+
* Application-level roles actually in use in this project.
|
|
6777
7284
|
*
|
|
6778
|
-
*
|
|
6779
|
-
*
|
|
6780
|
-
*
|
|
6781
|
-
*
|
|
6782
|
-
*
|
|
6783
|
-
*
|
|
6784
|
-
*
|
|
6785
|
-
* — the previous behaviour — made those two cases indistinguishable, and
|
|
6786
|
-
* the legitimate one is overwhelmingly the common one.
|
|
7285
|
+
* Distinct from {@link fetchAvailableRoles}, which returns native
|
|
7286
|
+
* PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
|
|
7287
|
+
* are the roles the SQL editor can `SET ROLE` to. *These* are the strings
|
|
7288
|
+
* held in the users table's `roles` column, injected per-transaction as
|
|
7289
|
+
* `rebase.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
|
|
7290
|
+
* into a `SecurityRule.roles` field produces a condition no user can ever
|
|
7291
|
+
* satisfy, so the two must not be conflated.
|
|
6787
7292
|
*
|
|
6788
|
-
*
|
|
6789
|
-
*
|
|
6790
|
-
*
|
|
6791
|
-
*
|
|
6792
|
-
*
|
|
6793
|
-
* `sessionId` is the identity.
|
|
7293
|
+
* Roles have no registry table — they were migrated out of
|
|
7294
|
+
* `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
|
|
7295
|
+
* set is derived from what is assigned. A role that is declared in a policy
|
|
7296
|
+
* but held by nobody yet cannot be discovered here; callers that need it
|
|
7297
|
+
* should union in the roles they already know about.
|
|
6794
7298
|
*/
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
}, (table) => ({ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId) }));
|
|
7299
|
+
async fetchApplicationRoles() {
|
|
7300
|
+
const located = await this.executeSql(`
|
|
7301
|
+
SELECT table_schema, table_name
|
|
7302
|
+
FROM information_schema.columns
|
|
7303
|
+
WHERE column_name = 'roles'
|
|
7304
|
+
AND data_type = 'ARRAY'
|
|
7305
|
+
AND table_name = 'users'
|
|
7306
|
+
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
7307
|
+
ORDER BY (table_schema = 'rebase') DESC, table_schema
|
|
7308
|
+
LIMIT 1;
|
|
7309
|
+
`);
|
|
7310
|
+
if (located.length === 0) return [];
|
|
7311
|
+
const schema = located[0].table_schema;
|
|
7312
|
+
const table = located[0].table_name;
|
|
7313
|
+
const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
|
|
7314
|
+
return (await this.executeSql(`
|
|
7315
|
+
SELECT DISTINCT unnest(roles) AS role
|
|
7316
|
+
FROM ${qualified}
|
|
7317
|
+
WHERE roles IS NOT NULL
|
|
7318
|
+
ORDER BY role;
|
|
7319
|
+
`)).map((r) => r.role).filter((r) => typeof r === "string" && r.length > 0);
|
|
7320
|
+
}
|
|
7321
|
+
async fetchCurrentDatabase() {
|
|
7322
|
+
return this.poolManager?.defaultDatabaseName;
|
|
7323
|
+
}
|
|
6821
7324
|
/**
|
|
6822
|
-
*
|
|
7325
|
+
* Fetch public tables that are not yet mapped to a collection.
|
|
7326
|
+
* Excludes internal tables (_rebase_*, _auth_*, auth tables, etc.)
|
|
7327
|
+
* and junction/connection tables used for many-to-many relations.
|
|
6823
7328
|
*/
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
7329
|
+
async fetchUnmappedTables(mappedPaths) {
|
|
7330
|
+
const allTables = (await this.executeSql(`
|
|
7331
|
+
SELECT table_name
|
|
7332
|
+
FROM information_schema.tables
|
|
7333
|
+
WHERE table_schema = 'public'
|
|
7334
|
+
AND table_type = 'BASE TABLE'
|
|
7335
|
+
ORDER BY table_name;
|
|
7336
|
+
`)).map((r) => r.table_name).filter((name) => classifyTable(name, "public") !== "rebase-internal");
|
|
7337
|
+
let junctionTables = /* @__PURE__ */ new Set();
|
|
7338
|
+
try {
|
|
7339
|
+
junctionTables = await detectJunctionTables(this.executeSql.bind(this));
|
|
7340
|
+
} catch (e) {
|
|
7341
|
+
logger.warn("Could not detect junction tables", { error: e });
|
|
7342
|
+
}
|
|
7343
|
+
const filteredTables = allTables.filter((name) => !junctionTables.has(name));
|
|
7344
|
+
if (!mappedPaths || mappedPaths.length === 0) return filteredTables;
|
|
7345
|
+
const mappedSet = new Set(mappedPaths.map((p) => p.toLowerCase()));
|
|
7346
|
+
return filteredTables.filter((name) => !mappedSet.has(name.toLowerCase()));
|
|
7347
|
+
}
|
|
6832
7348
|
/**
|
|
6833
|
-
*
|
|
7349
|
+
* Fetch metadata for a given table from information_schema (columns, policies, constraints).
|
|
6834
7350
|
*/
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
const mfaFactors = tableCreator("mfa_factors", {
|
|
6856
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
6857
|
-
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
6858
|
-
factorType: text("factor_type").notNull(),
|
|
6859
|
-
secretEncrypted: text("secret_encrypted").notNull(),
|
|
6860
|
-
friendlyName: text("friendly_name"),
|
|
6861
|
-
verified: boolean("verified").default(false).notNull(),
|
|
6862
|
-
/**
|
|
6863
|
-
* The highest TOTP time step ever accepted for this factor. RFC 6238
|
|
6864
|
-
* §5.2 forbids accepting an OTP twice, and the ±1 step window that
|
|
6865
|
-
* exists for clock drift is also a 90-second replay window: without
|
|
6866
|
-
* this, one observed code buys a fresh session for a minute and a half.
|
|
6867
|
-
*/
|
|
6868
|
-
lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
|
|
6869
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
6870
|
-
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
6871
|
-
});
|
|
6872
|
-
return {
|
|
6873
|
-
usersSchema,
|
|
6874
|
-
users,
|
|
6875
|
-
refreshTokens,
|
|
6876
|
-
passwordResetTokens,
|
|
6877
|
-
appConfig,
|
|
6878
|
-
userIdentities,
|
|
6879
|
-
mfaFactors,
|
|
6880
|
-
mfaChallenges: tableCreator("mfa_challenges", {
|
|
6881
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
6882
|
-
factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
|
|
6883
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
6884
|
-
verifiedAt: timestamp("verified_at"),
|
|
6885
|
-
ipAddress: text("ip_address"),
|
|
6886
|
-
/** Failed guesses recorded against this challenge; bounded by the route. */
|
|
6887
|
-
attempts: integer("attempts").default(0).notNull(),
|
|
6888
|
-
expiresAt: timestamp("expires_at").notNull()
|
|
6889
|
-
}),
|
|
6890
|
-
recoveryCodes: tableCreator("recovery_codes", {
|
|
6891
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
6892
|
-
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
6893
|
-
codeHash: text("code_hash").notNull(),
|
|
6894
|
-
usedAt: timestamp("used_at"),
|
|
6895
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
6896
|
-
}),
|
|
6897
|
-
magicLinkTokens: tableCreator("magic_link_tokens", {
|
|
6898
|
-
id: uuid("id").defaultRandom().primaryKey(),
|
|
6899
|
-
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
6900
|
-
tokenHash: text("token_hash").notNull().unique(),
|
|
6901
|
-
expiresAt: timestamp("expires_at").notNull(),
|
|
6902
|
-
usedAt: timestamp("used_at"),
|
|
6903
|
-
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
6904
|
-
})
|
|
6905
|
-
};
|
|
6906
|
-
}
|
|
6907
|
-
var defaultAuthSchema = createAuthSchema("rebase");
|
|
6908
|
-
var usersSchema = defaultAuthSchema.usersSchema;
|
|
6909
|
-
var users = defaultAuthSchema.users;
|
|
6910
|
-
var refreshTokens = defaultAuthSchema.refreshTokens;
|
|
6911
|
-
var passwordResetTokens = defaultAuthSchema.passwordResetTokens;
|
|
6912
|
-
var appConfig = defaultAuthSchema.appConfig;
|
|
6913
|
-
var userIdentities = defaultAuthSchema.userIdentities;
|
|
6914
|
-
var mfaFactors = defaultAuthSchema.mfaFactors;
|
|
6915
|
-
var mfaChallenges = defaultAuthSchema.mfaChallenges;
|
|
6916
|
-
var recoveryCodes = defaultAuthSchema.recoveryCodes;
|
|
6917
|
-
var magicLinkTokens = defaultAuthSchema.magicLinkTokens;
|
|
6918
|
-
var usersRelations = relations(users, ({ many }) => ({
|
|
6919
|
-
refreshTokens: many(refreshTokens),
|
|
6920
|
-
passwordResetTokens: many(passwordResetTokens),
|
|
6921
|
-
userIdentities: many(userIdentities),
|
|
6922
|
-
mfaFactors: many(mfaFactors),
|
|
6923
|
-
recoveryCodes: many(recoveryCodes),
|
|
6924
|
-
magicLinkTokens: many(magicLinkTokens)
|
|
6925
|
-
}));
|
|
6926
|
-
var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
|
|
6927
|
-
fields: [refreshTokens.uid],
|
|
6928
|
-
references: [users.id]
|
|
6929
|
-
}) }));
|
|
6930
|
-
var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
|
|
6931
|
-
fields: [passwordResetTokens.uid],
|
|
6932
|
-
references: [users.id]
|
|
6933
|
-
}) }));
|
|
6934
|
-
var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
|
|
6935
|
-
fields: [userIdentities.uid],
|
|
6936
|
-
references: [users.id]
|
|
6937
|
-
}) }));
|
|
6938
|
-
var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
|
|
6939
|
-
user: one(users, {
|
|
6940
|
-
fields: [mfaFactors.uid],
|
|
6941
|
-
references: [users.id]
|
|
6942
|
-
}),
|
|
6943
|
-
challenges: many(mfaChallenges)
|
|
6944
|
-
}));
|
|
6945
|
-
var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: one(mfaFactors, {
|
|
6946
|
-
fields: [mfaChallenges.factorId],
|
|
6947
|
-
references: [mfaFactors.id]
|
|
6948
|
-
}) }));
|
|
6949
|
-
var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
|
|
6950
|
-
fields: [recoveryCodes.uid],
|
|
6951
|
-
references: [users.id]
|
|
6952
|
-
}) }));
|
|
6953
|
-
var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
|
|
6954
|
-
fields: [magicLinkTokens.uid],
|
|
6955
|
-
references: [users.id]
|
|
6956
|
-
}) }));
|
|
6957
|
-
//#endregion
|
|
6958
|
-
//#region src/schema/generate-drizzle-schema-logic.ts
|
|
6959
|
-
/**
|
|
6960
|
-
* Resolve the SQL column name for a property.
|
|
6961
|
-
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
6962
|
-
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
6963
|
-
*/
|
|
6964
|
-
var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
6965
|
-
/**
|
|
6966
|
-
* A string literal for the generated schema file.
|
|
6967
|
-
*
|
|
6968
|
-
* Column names, table names and enum values are all written into this file as
|
|
6969
|
-
* literals, and none of them is constrained to be quote-free: a Postgres
|
|
6970
|
-
* identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
|
|
6971
|
-
* Interpolating them raw ended the literal early — for enum values, inside
|
|
6972
|
-
* single quotes, where an apostrophe is not an edge case.
|
|
6973
|
-
*/
|
|
6974
|
-
var quote$1 = (value) => JSON.stringify(value);
|
|
6975
|
-
/** An object key: verbatim when it is an identifier, quoted otherwise. */
|
|
6976
|
-
var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
|
|
6977
|
-
/**
|
|
6978
|
-
* A property access on a generated table variable.
|
|
6979
|
-
*
|
|
6980
|
-
* `users.full name` is not an expression; `users["full name"]` is, and Drizzle
|
|
6981
|
-
* treats the two identically.
|
|
6982
|
-
*/
|
|
6983
|
-
var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
|
|
6984
|
-
var resolveColumnName = (propName, prop) => {
|
|
6985
|
-
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
6986
|
-
return toSnakeCase(propName);
|
|
6987
|
-
};
|
|
6988
|
-
var getPrimaryKeyProp = (collection) => {
|
|
6989
|
-
if (collection.properties) {
|
|
6990
|
-
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
|
|
6991
|
-
if (idPropEntry) {
|
|
6992
|
-
const prop = idPropEntry[1];
|
|
6993
|
-
const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
|
|
6994
|
-
return {
|
|
6995
|
-
name: idPropEntry[0],
|
|
6996
|
-
type: prop.type === "number" ? "number" : "string",
|
|
6997
|
-
isUuid
|
|
6998
|
-
};
|
|
6999
|
-
}
|
|
7000
|
-
}
|
|
7001
|
-
const idProp = collection.properties?.["id"];
|
|
7002
|
-
if (idProp?.type === "number") return {
|
|
7003
|
-
name: "id",
|
|
7004
|
-
type: "number",
|
|
7005
|
-
isUuid: false
|
|
7006
|
-
};
|
|
7007
|
-
return {
|
|
7008
|
-
name: "id",
|
|
7009
|
-
type: "string",
|
|
7010
|
-
isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
|
|
7011
|
-
};
|
|
7012
|
-
};
|
|
7013
|
-
/**
|
|
7014
|
-
* Given a raw DB column name (e.g. "client_id"), the Drizzle property key that
|
|
7015
|
-
* maps to it.
|
|
7016
|
-
*
|
|
7017
|
-
* One line, because the rule is shared: the Drizzle object key is the wire
|
|
7018
|
-
* name, and {@link fieldKeyForColumn} is the one definition of what a column is
|
|
7019
|
-
* named on the wire. This used to be a private copy that fell back to the
|
|
7020
|
-
* column verbatim, which is how a derived foreign key ended up served as
|
|
7021
|
-
* `author_id` beside a hand-authored `displayName`.
|
|
7022
|
-
*/
|
|
7023
|
-
var resolvePropertyKeyForColumn = (collection, column) => fieldKeyForColumn(collection, column);
|
|
7024
|
-
var isNumericId = (collection) => {
|
|
7025
|
-
return getPrimaryKeyProp(collection).type === "number";
|
|
7026
|
-
};
|
|
7027
|
-
var getPrimaryKeyName = (collection) => {
|
|
7028
|
-
return getPrimaryKeyProp(collection).name;
|
|
7029
|
-
};
|
|
7030
|
-
var isIdProperty = (propName, prop, collection) => {
|
|
7031
|
-
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
7032
|
-
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
7033
|
-
};
|
|
7034
|
-
/**
|
|
7035
|
-
* The Drizzle column declaration a property compiles to, or `null` when the
|
|
7036
|
-
* property puts no column on *this* table (an inverse relation, whose column
|
|
7037
|
-
* lives on the target). Exported so it can be checked against its DDL twin
|
|
7038
|
-
* `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
|
|
7039
|
-
* with a database column and no Drizzle key.
|
|
7040
|
-
*/
|
|
7041
|
-
var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
7042
|
-
const colName = resolveColumnName(propName, prop);
|
|
7043
|
-
let columnDefinition;
|
|
7044
|
-
switch (prop.type) {
|
|
7045
|
-
case "string": {
|
|
7046
|
-
const stringProp = prop;
|
|
7047
|
-
if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
|
|
7048
|
-
else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
7049
|
-
else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
|
|
7050
|
-
else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
7051
|
-
else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
7052
|
-
else columnDefinition = `text(${quote$1(colName)})`;
|
|
7053
|
-
if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
|
|
7054
|
-
if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
|
|
7055
|
-
if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
|
|
7056
|
-
else if (stringProp.isId === "cuid") columnDefinition += ".default(sql`cuid()`)";
|
|
7057
|
-
else if (typeof stringProp.isId === "string") {
|
|
7058
|
-
const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`") ? stringProp.isId.substring(4, stringProp.isId.length - 1) : stringProp.isId;
|
|
7059
|
-
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
7060
|
-
}
|
|
7061
|
-
}
|
|
7062
|
-
if (stringProp.validation?.unique) columnDefinition += ".unique()";
|
|
7063
|
-
break;
|
|
7064
|
-
}
|
|
7065
|
-
case "number": {
|
|
7066
|
-
const numProp = prop;
|
|
7067
|
-
const isId = isIdProperty(propName, prop, collection);
|
|
7068
|
-
let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
|
|
7069
|
-
if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
|
|
7070
|
-
else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
|
|
7071
|
-
else baseType = `${numProp.columnType}(${quote$1(colName)})`;
|
|
7072
|
-
if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
|
|
7073
|
-
else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
|
|
7074
|
-
columnDefinition = baseType;
|
|
7075
|
-
const sqlContent = numProp.isId.startsWith("sql`") && numProp.isId.endsWith("`") ? numProp.isId.substring(4, numProp.isId.length - 1) : numProp.isId;
|
|
7076
|
-
columnDefinition += `.default(sql\`${sqlContent}\`)`;
|
|
7077
|
-
} else columnDefinition = baseType;
|
|
7078
|
-
if (isId) columnDefinition += ".primaryKey()";
|
|
7079
|
-
if (numProp.validation?.unique) columnDefinition += ".unique()";
|
|
7080
|
-
break;
|
|
7081
|
-
}
|
|
7082
|
-
case "boolean":
|
|
7083
|
-
columnDefinition = `boolean(${quote$1(colName)})`;
|
|
7084
|
-
break;
|
|
7085
|
-
case "date": {
|
|
7086
|
-
const dateProp = prop;
|
|
7087
|
-
if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
|
|
7088
|
-
else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
|
|
7089
|
-
else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
|
|
7090
|
-
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
|
|
7091
|
-
break;
|
|
7092
|
-
}
|
|
7093
|
-
case "map":
|
|
7094
|
-
if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
7095
|
-
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
7096
|
-
break;
|
|
7097
|
-
case "geopoint":
|
|
7098
|
-
columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
7099
|
-
break;
|
|
7100
|
-
case "array": {
|
|
7101
|
-
const arrayProp = prop;
|
|
7102
|
-
let colType = arrayProp.columnType;
|
|
7103
|
-
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
7104
|
-
const ofProp = arrayProp.of;
|
|
7105
|
-
if (ofProp.type === "string") colType = "text[]";
|
|
7106
|
-
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
7107
|
-
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
7108
|
-
}
|
|
7109
|
-
if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
|
|
7110
|
-
else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
|
|
7111
|
-
else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
|
|
7112
|
-
else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
|
|
7113
|
-
else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
|
|
7114
|
-
else columnDefinition = `jsonb(${quote$1(colName)})`;
|
|
7115
|
-
break;
|
|
7116
|
-
}
|
|
7117
|
-
case "vector": {
|
|
7118
|
-
const vp = prop;
|
|
7119
|
-
columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
|
|
7120
|
-
break;
|
|
7121
|
-
}
|
|
7122
|
-
case "binary":
|
|
7123
|
-
columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
|
|
7124
|
-
break;
|
|
7125
|
-
case "relation": {
|
|
7126
|
-
const refProp = prop;
|
|
7127
|
-
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
7128
|
-
if (!relation || relation.kind !== "belongsTo") return null;
|
|
7129
|
-
const fkFieldKey = fieldKeyForColumn(collection, relation.localKey);
|
|
7130
|
-
if (collection.properties[fkFieldKey] && propName !== fkFieldKey) return null;
|
|
7131
|
-
let targetCollection;
|
|
7132
|
-
try {
|
|
7133
|
-
targetCollection = relation.target();
|
|
7134
|
-
} catch {
|
|
7135
|
-
return null;
|
|
7136
|
-
}
|
|
7137
|
-
const fkColumnName = relation.localKey;
|
|
7138
|
-
const targetTableVar = getTableVarName(getTableName$1(targetCollection));
|
|
7139
|
-
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
7140
|
-
const targetIdField = pkProp.name;
|
|
7141
|
-
const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`;
|
|
7142
|
-
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
7143
|
-
const required = prop.validation?.required;
|
|
7144
|
-
const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
|
|
7145
|
-
const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
|
|
7146
|
-
let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
|
|
7147
|
-
if (required) columnDef += ".notNull()";
|
|
7148
|
-
return ` ${propKey(fkFieldKey)}: ${columnDef}`;
|
|
7351
|
+
async fetchTableMetadata(tableName) {
|
|
7352
|
+
const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
7353
|
+
const columns = (await this.db.execute(sql`
|
|
7354
|
+
SELECT column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length
|
|
7355
|
+
FROM information_schema.columns
|
|
7356
|
+
WHERE table_schema = 'public'
|
|
7357
|
+
AND table_name = ${safeName}
|
|
7358
|
+
ORDER BY ordinal_position
|
|
7359
|
+
`)).rows;
|
|
7360
|
+
const enumColumns = columns.filter((c) => c.data_type === "USER-DEFINED");
|
|
7361
|
+
if (enumColumns.length > 0) for (const col of enumColumns) try {
|
|
7362
|
+
col.enum_values = (await this.db.execute(sql`
|
|
7363
|
+
SELECT e.enumlabel
|
|
7364
|
+
FROM pg_type t
|
|
7365
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
7366
|
+
WHERE t.typname = ${col.udt_name}
|
|
7367
|
+
ORDER BY e.enumsortorder
|
|
7368
|
+
`)).rows.map((e) => e.enumlabel);
|
|
7369
|
+
} catch {
|
|
7370
|
+
col.enum_values = [];
|
|
7149
7371
|
}
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7372
|
+
return {
|
|
7373
|
+
columns,
|
|
7374
|
+
foreignKeys: (await this.db.execute(sql`
|
|
7375
|
+
SELECT
|
|
7376
|
+
kcu.column_name as column_name,
|
|
7377
|
+
ccu.table_name AS foreign_table_name,
|
|
7378
|
+
ccu.column_name AS foreign_column_name
|
|
7379
|
+
FROM
|
|
7380
|
+
information_schema.table_constraints AS tc
|
|
7381
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
7382
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
7383
|
+
AND tc.table_schema = kcu.table_schema
|
|
7384
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
7385
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
7386
|
+
AND ccu.table_schema = tc.table_schema
|
|
7387
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ${safeName};
|
|
7388
|
+
`)).rows,
|
|
7389
|
+
junctions: (await this.db.execute(sql`
|
|
7390
|
+
SELECT
|
|
7391
|
+
tc1.table_name as junction_table_name,
|
|
7392
|
+
kcu1.column_name as source_column_name,
|
|
7393
|
+
ccu2.table_name as target_table_name,
|
|
7394
|
+
kcu2.column_name as target_column_name
|
|
7395
|
+
FROM information_schema.table_constraints tc1
|
|
7396
|
+
JOIN information_schema.key_column_usage kcu1 ON tc1.constraint_name = kcu1.constraint_name
|
|
7397
|
+
JOIN information_schema.constraint_column_usage ccu1 ON ccu1.constraint_name = tc1.constraint_name
|
|
7398
|
+
JOIN information_schema.table_constraints tc2 ON tc1.table_name = tc2.table_name AND tc2.constraint_type = 'FOREIGN KEY'
|
|
7399
|
+
JOIN information_schema.key_column_usage kcu2 ON tc2.constraint_name = kcu2.constraint_name
|
|
7400
|
+
JOIN information_schema.constraint_column_usage ccu2 ON ccu2.constraint_name = tc2.constraint_name
|
|
7401
|
+
WHERE tc1.constraint_type = 'FOREIGN KEY'
|
|
7402
|
+
AND ccu1.table_name = ${safeName}
|
|
7403
|
+
AND ccu2.table_name != ${safeName};
|
|
7404
|
+
`)).rows,
|
|
7405
|
+
policies: (await this.db.execute(sql`
|
|
7406
|
+
SELECT
|
|
7407
|
+
polname as policy_name,
|
|
7408
|
+
polcmd as cmd,
|
|
7409
|
+
polroles::regrole[]::text[] as roles,
|
|
7410
|
+
pg_get_expr(polqual, polrelid) as qual,
|
|
7411
|
+
pg_get_expr(polwithcheck, polrelid) as with_check
|
|
7412
|
+
FROM pg_policy
|
|
7413
|
+
WHERE polrelid = (SELECT oid FROM pg_class WHERE relname = ${safeName} AND relnamespace = 'public'::regnamespace);
|
|
7414
|
+
`)).rows
|
|
7415
|
+
};
|
|
7416
|
+
}
|
|
7417
|
+
generateSubscriptionId() {
|
|
7418
|
+
return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
7419
|
+
}
|
|
7420
|
+
/**
|
|
7421
|
+
* Create a new delegate instance with authenticated context.
|
|
7422
|
+
* Starts a transaction and sets the current_user_id and current_user_roles
|
|
7423
|
+
* configuration parameters for PostgreSQL Row Level Security.
|
|
7424
|
+
*/
|
|
7425
|
+
async withAuth(user) {
|
|
7426
|
+
return new AuthenticatedPostgresBackendDriver(this, user);
|
|
7427
|
+
}
|
|
7428
|
+
};
|
|
7429
|
+
var AuthenticatedPostgresBackendDriver = class {
|
|
7430
|
+
delegate;
|
|
7431
|
+
key = "postgres";
|
|
7432
|
+
initialised = true;
|
|
7433
|
+
user;
|
|
7434
|
+
data;
|
|
7435
|
+
constructor(delegate, user) {
|
|
7436
|
+
this.delegate = delegate;
|
|
7437
|
+
this.user = user;
|
|
7438
|
+
this.data = buildSdkData(this);
|
|
7439
|
+
this.admin = delegate.admin;
|
|
7440
|
+
}
|
|
7441
|
+
/**
|
|
7442
|
+
* Typed admin capabilities — delegates to the base driver.
|
|
7443
|
+
*/
|
|
7444
|
+
admin;
|
|
7445
|
+
get restFetchService() {
|
|
7446
|
+
return {
|
|
7447
|
+
fetchCollectionForRest: async (collectionPath, options, include) => {
|
|
7448
|
+
return this.withTransaction(async (delegate) => {
|
|
7449
|
+
return delegate.restFetchService.fetchCollectionForRest(collectionPath, options, include);
|
|
7450
|
+
}, { accessMode: "read only" });
|
|
7451
|
+
},
|
|
7452
|
+
fetchOneForRest: async (collectionPath, id, include, databaseId) => {
|
|
7453
|
+
return this.withTransaction(async (delegate) => {
|
|
7454
|
+
return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId);
|
|
7455
|
+
}, { accessMode: "read only" });
|
|
7156
7456
|
}
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
async withTransaction(operation, options) {
|
|
7460
|
+
const pendingNotifications = [];
|
|
7461
|
+
const result = await this.delegate.db.transaction(async (tx) => {
|
|
7462
|
+
let uid = this.user?.uid;
|
|
7463
|
+
if (!uid) {
|
|
7464
|
+
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
7465
|
+
uid = "anonymous";
|
|
7466
|
+
}
|
|
7467
|
+
const userRoles = this.user?.roles ?? [];
|
|
7468
|
+
if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
7469
|
+
await applyAuthContext(tx, {
|
|
7470
|
+
uid,
|
|
7471
|
+
roles: userRoles
|
|
7472
|
+
}, this.delegate.rlsUserRole);
|
|
7473
|
+
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
7474
|
+
const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
|
|
7475
|
+
txDelegate.dataService = txEntityService;
|
|
7476
|
+
txDelegate._deferNotifications = true;
|
|
7477
|
+
txDelegate._pendingNotifications = pendingNotifications;
|
|
7478
|
+
txDelegate.client = this.delegate.client;
|
|
7479
|
+
return await operation(txDelegate);
|
|
7480
|
+
}, options);
|
|
7481
|
+
for (const notification of pendingNotifications) try {
|
|
7482
|
+
await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
|
|
7483
|
+
} catch (e) {
|
|
7484
|
+
logger.error("[DataDriver] Error flushing deferred notification", { error: e });
|
|
7166
7485
|
}
|
|
7167
|
-
|
|
7486
|
+
return result;
|
|
7168
7487
|
}
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
}
|
|
7172
|
-
/**
|
|
7173
|
-
*
|
|
7174
|
-
*
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
*
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
if (!withCheckClause && needsWithCheck) withCheckClause = "sql`false`";
|
|
7213
|
-
const parts = [];
|
|
7214
|
-
parts.push(`as: "${mode}"`);
|
|
7215
|
-
parts.push(`for: "${operation}"`);
|
|
7216
|
-
const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
7217
|
-
parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
|
|
7218
|
-
if (usingClause) parts.push(`using: ${usingClause}`);
|
|
7219
|
-
if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
|
|
7220
|
-
return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
|
|
7221
|
-
};
|
|
7222
|
-
/**
|
|
7223
|
-
* Computes a deterministic shared relation name for Drizzle.
|
|
7224
|
-
*
|
|
7225
|
-
* Drizzle requires both sides of a relation (owning + inverse) to use the
|
|
7226
|
-
* exact same `relationName` string so it can pair them. Each collection
|
|
7227
|
-
* definition may use a different local `relationName`, so we need a canonical
|
|
7228
|
-
* form that both sides can independently compute.
|
|
7229
|
-
*
|
|
7230
|
-
* Strategy: `{owningTable}_{foreignKey}`
|
|
7231
|
-
* - owning side → `{thisTable}_{localKey}` e.g. "jobs_company_id"
|
|
7232
|
-
* - inverse side → `{targetTable}_{foreignKeyOnTarget}` e.g. "jobs_company_id"
|
|
7233
|
-
*
|
|
7234
|
-
* For M2M with junction tables the owning relation name is already shared via
|
|
7235
|
-
* the junction table wiring, so we keep it as-is.
|
|
7236
|
-
*
|
|
7237
|
-
* Falls back to the local relation name when the counterpart can't be resolved.
|
|
7238
|
-
*/
|
|
7239
|
-
var computeSharedRelationName = (rel, sourceCollection, _collections) => {
|
|
7240
|
-
const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
|
|
7241
|
-
if (rel.kind === "belongsTo") {
|
|
7242
|
-
const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
|
|
7243
|
-
return `${getTableName$1(sourceCollection)}_${normalisedKey}`;
|
|
7488
|
+
async fetchCollection(props) {
|
|
7489
|
+
return this.withTransaction((delegate) => delegate.fetchCollection(props), { accessMode: "read only" });
|
|
7490
|
+
}
|
|
7491
|
+
/**
|
|
7492
|
+
* Injects the authenticated user's context into the most recently
|
|
7493
|
+
* registered realtime subscription so RLS-aware polling can apply.
|
|
7494
|
+
*/
|
|
7495
|
+
injectAuthContext(unsubscribe) {
|
|
7496
|
+
const authContext = {
|
|
7497
|
+
uid: this.user?.uid || "anonymous",
|
|
7498
|
+
roles: this.user?.roles ?? []
|
|
7499
|
+
};
|
|
7500
|
+
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
7501
|
+
const lastSub = entries[entries.length - 1]?.[1];
|
|
7502
|
+
if (lastSub && lastSub.clientId === "driver") lastSub.authContext = authContext;
|
|
7503
|
+
return unsubscribe;
|
|
7504
|
+
}
|
|
7505
|
+
listenCollection(props) {
|
|
7506
|
+
return this.injectAuthContext(this.delegate.listenCollection(props));
|
|
7507
|
+
}
|
|
7508
|
+
async fetchOne(props) {
|
|
7509
|
+
return this.withTransaction((delegate) => delegate.fetchOne(props), { accessMode: "read only" });
|
|
7510
|
+
}
|
|
7511
|
+
listenOne(props) {
|
|
7512
|
+
return this.injectAuthContext(this.delegate.listenOne(props));
|
|
7513
|
+
}
|
|
7514
|
+
async save(props) {
|
|
7515
|
+
return this.withTransaction((delegate) => delegate.save(props));
|
|
7516
|
+
}
|
|
7517
|
+
/**
|
|
7518
|
+
* One transaction for the whole batch, rather than one per row.
|
|
7519
|
+
*
|
|
7520
|
+
* This is the point of the method: `save` opens a transaction per call, so
|
|
7521
|
+
* importing 10k rows through it means 10k transactions (and, over HTTP, 10k
|
|
7522
|
+
* round trips). Here the RLS context is established once and every row lands
|
|
7523
|
+
* or none does. Realtime notifications are already deferred to commit by
|
|
7524
|
+
* `withTransaction`, so a batch does not flood subscribers mid-flight.
|
|
7525
|
+
*/
|
|
7526
|
+
async saveMany(props) {
|
|
7527
|
+
return this.withTransaction((delegate) => delegate.saveMany(props));
|
|
7528
|
+
}
|
|
7529
|
+
async delete(props) {
|
|
7530
|
+
return this.withTransaction((delegate) => delegate.delete(props));
|
|
7244
7531
|
}
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
|
|
7250
|
-
|
|
7532
|
+
async deleteAll(path) {
|
|
7533
|
+
return this.withTransaction((delegate) => delegate.deleteAll(path));
|
|
7534
|
+
}
|
|
7535
|
+
async checkUniqueField(path, name, value, id, collection) {
|
|
7536
|
+
return this.withTransaction((delegate) => delegate.checkUniqueField(path, name, value, id, collection), { accessMode: "read only" });
|
|
7537
|
+
}
|
|
7538
|
+
async count(props) {
|
|
7539
|
+
return this.withTransaction((delegate) => delegate.count(props), { accessMode: "read only" });
|
|
7251
7540
|
}
|
|
7252
|
-
return fallback;
|
|
7253
7541
|
};
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
"boolean",
|
|
7269
|
-
"timestamp",
|
|
7270
|
-
"date",
|
|
7271
|
-
"time",
|
|
7272
|
-
"jsonb",
|
|
7273
|
-
"json",
|
|
7274
|
-
"pgEnum",
|
|
7275
|
-
"numeric",
|
|
7276
|
-
"real",
|
|
7277
|
-
"doublePrecision",
|
|
7278
|
-
"bigint",
|
|
7279
|
-
"serial",
|
|
7280
|
-
"bigserial",
|
|
7281
|
-
"pgPolicy"
|
|
7282
|
-
];
|
|
7283
|
-
if (hasUuid) pgCoreImports.push("uuid");
|
|
7284
|
-
if (hasVector) pgCoreImports.push("vector");
|
|
7285
|
-
if (hasBinary || hasSearch) pgCoreImports.push("customType");
|
|
7286
|
-
const uniqueSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
|
|
7287
|
-
if (uniqueSchemas.length > 0) pgCoreImports.push("pgSchema");
|
|
7288
|
-
schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
|
|
7289
|
-
schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
|
|
7290
|
-
uniqueSchemas.forEach((schema) => {
|
|
7291
|
-
schemaContent += `export const ${schema}Schema = pgSchema("${schema}");\n`;
|
|
7292
|
-
});
|
|
7293
|
-
if (uniqueSchemas.length > 0) schemaContent += "\n";
|
|
7294
|
-
const exportedTableVars = [];
|
|
7295
|
-
const exportedEnumVars = [];
|
|
7296
|
-
const exportedRelationVars = [];
|
|
7297
|
-
const allTablesToGenerate = /* @__PURE__ */ new Map();
|
|
7298
|
-
collections.forEach((collection) => {
|
|
7299
|
-
const collectionPath = getTableName$1(collection);
|
|
7300
|
-
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
7301
|
-
if ("enum" in prop && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
7302
|
-
const enumVarName = getEnumVarName(collectionPath, propName);
|
|
7303
|
-
const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
|
|
7304
|
-
const values = Array.isArray(prop.enum) ? prop.enum.map((v) => String(typeof v === "object" && v !== null && "id" in v ? v.id : v)) : Object.keys(prop.enum);
|
|
7305
|
-
if (values.length > 0) {
|
|
7306
|
-
schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
|
|
7307
|
-
if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
|
|
7308
|
-
}
|
|
7309
|
-
}
|
|
7310
|
-
});
|
|
7311
|
-
});
|
|
7312
|
-
schemaContent += "\n";
|
|
7313
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
7314
|
-
for (const collection of collections) {
|
|
7315
|
-
const tableName = getTableName$1(collection);
|
|
7316
|
-
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
7317
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
7318
|
-
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
7319
|
-
const junctionTableName = relation.through.table;
|
|
7320
|
-
if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
|
|
7321
|
-
collection: {
|
|
7322
|
-
table: junctionTableName,
|
|
7323
|
-
properties: {}
|
|
7324
|
-
},
|
|
7325
|
-
isJunction: true,
|
|
7326
|
-
relation,
|
|
7327
|
-
sourceCollection: collection
|
|
7328
|
-
});
|
|
7542
|
+
//#endregion
|
|
7543
|
+
//#region src/databasePoolManager.ts
|
|
7544
|
+
var DatabasePoolManager = class {
|
|
7545
|
+
pools = /* @__PURE__ */ new Map();
|
|
7546
|
+
drizzleInstances = /* @__PURE__ */ new Map();
|
|
7547
|
+
defaultDatabaseName;
|
|
7548
|
+
rootConnectionString;
|
|
7549
|
+
constructor(adminConnectionString) {
|
|
7550
|
+
this.rootConnectionString = adminConnectionString;
|
|
7551
|
+
try {
|
|
7552
|
+
const url = new URL(adminConnectionString);
|
|
7553
|
+
this.defaultDatabaseName = url.pathname.slice(1);
|
|
7554
|
+
} catch (e) {
|
|
7555
|
+
throw new Error(`Invalid adminConnectionString provided: ${e}`);
|
|
7329
7556
|
}
|
|
7330
7557
|
}
|
|
7331
|
-
|
|
7332
|
-
const
|
|
7333
|
-
if (
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
const { sourceColumn, targetColumn } = relation.through;
|
|
7338
|
-
const refOptions = `{ onDelete: \"${relation.onDelete ?? "cascade"}\" }`;
|
|
7339
|
-
const sourceColType = isNumericId(sourceCollection) ? "integer" : getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text";
|
|
7340
|
-
const targetColType = isNumericId(targetCollection) ? "integer" : getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text";
|
|
7341
|
-
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
7342
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
7343
|
-
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
7344
|
-
schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
|
|
7345
|
-
schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName$1(targetCollection))}.${targetId}, ${refOptions}),\n`;
|
|
7346
|
-
schemaContent += "}, (table) => ([\n";
|
|
7347
|
-
schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
|
|
7348
|
-
const junctionSpec = junctionSpecs.get(baseTableName);
|
|
7349
|
-
if (!stripPolicies && junctionSpec) {
|
|
7350
|
-
const junctionCollection = getJunctionCollectionConfig(junctionSpec);
|
|
7351
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
7352
|
-
getJunctionSecurityRules(junctionSpec).forEach((rule, idx) => {
|
|
7353
|
-
schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
|
|
7354
|
-
});
|
|
7355
|
-
}
|
|
7356
|
-
schemaContent += "])).enableRLS();\n\n";
|
|
7357
|
-
} else if (!isJunction) {
|
|
7358
|
-
const schema = isPostgresCollectionConfig(collection) ? collection.schema : void 0;
|
|
7359
|
-
const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
|
|
7360
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
7361
|
-
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
7362
|
-
const columns = /* @__PURE__ */ new Set();
|
|
7363
|
-
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
7364
|
-
const columnString = getDrizzleColumn(propName, prop, collection, collections);
|
|
7365
|
-
if (columnString) columns.add(columnString);
|
|
7366
|
-
});
|
|
7367
|
-
const searchSpec = buildSearchColumnSpec(collection);
|
|
7368
|
-
if (searchSpec) {
|
|
7369
|
-
columns.add(` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}").generatedAlwaysAs(sql\`${searchSpec.expression}\`)`);
|
|
7370
|
-
if (searchSpec.fuzzy) columns.add(` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}").generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`);
|
|
7371
|
-
}
|
|
7372
|
-
if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
|
|
7373
|
-
schemaContent += `${Array.from(columns).join(",\n")}`;
|
|
7374
|
-
const securityRules = getEffectiveSecurityRules(collection);
|
|
7375
|
-
if (!stripPolicies && securityRules.length > 0) {
|
|
7376
|
-
schemaContent += "\n}, (table) => ([\n";
|
|
7377
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName$1(c) === slug);
|
|
7378
|
-
securityRules.forEach((rule, idx) => {
|
|
7379
|
-
schemaContent += generatePolicyCode(collection, rule, idx, resolveCollection);
|
|
7380
|
-
});
|
|
7381
|
-
schemaContent += "])).enableRLS();\n\n";
|
|
7382
|
-
} else schemaContent += "\n}).enableRLS();\n\n";
|
|
7383
|
-
}
|
|
7384
|
-
if (!exportedTableVars.includes(tableVarName)) exportedTableVars.push(tableVarName);
|
|
7558
|
+
getDrizzle(databaseName) {
|
|
7559
|
+
const existing = this.drizzleInstances.get(databaseName);
|
|
7560
|
+
if (existing) return existing;
|
|
7561
|
+
const db = drizzle(this.getPool(databaseName));
|
|
7562
|
+
this.drizzleInstances.set(databaseName, db);
|
|
7563
|
+
return db;
|
|
7385
7564
|
}
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
const
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
const emittedRelationNames = /* @__PURE__ */ new Set();
|
|
7415
|
-
for (const [relationKey, rel] of Object.entries(resolvedRelations)) try {
|
|
7416
|
-
const target = rel.target();
|
|
7417
|
-
const targetTableVar = getTableVarName(getTableName$1(target));
|
|
7418
|
-
const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
|
|
7419
|
-
const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
|
|
7420
|
-
if (emittedRelationNames.has(deduplicationKey)) continue;
|
|
7421
|
-
emittedRelationNames.add(deduplicationKey);
|
|
7422
|
-
switch (rel.kind) {
|
|
7423
|
-
case "belongsTo": {
|
|
7424
|
-
const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
|
|
7425
|
-
tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
7426
|
-
break;
|
|
7427
|
-
}
|
|
7428
|
-
case "hasOne":
|
|
7429
|
-
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
7430
|
-
break;
|
|
7431
|
-
case "hasMany":
|
|
7432
|
-
tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
7433
|
-
break;
|
|
7434
|
-
case "manyToMany": {
|
|
7435
|
-
const junctionTableVar = getTableVarName(rel.through.table);
|
|
7436
|
-
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
7437
|
-
break;
|
|
7438
|
-
}
|
|
7439
|
-
case "via": break;
|
|
7440
|
-
}
|
|
7441
|
-
} catch (e) {
|
|
7442
|
-
logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
|
|
7443
|
-
}
|
|
7444
|
-
for (const otherCollection of collections) {
|
|
7445
|
-
if (otherCollection.slug === collection.slug) continue;
|
|
7446
|
-
const otherRelations = resolveCollectionRelations(otherCollection);
|
|
7447
|
-
for (const [otherKey, otherRel] of Object.entries(otherRelations)) if (hasForeignKeyOnTarget(otherRel)) try {
|
|
7448
|
-
if (otherRel.target().slug === collection.slug) {
|
|
7449
|
-
const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
|
|
7450
|
-
const deduplicationKey = `${drizzleRelationName}::belongsTo`;
|
|
7451
|
-
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
7452
|
-
const otherTableVar = getTableVarName(getTableName$1(otherCollection));
|
|
7453
|
-
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
7454
|
-
const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
|
|
7455
|
-
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
7456
|
-
tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
|
|
7457
|
-
emittedRelationNames.add(deduplicationKey);
|
|
7458
|
-
}
|
|
7459
|
-
}
|
|
7460
|
-
} catch (e) {}
|
|
7461
|
-
}
|
|
7565
|
+
getPool(databaseName) {
|
|
7566
|
+
if (this.pools.has(databaseName)) return this.pools.get(databaseName);
|
|
7567
|
+
const url = new URL(this.rootConnectionString);
|
|
7568
|
+
url.pathname = `/${databaseName}`;
|
|
7569
|
+
const pool = new Pool({
|
|
7570
|
+
connectionString: pinSearchPath(url.toString()),
|
|
7571
|
+
max: cappedPoolMax(10),
|
|
7572
|
+
idleTimeoutMillis: 1e4,
|
|
7573
|
+
allowExitOnIdle: true
|
|
7574
|
+
});
|
|
7575
|
+
pool.on("error", (err) => {
|
|
7576
|
+
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
7577
|
+
});
|
|
7578
|
+
guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
|
|
7579
|
+
this.pools.set(databaseName, pool);
|
|
7580
|
+
return pool;
|
|
7581
|
+
}
|
|
7582
|
+
/**
|
|
7583
|
+
* Disconnect and remove the pool for a specific database.
|
|
7584
|
+
* Required before `CREATE DATABASE ... TEMPLATE` or `DROP DATABASE`,
|
|
7585
|
+
* which need exclusive access to the target database.
|
|
7586
|
+
*/
|
|
7587
|
+
async disconnectDatabase(databaseName) {
|
|
7588
|
+
const pool = this.pools.get(databaseName);
|
|
7589
|
+
if (pool) {
|
|
7590
|
+
await pool.end();
|
|
7591
|
+
this.pools.delete(databaseName);
|
|
7592
|
+
this.drizzleInstances.delete(databaseName);
|
|
7462
7593
|
}
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
7594
|
+
}
|
|
7595
|
+
/** Check if a pool exists for a given database name. */
|
|
7596
|
+
hasPool(databaseName) {
|
|
7597
|
+
return this.pools.has(databaseName);
|
|
7598
|
+
}
|
|
7599
|
+
async shutdown() {
|
|
7600
|
+
const promises = [];
|
|
7601
|
+
for (const [dbName, pool] of this.pools.entries()) {
|
|
7602
|
+
logger.info(`[DatabasePoolManager] Shutting down pool for ${dbName}`);
|
|
7603
|
+
promises.push(pool.end());
|
|
7467
7604
|
}
|
|
7605
|
+
await Promise.all(promises);
|
|
7606
|
+
this.pools.clear();
|
|
7607
|
+
this.drizzleInstances.clear();
|
|
7468
7608
|
}
|
|
7469
|
-
const tablesExport = `export const tables = { ${exportedTableVars.join(", ")} };\n`;
|
|
7470
|
-
const enumsExport = `export const enums = { ${exportedEnumVars.join(", ")} };\n`;
|
|
7471
|
-
const relationsExport = `export const relations = { ${exportedRelationVars.join(", ")} };\n\n`;
|
|
7472
|
-
schemaContent += tablesExport + enumsExport + relationsExport;
|
|
7473
|
-
return schemaContent;
|
|
7474
7609
|
};
|
|
7475
7610
|
//#endregion
|
|
7611
|
+
//#region src/schema/auth-schema.ts
|
|
7612
|
+
/**
|
|
7613
|
+
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
7614
|
+
*
|
|
7615
|
+
* This module builds queries; it does not create tables. `ensureAuthTablesExist`
|
|
7616
|
+
* owns the DDL, which makes everything here a *claim* about a database it cannot
|
|
7617
|
+
* enforce — and the claims drifted. Every column below was declared
|
|
7618
|
+
* `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
|
|
7619
|
+
* `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
|
|
7620
|
+
* `token_hash` as varchar(255). None of it was true of any database this
|
|
7621
|
+
* framework ever provisioned. Harmless at runtime — drizzle does not enforce a
|
|
7622
|
+
* length client-side, so the widths only ever misled the next reader — but a
|
|
7623
|
+
* schema module that describes columns that do not exist is worse than no
|
|
7624
|
+
* schema module. They are `text` here now because they are TEXT there.
|
|
7625
|
+
*/
|
|
7626
|
+
function createAuthSchema(usersSchemaName = "rebase") {
|
|
7627
|
+
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
7628
|
+
const tableCreator = usersSchema ? usersSchema.table.bind(usersSchema) : pgTable;
|
|
7629
|
+
/**
|
|
7630
|
+
* Users table - stores both email/password and OAuth users
|
|
7631
|
+
*/
|
|
7632
|
+
const users = tableCreator("users", {
|
|
7633
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7634
|
+
email: text("email").notNull().unique(),
|
|
7635
|
+
passwordHash: text("password_hash"),
|
|
7636
|
+
displayName: text("display_name"),
|
|
7637
|
+
photoUrl: text("photo_url"),
|
|
7638
|
+
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
7639
|
+
emailVerificationToken: text("email_verification_token"),
|
|
7640
|
+
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
7641
|
+
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
7642
|
+
roles: text("roles").array().default([]).notNull(),
|
|
7643
|
+
metadata: jsonb("metadata").$type().default({}).notNull(),
|
|
7644
|
+
/**
|
|
7645
|
+
* Sessions that began before this instant are dead, whatever tokens
|
|
7646
|
+
* they still hold. Password resets and admin revocations stamp it.
|
|
7647
|
+
*
|
|
7648
|
+
* Deleting the user's refresh-token rows (which we also do) is not
|
|
7649
|
+
* sufficient on its own: a request already in flight can insert a
|
|
7650
|
+
* freshly rotated row microseconds after the delete and survive it.
|
|
7651
|
+
* This timestamp cannot be outrun that way — it is checked against
|
|
7652
|
+
* `refresh_tokens.session_started_at`, which rotation carries forward.
|
|
7653
|
+
*/
|
|
7654
|
+
tokensValidAfter: timestamp("tokens_valid_after"),
|
|
7655
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
7656
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
7657
|
+
});
|
|
7658
|
+
/**
|
|
7659
|
+
* Refresh tokens for long-lived sessions.
|
|
7660
|
+
*
|
|
7661
|
+
* A row is one token, not one device. Every token minted from the same
|
|
7662
|
+
* sign-in shares a `sessionId`, and rotation ADDS a row rather than
|
|
7663
|
+
* replacing one: the superseded token stays on file, flagged `revoked`
|
|
7664
|
+
* with a `rotatedAt` stamp. That record is what lets the refresh endpoint
|
|
7665
|
+
* tell a client replaying a token it never got an answer for (a response
|
|
7666
|
+
* lost to a redeploy, a second tab racing on boot) apart from a stranger
|
|
7667
|
+
* presenting a token that was never issued. Deleting the old row on sight
|
|
7668
|
+
* — the previous behaviour — made those two cases indistinguishable, and
|
|
7669
|
+
* the legitimate one is overwhelmingly the common one.
|
|
7670
|
+
*
|
|
7671
|
+
* There is deliberately NO unique constraint on (uid, user_agent,
|
|
7672
|
+
* ip_address). Keying a session on the IP meant one row per "device",
|
|
7673
|
+
* so a second browser profile behind the same NAT silently evicted the
|
|
7674
|
+
* first, and a phone changing networks orphaned a row on every hop.
|
|
7675
|
+
* User agent and IP are descriptive metadata for the sessions list;
|
|
7676
|
+
* `sessionId` is the identity.
|
|
7677
|
+
*/
|
|
7678
|
+
const refreshTokens = tableCreator("refresh_tokens", {
|
|
7679
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7680
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
7681
|
+
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
7682
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
7683
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
7684
|
+
revoked: boolean("revoked").default(false).notNull(),
|
|
7685
|
+
rotatedAt: timestamp("rotated_at"),
|
|
7686
|
+
/**
|
|
7687
|
+
* When the sign-in this token descends from happened — carried across
|
|
7688
|
+
* every rotation, unlike `createdAt`. `users.tokensValidAfter` is
|
|
7689
|
+
* compared against this, so a revocation cannot be outrun by a token
|
|
7690
|
+
* that rotates immediately after it.
|
|
7691
|
+
*/
|
|
7692
|
+
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
7693
|
+
/**
|
|
7694
|
+
* The assurance level the sign-in was established at — `aal2` only
|
|
7695
|
+
* where a second factor was actually presented. Carried across
|
|
7696
|
+
* rotations, because refresh is not a new authentication and has
|
|
7697
|
+
* nothing else to read the level from.
|
|
7698
|
+
*/
|
|
7699
|
+
aal: text("aal"),
|
|
7700
|
+
userAgent: text("user_agent"),
|
|
7701
|
+
ipAddress: text("ip_address"),
|
|
7702
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
7703
|
+
}, (table) => ({ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId) }));
|
|
7704
|
+
/**
|
|
7705
|
+
* Password reset tokens for forgot password flow
|
|
7706
|
+
*/
|
|
7707
|
+
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
7708
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7709
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
7710
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
7711
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
7712
|
+
usedAt: timestamp("used_at"),
|
|
7713
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
7714
|
+
});
|
|
7715
|
+
/**
|
|
7716
|
+
* App config - key/value store for custom settings
|
|
7717
|
+
*/
|
|
7718
|
+
const appConfig = tableCreator("app_config", {
|
|
7719
|
+
key: text("key").primaryKey(),
|
|
7720
|
+
value: jsonb("value").notNull(),
|
|
7721
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
7722
|
+
});
|
|
7723
|
+
/**
|
|
7724
|
+
* User identities - maps external OAuth profiles back to local users
|
|
7725
|
+
*/
|
|
7726
|
+
const userIdentities = tableCreator("user_identities", {
|
|
7727
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7728
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
7729
|
+
provider: text("provider").notNull(),
|
|
7730
|
+
providerId: text("provider_id").notNull(),
|
|
7731
|
+
profileData: jsonb("profile_data"),
|
|
7732
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
7733
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
7734
|
+
}, (table) => ({ uniqueProviderId: unique("unique_provider_id").on(table.provider, table.providerId) }));
|
|
7735
|
+
/**
|
|
7736
|
+
* MFA factors table - stores enrolled MFA methods
|
|
7737
|
+
*/
|
|
7738
|
+
const mfaFactors = tableCreator("mfa_factors", {
|
|
7739
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7740
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
7741
|
+
factorType: text("factor_type").notNull(),
|
|
7742
|
+
secretEncrypted: text("secret_encrypted").notNull(),
|
|
7743
|
+
friendlyName: text("friendly_name"),
|
|
7744
|
+
verified: boolean("verified").default(false).notNull(),
|
|
7745
|
+
/**
|
|
7746
|
+
* The highest TOTP time step ever accepted for this factor. RFC 6238
|
|
7747
|
+
* §5.2 forbids accepting an OTP twice, and the ±1 step window that
|
|
7748
|
+
* exists for clock drift is also a 90-second replay window: without
|
|
7749
|
+
* this, one observed code buys a fresh session for a minute and a half.
|
|
7750
|
+
*/
|
|
7751
|
+
lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
|
|
7752
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
7753
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
7754
|
+
});
|
|
7755
|
+
return {
|
|
7756
|
+
usersSchema,
|
|
7757
|
+
users,
|
|
7758
|
+
refreshTokens,
|
|
7759
|
+
passwordResetTokens,
|
|
7760
|
+
appConfig,
|
|
7761
|
+
userIdentities,
|
|
7762
|
+
mfaFactors,
|
|
7763
|
+
mfaChallenges: tableCreator("mfa_challenges", {
|
|
7764
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7765
|
+
factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
|
|
7766
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
7767
|
+
verifiedAt: timestamp("verified_at"),
|
|
7768
|
+
ipAddress: text("ip_address"),
|
|
7769
|
+
/** Failed guesses recorded against this challenge; bounded by the route. */
|
|
7770
|
+
attempts: integer("attempts").default(0).notNull(),
|
|
7771
|
+
expiresAt: timestamp("expires_at").notNull()
|
|
7772
|
+
}),
|
|
7773
|
+
recoveryCodes: tableCreator("recovery_codes", {
|
|
7774
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7775
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
7776
|
+
codeHash: text("code_hash").notNull(),
|
|
7777
|
+
usedAt: timestamp("used_at"),
|
|
7778
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
7779
|
+
}),
|
|
7780
|
+
magicLinkTokens: tableCreator("magic_link_tokens", {
|
|
7781
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7782
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
7783
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
7784
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
7785
|
+
usedAt: timestamp("used_at"),
|
|
7786
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
7787
|
+
})
|
|
7788
|
+
};
|
|
7789
|
+
}
|
|
7790
|
+
var defaultAuthSchema = createAuthSchema("rebase");
|
|
7791
|
+
var usersSchema = defaultAuthSchema.usersSchema;
|
|
7792
|
+
var users = defaultAuthSchema.users;
|
|
7793
|
+
var refreshTokens = defaultAuthSchema.refreshTokens;
|
|
7794
|
+
var passwordResetTokens = defaultAuthSchema.passwordResetTokens;
|
|
7795
|
+
var appConfig = defaultAuthSchema.appConfig;
|
|
7796
|
+
var userIdentities = defaultAuthSchema.userIdentities;
|
|
7797
|
+
var mfaFactors = defaultAuthSchema.mfaFactors;
|
|
7798
|
+
var mfaChallenges = defaultAuthSchema.mfaChallenges;
|
|
7799
|
+
var recoveryCodes = defaultAuthSchema.recoveryCodes;
|
|
7800
|
+
var magicLinkTokens = defaultAuthSchema.magicLinkTokens;
|
|
7801
|
+
var usersRelations = relations(users, ({ many }) => ({
|
|
7802
|
+
refreshTokens: many(refreshTokens),
|
|
7803
|
+
passwordResetTokens: many(passwordResetTokens),
|
|
7804
|
+
userIdentities: many(userIdentities),
|
|
7805
|
+
mfaFactors: many(mfaFactors),
|
|
7806
|
+
recoveryCodes: many(recoveryCodes),
|
|
7807
|
+
magicLinkTokens: many(magicLinkTokens)
|
|
7808
|
+
}));
|
|
7809
|
+
var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
|
|
7810
|
+
fields: [refreshTokens.uid],
|
|
7811
|
+
references: [users.id]
|
|
7812
|
+
}) }));
|
|
7813
|
+
var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
|
|
7814
|
+
fields: [passwordResetTokens.uid],
|
|
7815
|
+
references: [users.id]
|
|
7816
|
+
}) }));
|
|
7817
|
+
var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
|
|
7818
|
+
fields: [userIdentities.uid],
|
|
7819
|
+
references: [users.id]
|
|
7820
|
+
}) }));
|
|
7821
|
+
var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
|
|
7822
|
+
user: one(users, {
|
|
7823
|
+
fields: [mfaFactors.uid],
|
|
7824
|
+
references: [users.id]
|
|
7825
|
+
}),
|
|
7826
|
+
challenges: many(mfaChallenges)
|
|
7827
|
+
}));
|
|
7828
|
+
var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: one(mfaFactors, {
|
|
7829
|
+
fields: [mfaChallenges.factorId],
|
|
7830
|
+
references: [mfaFactors.id]
|
|
7831
|
+
}) }));
|
|
7832
|
+
var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
|
|
7833
|
+
fields: [recoveryCodes.uid],
|
|
7834
|
+
references: [users.id]
|
|
7835
|
+
}) }));
|
|
7836
|
+
var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
|
|
7837
|
+
fields: [magicLinkTokens.uid],
|
|
7838
|
+
references: [users.id]
|
|
7839
|
+
}) }));
|
|
7840
|
+
//#endregion
|
|
7476
7841
|
//#region src/cli-output.ts
|
|
7477
7842
|
/**
|
|
7478
7843
|
* Terminal output for the `rebase db|schema|doctor` commands.
|
|
@@ -7563,7 +7928,7 @@ var runGeneration = async (collectionsFilePath, outputPath) => {
|
|
|
7563
7928
|
outError(`Error generating schema: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
7564
7929
|
}
|
|
7565
7930
|
};
|
|
7566
|
-
var main = () => {
|
|
7931
|
+
var main = async () => {
|
|
7567
7932
|
const collectionsFilePathArg = process.argv.find((arg) => arg.startsWith("--collections="));
|
|
7568
7933
|
const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
|
|
7569
7934
|
const outputPathArg = process.argv.find((arg) => arg.startsWith("--output="));
|
|
@@ -7577,6 +7942,7 @@ var main = () => {
|
|
|
7577
7942
|
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : void 0;
|
|
7578
7943
|
if (watch) {
|
|
7579
7944
|
out(`Watching for changes in ${resolvedPath}...`);
|
|
7945
|
+
const { default: chokidar } = await import("chokidar");
|
|
7580
7946
|
chokidar.watch(resolvedPath, {
|
|
7581
7947
|
persistent: true,
|
|
7582
7948
|
ignoreInitial: false
|
|
@@ -10561,7 +10927,7 @@ function createBackupCron(config) {
|
|
|
10561
10927
|
enabled: config.enabled ?? true,
|
|
10562
10928
|
timeoutSeconds: 3600,
|
|
10563
10929
|
async handler({ log }) {
|
|
10564
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
10930
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-FN6V3rVi.js").then((n) => n.r);
|
|
10565
10931
|
const { destination } = config;
|
|
10566
10932
|
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 }).`);
|
|
10567
10933
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -13758,7 +14124,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13758
14124
|
let readDb;
|
|
13759
14125
|
const readUrl = process.env.DATABASE_READ_URL;
|
|
13760
14126
|
if (readUrl && readUrl !== pgConfig.connectionString) try {
|
|
13761
|
-
const { createReadReplicaConnection } = await import("./connection-
|
|
14127
|
+
const { createReadReplicaConnection } = await import("./connection-GOKU3Hu5.js").then((n) => n.n);
|
|
13762
14128
|
readDb = createReadReplicaConnection(readUrl, mergedSchema).db;
|
|
13763
14129
|
logger.info("📖 [PostgresBootstrapper] Read replica connection established");
|
|
13764
14130
|
} catch (err) {
|
|
@@ -14001,7 +14367,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14001
14367
|
* proved it can bootstrap with.
|
|
14002
14368
|
*/
|
|
14003
14369
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
14004
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
14370
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-CvW6tbI7.js").then((n) => n.t);
|
|
14005
14371
|
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
14006
14372
|
for (const failure of plan.failures) logger.warn(failure.kind === "comment-column" ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, but a later change to the \`search\` block will not be detected: ${failure.error}` : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
|
|
14007
14373
|
return { applied: plan.actions.length - plan.failures.length };
|
|
@@ -14022,7 +14388,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14022
14388
|
* stays RLS-enabled, so it denies rather than leaks.
|
|
14023
14389
|
*/
|
|
14024
14390
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
14025
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
14391
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-B01cv9UC.js");
|
|
14026
14392
|
const queryable = provisioningQueryable(driverResult);
|
|
14027
14393
|
const outcome = await ensureCollectionPolicies(queryable, collections, log);
|
|
14028
14394
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
@@ -14031,7 +14397,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14031
14397
|
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.`);
|
|
14032
14398
|
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.`);
|
|
14033
14399
|
try {
|
|
14034
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
14400
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-DAwWHs81.js").then((n) => n.n);
|
|
14035
14401
|
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
14036
14402
|
info: (m) => logger.info(m),
|
|
14037
14403
|
warn: (m) => logger.warn(m)
|
|
@@ -14065,7 +14431,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14065
14431
|
},
|
|
14066
14432
|
mountRoutes(app, basePath, driverResult) {},
|
|
14067
14433
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
14068
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
14434
|
+
const { createPostgresWebSocket } = await import("./websocket-7Dp77lTh.js").then((n) => n.i);
|
|
14069
14435
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
14070
14436
|
}
|
|
14071
14437
|
};
|
|
@@ -14107,6 +14473,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
14107
14473
|
};
|
|
14108
14474
|
}
|
|
14109
14475
|
//#endregion
|
|
14110
|
-
export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectToolMajor, diagnoseRowSecurityDumpFailure, discardPartialDump, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
14476
|
+
export { ADMIN_ONLY_TYPES, AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PUBLIC_TYPES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, cappedPoolMax, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectToolMajor, diagnoseRowSecurityDumpFailure, discardPartialDump, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, poolMaxCeiling, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
|
|
14111
14477
|
|
|
14112
14478
|
//# sourceMappingURL=index.es.js.map
|