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

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