@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g041c925

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