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