@rebasepro/server 0.25.1 → 0.26.0

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 (157) hide show
  1. package/dist/{GCSStorageController-CRd9bFrc.js → GCSStorageController-40FyFeSx.js} +6 -2
  2. package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
  3. package/dist/{S3StorageController-D00EFa5P.js → S3StorageController-Bl5shp94.js} +6 -2
  4. package/dist/S3StorageController-Bl5shp94.js.map +1 -0
  5. package/dist/access-BFAUvsSs.js +1897 -0
  6. package/dist/access-BFAUvsSs.js.map +1 -0
  7. package/dist/api/branch-routes.d.ts +25 -0
  8. package/dist/api/live-schema-routes.d.ts +10 -0
  9. package/dist/api/rest/api-generator.d.ts +9 -1
  10. package/dist/api/rest/idempotency.d.ts +15 -1
  11. package/dist/api/root-error-handler.d.ts +6 -5
  12. package/dist/api/types.d.ts +12 -0
  13. package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
  14. package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
  15. package/dist/auth/access.d.ts +10 -0
  16. package/dist/auth/adapter-middleware.d.ts +2 -1
  17. package/dist/auth/adapter-service-key.d.ts +24 -0
  18. package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
  19. package/dist/auth/api-keys/api-key-store.d.ts +8 -1
  20. package/dist/auth/api-keys/key-grant.d.ts +18 -1
  21. package/dist/auth/expired-token-sweep.d.ts +5 -0
  22. package/dist/auth/impersonation.d.ts +3 -1
  23. package/dist/auth/jwt.d.ts +8 -2
  24. package/dist/auth/oauth-signin-policy.d.ts +21 -35
  25. package/dist/auth/rls-scope.d.ts +17 -7
  26. package/dist/auth/rls-simulator-routes.d.ts +51 -0
  27. package/dist/auth/simulation-read-only.d.ts +28 -0
  28. package/dist/auth/simulation.d.ts +141 -0
  29. package/dist/auth/sql-rate-limit-store.d.ts +15 -1
  30. package/dist/{auth-Dkvo2tTJ.js → auth-izI7AEzC.js} +370 -1880
  31. package/dist/auth-izI7AEzC.js.map +1 -0
  32. package/dist/auth_adapter-B7RUMk6e.js +89 -0
  33. package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
  34. package/dist/backend/create-backend.d.ts +6 -0
  35. package/dist/backend/options.d.ts +9 -4
  36. package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
  37. package/dist/backend-CaG_Jyso.js.map +1 -0
  38. package/dist/{backup-B9m3cb3X.js → backup-BVbHoKo8.js} +6 -6
  39. package/dist/{backup-B9m3cb3X.js.map → backup-BVbHoKo8.js.map} +1 -1
  40. package/dist/boot/boot.d.ts +2 -15
  41. package/dist/boot/ddl-bootstrap.d.ts +34 -0
  42. package/dist/boot/env.d.ts +6 -0
  43. package/dist/boot/fetch-bundle.d.ts +5 -1
  44. package/dist/boot/provision-only.d.ts +23 -0
  45. package/dist/boot/provision.d.ts +53 -2
  46. package/dist/boot/role.d.ts +2 -0
  47. package/dist/boot/schema-change-recorder.d.ts +45 -0
  48. package/dist/boot/security-headers.d.ts +22 -0
  49. package/dist/branch-routes-B2vvEx53.js +84 -0
  50. package/dist/branch-routes-B2vvEx53.js.map +1 -0
  51. package/dist/{contract-routes-CsFXW7j4.js → contract-routes-CU2A3JnF.js} +2 -2
  52. package/dist/{contract-routes-CsFXW7j4.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
  53. package/dist/cron/cron-store.d.ts +8 -1
  54. package/dist/{cron-loader-Dj6lA4HQ.js → cron-loader-mMhqbVsl.js} +2 -2
  55. package/dist/{cron-loader-Dj6lA4HQ.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
  56. package/dist/{cron-routes-CwbLZguO.js → cron-routes-Cs33j_Kr.js} +3 -3
  57. package/dist/{cron-routes-CwbLZguO.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
  58. package/dist/{cron-scheduler-B04ACoUE.js → cron-scheduler-Be7CNghL.js} +2 -2
  59. package/dist/{cron-scheduler-B04ACoUE.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
  60. package/dist/{cron-store-DkyfuLOH.js → cron-store-DLn11M8H.js} +10 -9
  61. package/dist/cron-store-DLn11M8H.js.map +1 -0
  62. package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
  63. package/dist/data_driver-D0mg1Y_x.js.map +1 -0
  64. package/dist/{ddl-bootstrap-Ck20GSIX.js → ddl-bootstrap-CjRrn12l.js} +91 -2
  65. package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
  66. package/dist/deploy/pod-contract.d.ts +1 -1
  67. package/dist/email/dev-sink.d.ts +6 -0
  68. package/dist/email/index.d.ts +1 -1
  69. package/dist/{soft-delete-params-BmlVwecC.js → field-access-DYsvjI_P.js} +27 -57
  70. package/dist/field-access-DYsvjI_P.js.map +1 -0
  71. package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
  72. package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
  73. package/dist/{function-loader-KKDqTcIr.js → function-loader-B8YzXEsQ.js} +21 -14
  74. package/dist/function-loader-B8YzXEsQ.js.map +1 -0
  75. package/dist/functions/hono-like.d.ts +12 -0
  76. package/dist/functions/index.js +19 -1
  77. package/dist/functions/index.js.map +1 -1
  78. package/dist/functions/request-timeout.d.ts +3 -3
  79. package/dist/history/history-routes.d.ts +6 -0
  80. package/dist/history/retention-sweep.d.ts +5 -0
  81. package/dist/{history-recorder-B2rkuhuy.js → history-recorder-Bsau7h4X.js} +6 -6
  82. package/dist/history-recorder-Bsau7h4X.js.map +1 -0
  83. package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
  84. package/dist/history-store-BQz5YCkW.js.map +1 -0
  85. package/dist/index.d.ts +3 -1
  86. package/dist/index.es.js +2449 -543
  87. package/dist/index.es.js.map +1 -1
  88. package/dist/init/process-safety.d.ts +4 -3
  89. package/dist/init/shutdown.d.ts +8 -0
  90. package/dist/init.d.ts +6 -1
  91. package/dist/jobs/job-store.d.ts +8 -1
  92. package/dist/{jobs-h1WRq3cp.js → jobs-Co6Ni5HP.js} +6 -6
  93. package/dist/jobs-Co6Ni5HP.js.map +1 -0
  94. package/dist/{jwt-D3oFauZI.js → jwt-rDS0884_.js} +4 -3
  95. package/dist/{jwt-D3oFauZI.js.map → jwt-rDS0884_.js.map} +1 -1
  96. package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
  97. package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
  98. package/dist/mcp/consent-page.d.ts +38 -1
  99. package/dist/mcp/mcp-routes.d.ts +9 -0
  100. package/dist/mcp/mcp-tools.d.ts +7 -1
  101. package/dist/mcp/oauth-store.d.ts +12 -5
  102. package/dist/metrics/history-recorder.d.ts +4 -1
  103. package/dist/metrics/history-store.d.ts +8 -0
  104. package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
  105. package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
  106. package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
  107. package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
  108. package/dist/{openapi-generator-BjHr6upl.js → openapi-generator-CyxEtYEX.js} +61 -22
  109. package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
  110. package/dist/{proxy-CH8Jsovg.js → proxy-BYir8tWK.js} +4 -6
  111. package/dist/proxy-BYir8tWK.js.map +1 -0
  112. package/dist/{query-parser-otFZqJsC.js → query-parser-CDXWf2hH.js} +6 -5
  113. package/dist/{query-parser-otFZqJsC.js.map → query-parser-CDXWf2hH.js.map} +1 -1
  114. package/dist/{request-timeout-59crS-9u.js → request-timeout-DVjo-c_8.js} +4 -4
  115. package/dist/request-timeout-DVjo-c_8.js.map +1 -0
  116. package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
  117. package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
  118. package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
  119. package/dist/{schema-editor-routes-Bsut0bCC.js → schema-editor-routes-Dc3DU_jm.js} +3 -3
  120. package/dist/{schema-editor-routes-Bsut0bCC.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
  121. package/dist/soft-delete-params-BRONCOA2.js +59 -0
  122. package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
  123. package/dist/{sql-export-routes-DmB_uZ4l.js → sql-export-routes-QycX8W8S.js} +38 -4
  124. package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
  125. package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
  126. package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
  127. package/dist/storage/GCSStorageController.d.ts +2 -0
  128. package/dist/storage/LocalStorageController.d.ts +8 -3
  129. package/dist/storage/S3StorageController.d.ts +2 -0
  130. package/dist/storage/maintenance-keys.d.ts +17 -0
  131. package/dist/storage/maintenance-routes.d.ts +28 -5
  132. package/dist/storage/maintenance.d.ts +136 -4
  133. package/dist/storage/orphan-scans.d.ts +120 -0
  134. package/dist/storage/types.d.ts +11 -0
  135. package/dist/strings.es-B7QLnHh2.js +96 -0
  136. package/dist/strings.es-B7QLnHh2.js.map +1 -0
  137. package/dist/upload-validation-Bgo626uY.js.map +1 -1
  138. package/package.json +5 -5
  139. package/dist/GCSStorageController-CRd9bFrc.js.map +0 -1
  140. package/dist/S3StorageController-D00EFa5P.js.map +0 -1
  141. package/dist/auth-Dkvo2tTJ.js.map +0 -1
  142. package/dist/auth_adapter-CIItCTc8.js +0 -21
  143. package/dist/auth_adapter-CIItCTc8.js.map +0 -1
  144. package/dist/backend-DTAOsLQc.js.map +0 -1
  145. package/dist/cron-store-DkyfuLOH.js.map +0 -1
  146. package/dist/ddl-bootstrap-Ck20GSIX.js.map +0 -1
  147. package/dist/function-loader-KKDqTcIr.js.map +0 -1
  148. package/dist/history-recorder-B2rkuhuy.js.map +0 -1
  149. package/dist/history-store-DPofGVDp.js.map +0 -1
  150. package/dist/jobs-h1WRq3cp.js.map +0 -1
  151. package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
  152. package/dist/openapi-generator-BjHr6upl.js.map +0 -1
  153. package/dist/proxy-CH8Jsovg.js.map +0 -1
  154. package/dist/request-timeout-59crS-9u.js.map +0 -1
  155. package/dist/soft-delete-params-BmlVwecC.js.map +0 -1
  156. package/dist/sql-export-routes-DmB_uZ4l.js.map +0 -1
  157. package/dist/strings.es-BeZWAodn.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -3,43 +3,47 @@ import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
5
  import { r as __require, t as __commonJSMin } from "./rolldown-runtime-dW7B1o5h.js";
6
- import { a as MAX_LIST_LIMIT, c as RebaseClientError, l as isUnsupported, n as suggestNearMiss, r as toSnakeCase, s as RebaseApiError, u as unsupportedMethod } from "./strings.es-BeZWAodn.js";
7
- import { C as isFieldOperation, T as EntityRelation, _ as normalizeOrderBy, b as BATCH_REF_KEY, c as collectAllPages, g as OrderBySpecError, l as paginateFind, m as normalizeInclude, p as mergeIncludeSpecs, r as deserializeFilter, t as RESERVED_QUERY_KEYS, u as resolveFindWindow, v as serializeOrderBy, x as FIELD_OPERATORS, y as withRelationReferencesOnly } from "./filter-dialect-O1HK6Nvr.js";
8
- import { A as DEFAULT_RESOURCE_KEY, C as declaredSubscriptions, D as setTopicRuntime, E as setQueueRuntime, F as resourceKind, G as DEFAULT_DATA_SOURCE_KEY, H as getDeclaredSubcollections, I as resourceKinds, K as getDataSourceCapabilities, L as RLS_JWT_SQL, M as resolveResourceRefs, N as resourceEnvSuffix, O as DEFAULT_STORAGE_SOURCE_KEY, P as resourceKeyOf, R as RLS_ROLES_SQL, S as declaredQueueConsumers, T as resourceToStorageSource, U as isPostgresCollectionConfig, V as getCollectionDataPath, _ as scopesForRoles, c as intersectScopes, et as sortKeyToString, h as scopeGrantsAny, j as buildResourceGraph, k as storageEnvSuffix, m as scopeGrants, p as parseScope, q as ALL_WHERE_FILTER_OPS, s as hasAdminRole, w as resourceToDataSource, y as summarizeScopes, z as RLS_UID_SQL } from "./src-D5Y3N-S4.js";
6
+ import { a as RebaseClientError, i as RebaseApiError, n as MAX_LIST_LIMIT, o as isUnsupported, s as unsupportedMethod } from "./data_driver-D0mg1Y_x.js";
7
+ import { C as isFieldOperation, T as EntityRelation, _ as normalizeOrderBy, b as BATCH_REF_KEY, c as collectAllPages, g as OrderBySpecError, l as paginateFind, m as normalizeInclude, p as mergeIncludeSpecs, r as deserializeFilter, t as RESERVED_QUERY_KEYS, u as resolveFindWindow, v as serializeOrderBy, x as FIELD_OPERATORS, y as withRelationReferencesOnly } from "./filter-dialect-TrKFWLqT.js";
8
+ import { A as storageEnvSuffix, B as RLS_UID_SQL, C as declaredQueueConsumers, D as setQueueRuntime, E as resourceToStorageSource, F as resourceKeyOf, H as getCollectionDataPath, I as resourceKind, J as ALL_WHERE_FILTER_OPS, K as DEFAULT_DATA_SOURCE_KEY, L as resourceKinds, M as buildResourceGraph, N as resolveResourceRefs, O as setTopicRuntime, P as resourceEnvSuffix, R as RLS_JWT_SQL, T as resourceToDataSource, U as getDeclaredSubcollections, W as isPostgresCollectionConfig, b as summarizeScopes, c as intersectScopes, g as scopeGrantsAny, h as scopeGrants, j as DEFAULT_RESOURCE_KEY, k as DEFAULT_STORAGE_SOURCE_KEY, p as parseScope, q as getDataSourceCapabilities, s as hasAdminRole, tt as sortKeyToString, v as scopesForRoles, w as declaredSubscriptions, z as RLS_ROLES_SQL } from "./src-Bh9LRPb9.js";
9
9
  import { a as nestCollectionPatchPaths, c as ADMIN_PROPERTY_KEYS, i as isCollectionPatch, n as collectionPatchProblems, r as diffCollections, s as ADMIN_COLLECTION_KEYS, t as applyCollectionPatch } from "./collection_patch-BRu-BvDv.js";
10
- import { a as softDeleteFieldOf, c as JUNCTION_PIVOT_KEY, i as restoresSoftDeletedRow, l as hasForeignKeyOnTarget, n as buildCollectionSchema, o as buildTenantSecurityRule, s as INCLUDE_COUNT_KEY, t as buildCollectionInputSchema, u as isManyToMany } from "./openapi-generator-BjHr6upl.js";
10
+ import { a as softDeleteFieldOf, c as JUNCTION_PIVOT_KEY, i as restoresSoftDeletedRow, l as hasForeignKeyOnTarget, n as buildCollectionSchema, o as buildTenantSecurityRule, s as INCLUDE_COUNT_KEY, t as buildCollectionInputSchema, u as isManyToMany } from "./openapi-generator-CyxEtYEX.js";
11
11
  import { n as isAnonymousUid, r as policy } from "./policy-PdzX-Opd.js";
12
- import { n as isSchemaEditingAdmin, t as isSQLAdmin } from "./backend-DTAOsLQc.js";
13
- import { a as parseEnvBoolean, i as validateCronExpression, t as CronScheduler } from "./cron-scheduler-B04ACoUE.js";
12
+ import { n as isSQLAdmin, r as isSchemaEditingAdmin } from "./backend-CaG_Jyso.js";
13
+ import { a as parseEnvBoolean, i as validateCronExpression, t as CronScheduler } from "./cron-scheduler-Be7CNghL.js";
14
+ import { s as simulationHeaders } from "./auth_adapter-B7RUMk6e.js";
14
15
  import { n as isValidAppHost, r as reservedPrefixFor, t as SCHEMA_VERSION_HEADER } from "./project_manifest-R6y_KHms.js";
15
- import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-CsFXW7j4.js";
16
- import { $ as optionalAuth, $t as raw, A as createAuthLimiters, At as setDefaultAccessControl, B as registerDevEmailSink, Bt as resolveEmailLinkBase, C as createPersonalKeyRoutes, Ct as configureAccess, D as configureUserPicking, Dt as processAccessControl, E as createBuiltinAuthAdapter, Et as keyOwnerResolverFor, F as MemoryRateLimitStore, Ft as supportsRlsScoping, G as validatePasswordStrength, Gt as getMagicLinkTemplate, H as createEmailService, Ht as getEmailChangeTemplate, I as activeDevEmailSink, It as isAccessTokenRevoked, J as createAdapterAuthMiddleware, Jt as getWelcomeEmailTemplate, K as verifyPassword, Kt as getPasswordResetTemplate, L as clearActiveDevEmailSink, Lt as completeUserCreation, M as createDataRateLimiter, Mt as safeCompare, N as createRateLimiter, Nt as SERVICE_IDENTITY, O as isBootstrapWindowOpen, Ot as requireScope, P as setSharedRateLimitStore, Pt as scopeDataDriver, Q as fileTokenAuth, Qt as html, R as createDevEmailSink, Rt as generateSecurePassword, S as createApiKeyRoutes, St as callerScopes, T as createJwksRoutes, Tt as hasScope, U as resolveAuthHooks, Ut as getEmailOtpTemplate, V as SMTPEmailService, Vt as getEmailChangeNoticeTemplate, W as hashPassword, Wt as getEmailVerificationTemplate, X as createRequireAuth, Xt as RawHtml, Y as createAuthMiddleware, Yt as resolveEmailBranding, Z as extractUserFromToken, Zt as escapeHtml, _ as oauthCodeFlowSchema, _t as isApiKeyToken, a as createBitbucketProvider, an as storageObjectPath, at as userFromToken, b as resolveRateLimitStoreKind, bt as accessModelFromCollections, c as createTwitterProvider, ct as isPublicObjectGrant, d as createMicrosoftProvider, dt as storageRequestWildcard, en as httpMethodToOperation, et as publicObjectAuth, f as tryVerifyOidcIdToken, ft as decideImpersonation, g as createGoogleProvider, gt as createTusScopeGuard, h as createLinkedinProvider, ht as createFunctionScopeGuard, i as createSlackProvider, in as isPublicStoragePath, it as requireAuth, j as createDataRateLimitCheck, jt as extractBearerToken, k as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, kt as requireScopeByMethod, l as createFacebookProvider, lt as requestedStorageObject, m as createGitHubProvider, mt as createApiKeyPreAuth, n as createCustomAuthAdapter, nn as PUBLIC_STORAGE_PREFIX, nt as refuse$1, o as createGitLabProvider, ot as PUBLIC_OBJECT_PRINCIPAL, p as verifyOidcIdToken, pt as refuseUnhonouredImpersonation, q as adapterRefusal, qt as getUserInvitationTemplate, r as createSpotifyProvider, rn as isPublicStorageKey, rt as requireAdmin, s as createDiscordProvider, st as isDownloadTokenGrant, tn as securityRuleToConditions, tt as queryTokenAuth, u as createAppleProvider, ut as storageObjectOfPath, v as pkceTokenParams, vt as resolveApiKey, w as createApiKeyStore, wt as getAccessModel, x as createSqlRateLimitStore, xt as accessOf, y as providerVerifiedEmail, yt as validateApiKey, z as extractLinks, zt as assertEmailLinkBases } from "./auth-Dkvo2tTJ.js";
16
+ import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-CU2A3JnF.js";
17
+ import { A as isPublicObjectGrant, C as queryTokenAuth, D as userFromToken, E as requireAuth, F as decideImpersonation, G as isApiKeyToken, H as createApiKeyPreAuth, I as refuseUnhonouredImpersonation, J as extractBearerToken, K as resolveApiKey, M as storageObjectOfPath, N as storageRequestWildcard, O as PUBLIC_OBJECT_PRINCIPAL, Q as supportsRlsScoping, S as publicObjectAuth, T as requireAdmin, U as createFunctionScopeGuard, V as reportSimulatedContext, W as createTusScopeGuard, X as SERVICE_IDENTITY, Y as safeCompare, Z as scopeDataDriver, _ as createAuthMiddleware, at as httpMethodToOperation, b as fileTokenAuth, c as configureAccess, ct as isPublicStoragePath, d as keyOwnerResolverFor, et as isAccessTokenRevoked, f as processAccessControl, g as setDefaultAccessControl, h as requireScopeByMethod, it as refuseSimulatedWrite, j as requestedStorageObject, k as isDownloadTokenGrant, l as getAccessModel, lt as storageObjectPath, m as requireScope, n as accessModelFromCollections, ot as PUBLIC_STORAGE_PREFIX, q as validateApiKey, r as accessOf, s as callerScopes, st as isPublicStorageKey, u as hasScope, v as createRequireAuth, w as refuse$1, x as optionalAuth, y as extractUserFromToken } from "./access-BFAUvsSs.js";
17
18
  import { a as logger, c as setLogLevel, d as hostEnv, i as logLevelEnabled, l as setLogOutput, o as rawQueryLoggingEnabled, r as describeCauseChain, s as redactSensitiveText, t as _setLogLineHook } from "./logger-CW8pmTS6.js";
18
- import { a as apiHiddenFieldNames, c as fieldKeyForColumn, d as isJunctionBackedRelation, g as toWireKey, h as resolveRelation, l as findRelation, m as resolveCollectionRelations, n as HARD_DELETE_QUERY_PARAM, p as relationDeclaringProperty, r as parseHardDelete, s as restrictedFieldNames, u as getTableName } from "./soft-delete-params-BmlVwecC.js";
19
- import { c as createTransport, d as parseRebaseJson, h as unref, l as defaultCodeForStatus, m as withETag, o as createCollectionClient, p as etagOf, r as SDKQueryBuilder, u as invalidBulkBody } from "./offline-store-BhYXViha-BW5j1We_.js";
19
+ import { n as suggestNearMiss, r as toSnakeCase } from "./strings.es-B7QLnHh2.js";
20
+ import { a as findRelation, d as resolveRelation, f as toWireKey, i as fieldKeyForColumn, l as relationDeclaringProperty, o as getTableName, r as restrictedFieldNames, s as isJunctionBackedRelation, t as apiHiddenFieldNames, u as resolveCollectionRelations } from "./field-access-DYsvjI_P.js";
21
+ import { c as createTransport, d as parseRebaseJson, h as unref, l as defaultCodeForStatus, m as withETag, o as createCollectionClient, p as etagOf, r as SDKQueryBuilder, u as invalidBulkBody } from "./offline-store-BhYXViha-YTmjBpo2.js";
22
+ import { $ as getEmailChangeTemplate, A as createDataRateLimiter, B as SMTPEmailService, C as createJwksRoutes, D as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, E as isBootstrapWindowOpen, F as clearActiveDevEmailSink, G as verifyPassword, H as resolveAuthHooks, I as createDevEmailSink, J as completeUserCreation, K as adapterRefusal, L as devEmailSinkFor, M as setSharedRateLimitStore, N as MemoryRateLimitStore, O as createAuthLimiters, P as activeDevEmailSink, Q as getEmailChangeNoticeTemplate, R as extractLinks, S as createApiKeyStore, T as configureUserPicking, U as hashPassword, V as createEmailService, W as validatePasswordStrength, X as assertEmailLinkBases, Y as generateSecurePassword, Z as resolveEmailLinkBase, _ as oauthCodeFlowSchema, a as createBitbucketProvider, at as getWelcomeEmailTemplate, b as createApiKeyRoutes, c as createTwitterProvider, ct as escapeHtml, d as createMicrosoftProvider, dt as createSqlRateLimitStore, et as getEmailOtpTemplate, f as tryVerifyOidcIdToken, ft as resolveRateLimitStoreKind, g as createGoogleProvider, h as createLinkedinProvider, i as createSlackProvider, it as getUserInvitationTemplate, j as createRateLimiter, k as createDataRateLimitCheck, l as createFacebookProvider, lt as html, m as createGitHubProvider, n as createCustomAuthAdapter, nt as getMagicLinkTemplate, o as createGitLabProvider, ot as resolveEmailBranding, p as verifyOidcIdToken, pt as securityRuleToConditions, q as createAdapterAuthMiddleware, r as createSpotifyProvider, rt as getPasswordResetTemplate, s as createDiscordProvider, st as RawHtml, tt as getEmailVerificationTemplate, u as createAppleProvider, ut as raw, v as pkceTokenParams, w as createBuiltinAuthAdapter, x as createPersonalKeyRoutes, y as providerVerifiedEmail, z as registerDevEmailSink } from "./auth-izI7AEzC.js";
20
23
  import "./resumable-upload-threshold-CKv-Ry0l.js";
21
24
  import { n as revokeInternalTableSql } from "./internal-tables-L2t39RdT.js";
22
25
  import { i as warnDeprecated } from "./load-failure-L5hvfDoG.js";
23
- import { a as resolveListLimitParam, c as assertReadableFields, d as decodeCursor$1, f as reconcileCursorOrder, i as parseQueryOptions, l as requestViewer, n as parseAggregateSelect, o as assertNoHiddenAggregateNames, r as parseGroupBy, s as assertQueryFieldsReadable, t as orderByEntriesToTuples, u as cursorToStartAfter } from "./query-parser-otFZqJsC.js";
26
+ import { a as resolveListLimitParam, c as assertReadableFields, d as decodeCursor$1, f as reconcileCursorOrder, i as parseQueryOptions, l as requestViewer, n as parseAggregateSelect, o as assertNoHiddenAggregateNames, r as parseGroupBy, s as assertQueryFieldsReadable, t as orderByEntriesToTuples, u as cursorToStartAfter } from "./query-parser-CDXWf2hH.js";
27
+ import { C as randomHex, E as sha256Hex, S as constantTimeEqual, T as sha256Bytes, b as normalizePemFromEnv, d as hasAsymmetricSigningKey, g as setDefaultJwtService, h as processJwtService, l as getJwks, m as jwtOf, n as configureJwt, r as createJwtService, y as verifyJwt } from "./jwt-rDS0884_.js";
28
+ import { a as isConcurrentDdlRace, c as provisioningExec, l as withoutLeadingComments, o as isDuplicateObjectRace, r as createDdlBootstrapper, s as isSchemaChange, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-CjRrn12l.js";
29
+ import { t as createCronStore } from "./cron-store-DLn11M8H.js";
24
30
  import { a as isDataException, i as errorHandler, n as DATABASE_UNAVAILABLE_MESSAGE, o as isDatabaseUnreachable, r as declaredErrorAnswer, s as schemaDriftRemedy, t as ApiError } from "./errors-OMWkKSH-.js";
25
- import { C as randomHex, E as sha256Hex, S as constantTimeEqual, T as sha256Bytes, b as normalizePemFromEnv, d as hasAsymmetricSigningKey, g as setDefaultJwtService, h as processJwtService, l as getJwks, m as jwtOf, n as configureJwt, r as createJwtService, y as verifyJwt } from "./jwt-D3oFauZI.js";
26
- import { a as canonicalStorageId, i as canonicalStorageBucket, n as InvalidStorageBucketError, o as canonicalStorageKey, r as InvalidStorageKeyError, t as InvalidListOptionsError } from "./keys-COzQ2aE8.js";
31
+ import { n as HARD_DELETE_QUERY_PARAM, r as parseHardDelete } from "./soft-delete-params-BRONCOA2.js";
32
+ import { a as canonicalStorageId, i as canonicalStorageBucket, n as InvalidStorageBucketError, o as canonicalStorageKey, r as InvalidStorageKeyError, t as InvalidListOptionsError } from "./keys-BXcdRNhJ.js";
27
33
  import { t as logMiddleware } from "./logs-routes-CBt7mMIL.js";
28
34
  import { a as readStreamFully, c as DOCUMENT_MIME_TYPES, i as fileWebStream, l as IMAGE_MIME_TYPES, r as bytesView, s as DEFAULT_MAX_FILE_SIZE, t as validateUploadedFile } from "./upload-validation-Bgo626uY.js";
29
- import { t as S3StorageController } from "./S3StorageController-D00EFa5P.js";
30
- import { t as GCSStorageController } from "./GCSStorageController-CRd9bFrc.js";
31
- import { a as isDuplicateObjectRace, i as isConcurrentDdlRace, n as createDdlBootstrapper, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-Ck20GSIX.js";
32
- import { t as createCronStore } from "./cron-store-DkyfuLOH.js";
33
- import { i as loadFunctionsWithDiagnostics, n as loadFunctions, r as loadFunctionsFromDirectory } from "./function-loader-KKDqTcIr.js";
35
+ import { t as S3StorageController } from "./S3StorageController-Bl5shp94.js";
36
+ import { t as GCSStorageController } from "./GCSStorageController-40FyFeSx.js";
37
+ import { a as isHonoLike, i as loadFunctionsWithDiagnostics, n as loadFunctions, r as loadFunctionsFromDirectory } from "./function-loader-B8YzXEsQ.js";
34
38
  import { a as getDriver, c as getUser, d as identityResolved, f as isAdmin, i as getApiKey, l as getUserId, m as requireDriver, o as getRequestId, p as isAuthenticated, r as requireRole, s as getRoles, t as createFunctionRoutes, u as hasRole } from "./function-routes-RxrCHNuM.js";
35
- import "./proxy-CH8Jsovg.js";
36
- import "./request-timeout-59crS-9u.js";
39
+ import "./proxy-BYir8tWK.js";
40
+ import "./request-timeout-DVjo-c_8.js";
37
41
  import { t as FunctionSelectionError } from "./selection-CRpqKUbt.js";
38
- import { a as loadCrons, i as loadCronJobsWithDiagnostics, n as declareCrons, r as loadCronJobsFromDirectory } from "./cron-loader-Dj6lA4HQ.js";
39
- import { t as createCronRoutes } from "./cron-routes-CwbLZguO.js";
40
- import { a as listBackupObjects, c as readBackupBytes, i as backupStorageFromEnv, n as createBackupLinkRoutes, o as parseBackupDestination, r as createBackupRoutes, s as parseBackupTimestamp } from "./backup-B9m3cb3X.js";
41
- import { a as defaultBackoff, i as createJobQueue, n as createJobStore, o as currentAmbientTransaction, r as PermanentJobError } from "./jobs-h1WRq3cp.js";
42
- import { t as METRIC_SERIES } from "./history-store-DPofGVDp.js";
42
+ import { a as loadCrons, i as loadCronJobsWithDiagnostics, n as declareCrons, r as loadCronJobsFromDirectory } from "./cron-loader-mMhqbVsl.js";
43
+ import { t as createCronRoutes } from "./cron-routes-Cs33j_Kr.js";
44
+ import { a as listBackupObjects, c as readBackupBytes, i as backupStorageFromEnv, n as createBackupLinkRoutes, o as parseBackupDestination, r as createBackupRoutes, s as parseBackupTimestamp } from "./backup-BVbHoKo8.js";
45
+ import { a as defaultBackoff, i as createJobQueue, n as createJobStore, o as currentAmbientTransaction, r as PermanentJobError } from "./jobs-Co6Ni5HP.js";
46
+ import { t as METRIC_SERIES } from "./history-store-BQz5YCkW.js";
43
47
  import { createHash, createSign, randomBytes } from "node:crypto";
44
48
  import * as path$2 from "node:path";
45
49
  import path from "node:path";
@@ -302,9 +306,10 @@ var HANDLER_SLOT = Symbol.for("@rebasepro/server:unhandled-rejection-handler");
302
306
  * request path is covered — but a fire-and-forget `void syncToCrm(body)`, a
303
307
  * `setTimeout(async () => …)`, or an unawaited `.then()` in one custom function
304
308
  * rejects outside Hono's frame. The default outcome is that one project's
305
- * floating promise ends the process; on the managed runtime that process is
306
- * shared, so every other tenant on the pod goes with it, and the request log
307
- * points nowhere because the request itself succeeded.
309
+ * floating promise ends the process, and with it every other route the
310
+ * project serves — its data API, auth, realtime — and the request log points
311
+ * nowhere because the request itself succeeded. On the managed runtime a
312
+ * project usually has one instance, so that is an outage until it restarts.
308
313
  *
309
314
  * A logged rejection is the better trade here: the process keeps serving, and
310
315
  * the stack reaches the operator. `uncaughtException` is deliberately **not**
@@ -1714,6 +1719,75 @@ async function afterBurst() {
1714
1719
  for (let turn = 0; turn < BURST_TURNS; turn++) await void 0;
1715
1720
  }
1716
1721
  //#endregion
1722
+ //#region ../common/src/util/callbacks.ts
1723
+ /**
1724
+ * Helper function to recursively check if there are any callbacks in the properties.
1725
+ */
1726
+ function hasPropertyCallbacks(properties, callbackName) {
1727
+ if (!properties) return false;
1728
+ for (const property of Object.values(properties)) {
1729
+ if (property.callbacks?.[callbackName]) return true;
1730
+ if (property.type === "map" && property.properties) {
1731
+ if (hasPropertyCallbacks(property.properties, callbackName)) return true;
1732
+ } else if (property.type === "array" && property.of) {
1733
+ const ofs = Array.isArray(property.of) ? property.of : [property.of];
1734
+ for (const of of ofs) {
1735
+ if (of.callbacks?.[callbackName]) return true;
1736
+ if (of.type === "map" && of.properties && hasPropertyCallbacks(of.properties, callbackName)) return true;
1737
+ }
1738
+ }
1739
+ }
1740
+ return false;
1741
+ }
1742
+ /**
1743
+ * Recursively process properties to apply field-level hooks.
1744
+ */
1745
+ async function processProperties(properties, values, previousValues, propsContext, callbackName) {
1746
+ if (!values || typeof values !== "object") return values;
1747
+ const result = { ...values };
1748
+ for (const [key, property] of Object.entries(properties)) {
1749
+ if (result[key] === void 0) continue;
1750
+ let currentValue = result[key];
1751
+ const previousValue = previousValues?.[key];
1752
+ if (property.type === "array" && Array.isArray(currentValue)) {
1753
+ if (property.of && !Array.isArray(property.of)) currentValue = await Promise.all(currentValue.map(async (item, index) => {
1754
+ const prevItem = Array.isArray(previousValue) ? previousValue[index] : void 0;
1755
+ return (await processProperties({ "_tmp": property.of }, { "_tmp": item }, { "_tmp": prevItem }, propsContext, callbackName))["_tmp"];
1756
+ }));
1757
+ } else if (property.type === "map" && property.properties && typeof currentValue === "object") currentValue = await processProperties(property.properties, currentValue, previousValue ?? {}, propsContext, callbackName);
1758
+ if (property.callbacks?.[callbackName]) {
1759
+ const cbRes = await Promise.resolve(property.callbacks[callbackName]({
1760
+ ...propsContext,
1761
+ value: currentValue,
1762
+ previousValue
1763
+ }));
1764
+ if (cbRes !== void 0) currentValue = cbRes;
1765
+ }
1766
+ result[key] = currentValue;
1767
+ }
1768
+ return result;
1769
+ }
1770
+ /**
1771
+ * Helper function to extract field-level PropertyCallbacks from a properties schema
1772
+ * and wrap them into an CollectionCallbacks object recursively.
1773
+ */
1774
+ var buildPropertyCallbacks = (properties) => {
1775
+ if (!properties) return void 0;
1776
+ const propertyCallbacks = {};
1777
+ if (hasPropertyCallbacks(properties, "afterRead")) propertyCallbacks.afterRead = async (props) => {
1778
+ const row = props.row;
1779
+ const processedValues = await processProperties(properties, row, row, props, "afterRead");
1780
+ return {
1781
+ ...props.row,
1782
+ ...processedValues
1783
+ };
1784
+ };
1785
+ if (hasPropertyCallbacks(properties, "beforeSave")) propertyCallbacks.beforeSave = async (props) => {
1786
+ return await processProperties(properties, props.values, props.previousValues ?? {}, props, "beforeSave");
1787
+ };
1788
+ return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
1789
+ };
1790
+ //#endregion
1717
1791
  //#region ../common/src/data/filter-conditions.ts
1718
1792
  /**
1719
1793
  * Read one field's filter as the list of conditions it stands for.
@@ -5847,6 +5921,8 @@ function createAdmin(transport, options) {
5847
5921
  if (options?.search) params.set("search", options.search);
5848
5922
  if (options?.orderBy) params.set("orderBy", options.orderBy);
5849
5923
  if (options?.orderDir) params.set("orderDir", options.orderDir);
5924
+ if (options?.role) params.set("role", options.role);
5925
+ if (options?.ids) params.set("ids", options.ids.join(","));
5850
5926
  const qs = params.toString();
5851
5927
  return transport.request(adminPath + "/users" + (qs ? "?" + qs : ""), { method: "GET" });
5852
5928
  }
@@ -6606,10 +6682,23 @@ function requestTimeoutError(written) {
6606
6682
  * connection has been down for longer than a blip. The registration is kept and
6607
6683
  * resumes when the socket is back.
6608
6684
  */
6609
- function realtimeLostError() {
6685
+ function realtimeLostError(refusal) {
6686
+ if (refusal) return new RebaseApiError(`The server refused this realtime connection (${refusal.code}: ${refusal.message}), so live data is not updating. The client keeps retrying, more slowly each time, and refreshes this subscription once it is let in.`, {
6687
+ code: "CONNECTION_LOST",
6688
+ details: { reason: refusal.code },
6689
+ cause: refusal
6690
+ });
6610
6691
  return new RebaseApiError("The realtime connection is down, so live data is not updating. The client keeps reconnecting and refreshes this subscription when the connection is back.", { code: "CONNECTION_LOST" });
6611
6692
  }
6612
6693
  /**
6694
+ * Whether `error` is the server refusing this socket for its per-user ceiling
6695
+ * (`REALTIME_MAX_SOCKETS_PER_USER`): it answers AUTHENTICATE with
6696
+ * `TOO_MANY_SOCKETS` and closes the socket.
6697
+ */
6698
+ function isSocketCeilingRefusal(error) {
6699
+ return error instanceof RebaseApiError && error.code === "TOO_MANY_SOCKETS";
6700
+ }
6701
+ /**
6613
6702
  * Whether realtime debugging is on: `globalThis.__REBASE_DEBUG_REALTIME__ = true`,
6614
6703
  * set from a console or a test. While it is, each refetched row that differs
6615
6704
  * from the one held is explained key by key with `console.debug`.
@@ -6618,17 +6707,28 @@ function realtimeDebugOn() {
6618
6707
  return Reflect.get(globalThis, "__REBASE_DEBUG_REALTIME__") === true;
6619
6708
  }
6620
6709
  /**
6621
- * Connection chatter: the socket opened, signed in, closed, is redialling.
6622
- *
6623
- * `console.debug` where that is a debug level — a browser's console hides it
6624
- * unless Verbose is asked for. Not in Node, where `console.debug` is
6625
- * `console.log`: a script's `listen()` opened its stdout with "Connected to
6626
- * PostgreSQL backend" and "WebSocket auto-authenticated", ahead of what the
6627
- * script prints. There it is written only while realtime debugging is on.
6710
+ * The `localStorage` key that turns connection logging on for one browser,
6711
+ * without a code change or a rebuild: `localStorage.setItem("rebase_debug", "1")`
6712
+ * in its console. For a page that loads the client as a plain script, where
6713
+ * `createRebaseClient({ debug: true })` means editing and redeploying the site.
6628
6714
  */
6629
- function connectionDebug(...args) {
6630
- if (!(typeof window !== "undefined" || typeof document !== "undefined") && !realtimeDebugOn()) return;
6631
- console.debug(...args);
6715
+ var DEBUG_STORAGE_KEY = "rebase_debug";
6716
+ /**
6717
+ * Whether connection logging was switched on from outside the code:
6718
+ * `globalThis.__REBASE_DEBUG__`, `globalThis.__REBASE_DEBUG_REALTIME__` (which
6719
+ * also explains refetched rows), or {@link DEBUG_STORAGE_KEY} in this
6720
+ * browser's `localStorage`. Read at each line, so it can be switched on in a
6721
+ * console without reloading a page whose socket is already open.
6722
+ */
6723
+ function debugSwitchedOn() {
6724
+ if (Reflect.get(globalThis, "__REBASE_DEBUG__") === true || realtimeDebugOn()) return true;
6725
+ if (typeof window === "undefined" || typeof localStorage === "undefined") return false;
6726
+ try {
6727
+ const value = localStorage.getItem(DEBUG_STORAGE_KEY);
6728
+ return value === "1" || value === "true";
6729
+ } catch {
6730
+ return false;
6731
+ }
6632
6732
  }
6633
6733
  /**
6634
6734
  * The codes an `AUTHENTICATE` is refused with when the server will not honour
@@ -6950,11 +7050,28 @@ var RebaseWebSocketClient = class {
6950
7050
  onUnauthorized;
6951
7051
  refreshInProgress = null;
6952
7052
  impersonate;
7053
+ debug;
7054
+ /**
7055
+ * Connection chatter: the socket opened, signed in, closed, is redialling.
7056
+ *
7057
+ * Silent unless asked for, with the `debug` option or
7058
+ * {@link debugSwitchedOn}. It used to go to `console.debug` in every
7059
+ * browser: a public page printed "Connected to PostgreSQL backend" and
7060
+ * "WebSocket auto-authenticated" to each visitor's console, naming the
7061
+ * backend on every load. In Node, where `console.debug` is `console.log`,
7062
+ * it opened a script's stdout ahead of what the script prints. Warnings and
7063
+ * errors are not chatter and are always written.
7064
+ */
7065
+ connectionDebug(...args) {
7066
+ if (!this.debug && !debugSwitchedOn()) return;
7067
+ console.debug(...args);
7068
+ }
6953
7069
  constructor(config) {
6954
7070
  this.websocketUrl = config.websocketUrl;
6955
7071
  this.getAuthToken = config.getAuthToken;
6956
7072
  this.onUnauthorized = config.onUnauthorized;
6957
7073
  this.impersonate = config.impersonate;
7074
+ this.debug = config.debug === true;
6958
7075
  this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
6959
7076
  }
6960
7077
  /**
@@ -6993,7 +7110,7 @@ var RebaseWebSocketClient = class {
6993
7110
  if (this.closedByCaller) return;
6994
7111
  if (this.connectionState !== "reconnecting" && this.connectionState !== "disconnected") return;
6995
7112
  if (this.ws) return;
6996
- connectionDebug(`${reason} — retrying the realtime connection`);
7113
+ this.connectionDebug(`${reason} — retrying the realtime connection`);
6997
7114
  if (this.reconnectTimeout) {
6998
7115
  clearTimeout(this.reconnectTimeout);
6999
7116
  this.reconnectTimeout = null;
@@ -7043,6 +7160,21 @@ var RebaseWebSocketClient = class {
7043
7160
  }
7044
7161
  return true;
7045
7162
  }
7163
+ /** Whether the current run of ceiling refusals has been said in the console. */
7164
+ socketCeilingWarned = false;
7165
+ /**
7166
+ * The server refused this socket for its per-user ceiling. Said once per
7167
+ * run of refusals, as a warning, and reported to every registration at
7168
+ * once as an outage — a refusal is not a blip that the next dial fixes.
7169
+ */
7170
+ noteSocketCeiling(error) {
7171
+ if (!this.socketCeilingWarned) {
7172
+ this.socketCeilingWarned = true;
7173
+ console.warn(`[Rebase] Realtime: the server refused this connection (${error.code}: ${error.message}). Live data is not updating; the client retries, more slowly each time, until another connection closes.`);
7174
+ }
7175
+ this.clearOutageTimer();
7176
+ this.reportConnectionLost(error);
7177
+ }
7046
7178
  /**
7047
7179
  * Authenticate the WebSocket connection
7048
7180
  */
@@ -7091,10 +7223,10 @@ var RebaseWebSocketClient = class {
7091
7223
  if (this.isConnected && !this.isAuthenticated && !this.authPromise) this.getAuthToken().then((token) => {
7092
7224
  if (!this.ws) return;
7093
7225
  if (token) this.authenticate(token).catch((e) => {
7094
- if (this.ws) connectionDebug("WebSocket auto-auth skipped:", e?.message || e);
7226
+ if (this.ws) this.connectionDebug("WebSocket auto-auth skipped:", e?.message || e);
7095
7227
  });
7096
7228
  }).catch((e) => {
7097
- if (this.ws) connectionDebug("WebSocket auto-auth skipped:", e?.message || e);
7229
+ if (this.ws) this.connectionDebug("WebSocket auto-auth skipped:", e?.message || e);
7098
7230
  });
7099
7231
  }
7100
7232
  /**
@@ -7145,15 +7277,12 @@ var RebaseWebSocketClient = class {
7145
7277
  const socket = new this.WebSocketConstructor(withOptIns(this.websocketUrl));
7146
7278
  this.ws = socket;
7147
7279
  this.ws.onopen = async () => {
7148
- connectionDebug("Connected to PostgreSQL backend");
7280
+ this.connectionDebug("Connected to PostgreSQL backend");
7149
7281
  const wasReconnect = this.hadConnection;
7150
7282
  this.hadConnection = true;
7151
7283
  this.isConnected = true;
7152
- this.reconnectAttempts = 0;
7153
- this.clearOutageTimer();
7154
- this.outageReported = false;
7155
7284
  this.gaveUp = false;
7156
- this.setState("connected");
7285
+ if (!this.outageReported) this.setState("connected");
7157
7286
  this.refusedSession = null;
7158
7287
  if (this.getAuthToken && !this.isAuthenticated) {
7159
7288
  let token = null;
@@ -7161,12 +7290,22 @@ var RebaseWebSocketClient = class {
7161
7290
  token = await this.getAuthToken();
7162
7291
  if (token) {
7163
7292
  await this.authenticate(token);
7164
- connectionDebug("WebSocket auto-authenticated");
7293
+ this.connectionDebug("WebSocket auto-authenticated");
7165
7294
  }
7166
7295
  } catch (error) {
7167
- if (!(token && this.noteSessionRefused(token, error))) connectionDebug("WebSocket connected, not signed in yet:", error?.message || error);
7296
+ if (isSocketCeilingRefusal(error)) {
7297
+ this.noteSocketCeiling(error);
7298
+ return;
7299
+ }
7300
+ if (!(token && this.noteSessionRefused(token, error))) this.connectionDebug("WebSocket connected, not signed in yet:", error?.message || error);
7168
7301
  }
7169
7302
  }
7303
+ if (this.ws !== socket) return;
7304
+ this.reconnectAttempts = 0;
7305
+ this.clearOutageTimer();
7306
+ this.outageReported = false;
7307
+ this.socketCeilingWarned = false;
7308
+ this.setState("connected");
7170
7309
  this.emit(wasReconnect ? "reconnect" : "connect");
7171
7310
  this.processMessageQueue(wasReconnect);
7172
7311
  if (wasReconnect) this.resubscribeAll();
@@ -7182,7 +7321,7 @@ var RebaseWebSocketClient = class {
7182
7321
  }
7183
7322
  };
7184
7323
  const onClosed = () => {
7185
- connectionDebug("Disconnected from PostgreSQL backend");
7324
+ this.connectionDebug("Disconnected from PostgreSQL backend");
7186
7325
  this.stopLivenessWatch();
7187
7326
  this.forgetServerOptIns();
7188
7327
  if (this.ws === socket) this.ws = null;
@@ -7312,7 +7451,7 @@ var RebaseWebSocketClient = class {
7312
7451
  this.reconnectAttempts = Math.min(this.reconnectAttempts + 1, 30);
7313
7452
  const ceiling = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_DELAY_MS);
7314
7453
  const delay = MIN_RECONNECT_DELAY_MS + Math.floor(Math.random() * (ceiling - MIN_RECONNECT_DELAY_MS));
7315
- connectionDebug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
7454
+ this.connectionDebug(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
7316
7455
  if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
7317
7456
  this.reconnectTimeout = setTimeout(() => {
7318
7457
  this.reconnectTimeout = null;
@@ -7338,11 +7477,11 @@ var RebaseWebSocketClient = class {
7338
7477
  if (this.outageTimer) clearTimeout(this.outageTimer);
7339
7478
  this.outageTimer = null;
7340
7479
  }
7341
- reportConnectionLost() {
7480
+ reportConnectionLost(refusal) {
7342
7481
  if (this.outageReported) return;
7343
7482
  this.outageReported = true;
7344
7483
  this.setState("disconnected");
7345
- const error = realtimeLostError();
7484
+ const error = realtimeLostError(refusal);
7346
7485
  for (const subscription of this.collectionSubscriptions.values()) this.notifyError(subscription.callbacks, error);
7347
7486
  for (const subscription of this.singleSubscriptions.values()) this.notifyError(subscription.callbacks, error);
7348
7487
  for (const handler of [...this.connectionLostHandlers]) try {
@@ -7588,7 +7727,7 @@ var RebaseWebSocketClient = class {
7588
7727
  if (!token) throw new Error("user not logged in");
7589
7728
  if (this.refusedSession?.token === token) throw this.refusedSession.error;
7590
7729
  await this.authenticate(token);
7591
- connectionDebug("WebSocket authenticated on demand");
7730
+ this.connectionDebug("WebSocket authenticated on demand");
7592
7731
  return;
7593
7732
  } catch (error) {
7594
7733
  lastError = error;
@@ -7607,7 +7746,7 @@ var RebaseWebSocketClient = class {
7607
7746
  }
7608
7747
  if (attempt < retryCount - 1) {
7609
7748
  const delay = Math.min(1e3 * (attempt + 1), 3e3);
7610
- connectionDebug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
7749
+ this.connectionDebug(`WebSocket auth attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
7611
7750
  await new Promise((resolve) => setTimeout(resolve, delay));
7612
7751
  }
7613
7752
  }
@@ -7622,7 +7761,7 @@ var RebaseWebSocketClient = class {
7622
7761
  const token = await this.getAuthToken();
7623
7762
  if (!token) throw new Error("user not logged in");
7624
7763
  await this.authenticate(token);
7625
- connectionDebug("WebSocket reauthenticated successfully");
7764
+ this.connectionDebug("WebSocket reauthenticated successfully");
7626
7765
  } catch (error) {
7627
7766
  console.error("WebSocket reauthentication failed:", error);
7628
7767
  throw error;
@@ -8312,7 +8451,7 @@ var RebaseWebSocketClient = class {
8312
8451
  * we need to re-register everything to resume receiving updates.
8313
8452
  */
8314
8453
  resubscribeAll() {
8315
- connectionDebug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
8454
+ this.connectionDebug(`[WS] Re-subscribing: ${this.collectionSubscriptions.size} collection(s), ${this.singleSubscriptions.size} row(ies)`);
8316
8455
  for (const [key, sub] of this.collectionSubscriptions.entries()) {
8317
8456
  if (sub.subscribeInFlight) continue;
8318
8457
  const oldBackendId = sub.backendSubscriptionId;
@@ -8600,12 +8739,28 @@ var RebaseRealtimeChannel = class {
8600
8739
  */
8601
8740
  async broadcast(event, payload) {
8602
8741
  await this.join();
8742
+ this.rememberOwnSender(this.transport.socketClientId);
8603
8743
  await this.send("broadcast", {
8604
8744
  event,
8605
8745
  payload
8606
8746
  });
8607
8747
  }
8608
8748
  /**
8749
+ * The socket ids this channel has broadcast from — the current one and the
8750
+ * ones before a reconnect. A message in history from any of them is this
8751
+ * client's own, and a sender is never sent its own messages: replayed after
8752
+ * a reconnect whose `broadcast_ack` was lost, it was applied twice.
8753
+ */
8754
+ ownSenderIds = /* @__PURE__ */ new Set();
8755
+ rememberOwnSender(id) {
8756
+ if (id === void 0 || this.ownSenderIds.has(id)) return;
8757
+ this.ownSenderIds.add(id);
8758
+ if (this.ownSenderIds.size > 16) {
8759
+ const oldest = this.ownSenderIds.values().next().value;
8760
+ if (oldest !== void 0) this.ownSenderIds.delete(oldest);
8761
+ }
8762
+ }
8763
+ /**
8609
8764
  * Observe refusals about this channel.
8610
8765
  *
8611
8766
  * `CHANNEL_FORBIDDEN` when an authorizer refuses a join, a broadcast or a
@@ -8778,7 +8933,7 @@ var RebaseRealtimeChannel = class {
8778
8933
  for (const entry of entries) {
8779
8934
  if (entry.seq <= this.lastSeq) continue;
8780
8935
  this.lastSeq = entry.seq;
8781
- if (ownId !== void 0 && entry.senderId === ownId) continue;
8936
+ if (ownId !== void 0 && (entry.senderId === ownId || entry.senderId !== void 0 && this.ownSenderIds.has(entry.senderId))) continue;
8782
8937
  this.deliver({
8783
8938
  event: entry.event,
8784
8939
  payload: entry.payload,
@@ -8925,7 +9080,7 @@ var LazyOfflineManager = class {
8925
9080
  keepsLastUser;
8926
9081
  constructor(config, createInner) {
8927
9082
  this.keepsLastUser = config.keepLastUser === true;
8928
- import("./offline-BrqCRgih-BtDB1ecy.js").then(({ OfflineManager: Engine }) => {
9083
+ import("./offline-AvuEcg7u-DFHL98OT.js").then(({ OfflineManager: Engine }) => {
8929
9084
  const engine = new Engine(config, createInner);
8930
9085
  this.engine = engine;
8931
9086
  const queued = this.queued;
@@ -9000,6 +9155,9 @@ var LazyOfflineManager = class {
9000
9155
  setScope(uid, options) {
9001
9156
  this.run((engine) => engine.setScope(uid, options)).catch(() => void 0);
9002
9157
  }
9158
+ onReachedUnconfirmed(listener) {
9159
+ return this.subscribe((engine) => engine.onReachedUnconfirmed(listener));
9160
+ }
9003
9161
  async resumeLastUser() {
9004
9162
  await await this.run((engine) => engine.resumeLastUser());
9005
9163
  }
@@ -9155,7 +9313,8 @@ function createRebaseClient(options) {
9155
9313
  websocketUrl: resolvedWsUrl,
9156
9314
  getAuthToken: () => transport.resolveToken(),
9157
9315
  onUnauthorized: () => onUnauthorized(),
9158
- impersonate: options.impersonate
9316
+ impersonate: options.impersonate,
9317
+ debug: options.debug
9159
9318
  });
9160
9319
  let realtimeUid = auth.getSession()?.user?.uid ?? null;
9161
9320
  auth.onAuthStateChange((event, session) => {
@@ -9232,15 +9391,19 @@ function createRebaseClient(options) {
9232
9391
  });
9233
9392
  if (offlineManager.keepsLastUser) {
9234
9393
  let confirming = false;
9235
- offlineManager.api.onStatusChange((status) => {
9236
- if (!status.online || !status.unverifiedUser || confirming || auth.getSession()) return;
9394
+ const confirm = () => {
9395
+ if (confirming || auth.getSession()) return;
9237
9396
  confirming = true;
9238
9397
  auth.refreshSession().catch((error) => {
9239
9398
  if (error instanceof RebaseApiError && error.status !== void 0 && error.status >= 400 && error.status < 500 && !auth.getSession()) offlineManager.setScope(void 0);
9240
9399
  }).finally(() => {
9241
9400
  confirming = false;
9242
9401
  });
9402
+ };
9403
+ offlineManager.api.onStatusChange((status) => {
9404
+ if (status.online && status.unverifiedUser) confirm();
9243
9405
  });
9406
+ offlineManager.onReachedUnconfirmed(confirm);
9244
9407
  }
9245
9408
  }
9246
9409
  /**
@@ -9301,11 +9464,12 @@ function createRebaseClient(options) {
9301
9464
  }
9302
9465
  });
9303
9466
  }
9467
+ const slug = toSnakeCase(prop);
9304
9468
  if (!untypedWarned) {
9305
9469
  untypedWarned = true;
9306
- console.warn(`[Rebase] Untyped data access detected (client.data.${prop}). Collection names are resolved via snake_case conversion, which may cause silent 404s at request time. Pass a \`collections\` dictionary to createRebaseClient() or use the generated SDK for type-safe access.`);
9470
+ console.warn(`[Rebase] Untyped data access detected: client.data.${prop} was sent to /data/${slug}, its name in snake_case, because this client has no \`collections\` dictionary. A collection whose slug is not that (a kebab-case slug, a renamed accessor) answers 404. With generated types, pass \`collections: collectionsDictionary\` (from generated/sdk/database.types) to createRebaseClient(); a typed client needs it. Otherwise use client.data.collection("<slug>").`);
9307
9471
  }
9308
- return collection(toSnakeCase(prop));
9472
+ return collection(slug);
9309
9473
  },
9310
9474
  has(_target, prop) {
9311
9475
  if (prop === "collection") return true;
@@ -9372,6 +9536,28 @@ channel: (name, options) => {
9372
9536
  onUnauthorized = handler;
9373
9537
  },
9374
9538
  resolveToken: transport.resolveToken,
9539
+ simulate: (simulation) => {
9540
+ const derived = createRebaseClient({
9541
+ baseUrl: options.baseUrl,
9542
+ apiPath: options.apiPath,
9543
+ fetch: options.fetch,
9544
+ collections: options.collections,
9545
+ schemaVersion: options.schemaVersion,
9546
+ headers: {
9547
+ ...options.headers ?? {},
9548
+ ...simulationHeaders(simulation)
9549
+ },
9550
+ realtime: false,
9551
+ auth: {
9552
+ storage: createMemoryStorage(),
9553
+ persistSession: false,
9554
+ autoRefresh: false
9555
+ }
9556
+ });
9557
+ derived.setAuthTokenGetter(() => transport.resolveToken());
9558
+ derived.setOnUnauthorized(() => onUnauthorized());
9559
+ return derived;
9560
+ },
9375
9561
  baseUrl: transport.baseUrl,
9376
9562
  apiPath: transport.apiPath,
9377
9563
  collection,
@@ -10721,6 +10907,251 @@ async function loadCollectionsFromDirectory(source, options = {}) {
10721
10907
  return validate(applyCollectionDefaults(collections, await readDefaults(resolved)), sources);
10722
10908
  }
10723
10909
  //#endregion
10910
+ //#region src/api/rest/idempotency.ts
10911
+ /**
10912
+ * Remembering what a write already answered, so replaying it does not do it twice.
10913
+ *
10914
+ * The offline queue replays a mutation whenever it did not see the response —
10915
+ * which includes every case where the write *committed* and the ACK was lost to
10916
+ * a dropped connection. For a collection whose id the client chooses, the replay
10917
+ * collides on that id and the client can recognise its own earlier attempt. For
10918
+ * a collection with a serial id it cannot: the server ignored the id the client
10919
+ * invented and assigned its own, so the replay is indistinguishable from a new
10920
+ * row and inserts a second one. The scaffold's own collections use
10921
+ * `isId: "increment"`, so that is the default case, not an exotic one.
10922
+ *
10923
+ * A key is honoured only for the principal that created it. Mutation ids are
10924
+ * generated on the client, so keying on the id alone would let anyone who
10925
+ * learned (or guessed) another user's id replay their key and be handed that
10926
+ * user's row back — a read of someone else's data through a write endpoint.
10927
+ * That is also why an unauthenticated caller gets no idempotency at all: every
10928
+ * anonymous request shares one principal, so honouring a key there would hand
10929
+ * one visitor's stored response to the next one who sent the same string.
10930
+ */
10931
+ var TABLE = "\"rebase\".\"idempotency_keys\"";
10932
+ /**
10933
+ * How long a replay is recognised. Long enough to cover an offline stretch and
10934
+ * a retry schedule; short enough that the table stays small and a key cannot be
10935
+ * replayed indefinitely. Rows past this are pruned opportunistically rather than
10936
+ * by a scheduled job — there is no cron guaranteed to be running.
10937
+ */
10938
+ var TTL_HOURS = 24;
10939
+ /**
10940
+ * How long a claim that has not answered yet is presumed to still be running.
10941
+ *
10942
+ * A claim is taken before the write and given back after it, so every exit the
10943
+ * process does not survive — a rolled pod, an OOM kill, a node drain, a
10944
+ * `complete` that itself fails — leaves the row pending with nobody coming back
10945
+ * for it. Reusing the 24-hour replay window as the lease made that
10946
+ * indistinguishable from a request still in flight, so every retry of that key
10947
+ * was refused for a day: the mechanism turned a write that plain retrying would
10948
+ * have completed into permanent loss, which is worse than not having it at all.
10949
+ *
10950
+ * The number trades the two ways it can be wrong, and both are bounded. Too
10951
+ * short and a genuinely slow write has its key taken over by its own retry —
10952
+ * the duplicate this exists to prevent. Too long and a crashed claim refuses
10953
+ * its retries for that long. A minute outlasts any write the row cap allows
10954
+ * and stays well inside the window the offline queue keeps retrying for.
10955
+ */
10956
+ var PENDING_LEASE_SECONDS = 60;
10957
+ /**
10958
+ * How long to wait before attempting the table creation again after it failed.
10959
+ *
10960
+ * `ensure`'s answer used to be memoised unconditionally, so one transient DDL
10961
+ * failure — a database still starting, a lock on the `rebase` schema, a sibling
10962
+ * pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
10963
+ * that pod for the life of the process after a single log line. A failure is
10964
+ * retried instead, and the cooldown is what stops every keyed request re-running
10965
+ * DDL against a database that is genuinely unhappy.
10966
+ */
10967
+ var ENSURE_RETRY_COOLDOWN_MS = 3e4;
10968
+ /**
10969
+ * The principal a key belongs to, or `undefined` when there is nobody to scope
10970
+ * it to and the key must therefore be ignored.
10971
+ *
10972
+ * Anonymous callers are a supported configuration — `auth.requireAuth: false`
10973
+ * delegates access control to RLS — and they used to share one sentinel
10974
+ * principal, which is exactly the cross-principal replay the note above says
10975
+ * must not happen: two visitors of a public form who send the same key string
10976
+ * are handed each other's stored response. Refusing the key degrades them to no
10977
+ * idempotency, which is what they had before this table existed.
10978
+ */
10979
+ function principal(uid) {
10980
+ return uid && uid.length > 0 ? uid : void 0;
10981
+ }
10982
+ /**
10983
+ * A stable rendering of a JSON value: one body always produces one string,
10984
+ * whatever order its keys arrived in.
10985
+ *
10986
+ * `JSON.stringify` preserves insertion order, so two encodings of the same
10987
+ * object would fingerprint differently and a legitimate retry would be refused
10988
+ * as a reused key. Only JSON types reach here — the value came from `JSON.parse`.
10989
+ */
10990
+ function stableStringify$1(value) {
10991
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
10992
+ if (Array.isArray(value)) return `[${value.map(stableStringify$1).join(",")}]`;
10993
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify$1(v)}`).join(",")}}`;
10994
+ }
10995
+ /**
10996
+ * A query string with its parameters in a fixed order, so one request written
10997
+ * two ways is one request. Sorted by name only: the sort is stable, so a
10998
+ * repeated parameter keeps the order its values were sent in.
10999
+ */
11000
+ function canonicalQuery(query) {
11001
+ return [...query.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join("&");
11002
+ }
11003
+ /**
11004
+ * What a key was claimed *for*, so presenting it on a different request is
11005
+ * caught instead of silently replaying the first one's answer.
11006
+ *
11007
+ * Nothing about the request used to enter the key, so `createMany(rows, { key })`
11008
+ * followed by `deleteMany(ids, { key })` — the reusable business id the docs
11009
+ * recommended — answered the delete with the create's `200` body and deleted
11010
+ * nothing. Method and path separate the routes; the body separates a re-run
11011
+ * carrying corrected rows from the retry of an unanswered one, which is the
11012
+ * difference between "here is your earlier answer" and silently discarding a
11013
+ * correction.
11014
+ *
11015
+ * The query string separates instructions sent to one path: `?hard=true` purges
11016
+ * where the same `DELETE` without it soft-deletes, and `?on_conflict=` makes a
11017
+ * create an upsert. Left out, a purge sent under the soft delete's key was
11018
+ * answered with that delete's `204` and never ran. A request with no query
11019
+ * string fingerprints exactly as it did before one could enter.
11020
+ */
11021
+ function requestFingerprint(method, path, body, query) {
11022
+ const search = query ? canonicalQuery(query) : "";
11023
+ return sha256Hex(`${method.toUpperCase()} ${path}${search ? `?${search}` : ""}\n${stableStringify$1(body)}`);
11024
+ }
11025
+ /**
11026
+ * Returns `undefined` when the driver cannot run SQL, which disables the whole
11027
+ * mechanism rather than failing writes: a document backend has no table to put
11028
+ * this in, and refusing to serve is far worse than the duplicate this prevents.
11029
+ *
11030
+ * Every method swallows its own errors for the same reason. A write must not
11031
+ * fail because the bookkeeping around it did — the worst case of a failed
11032
+ * `remember` is the duplicate we already have today, while a thrown error would
11033
+ * reject a write the database has already accepted.
11034
+ */
11035
+ function createIdempotencyStore(driver, options = {}) {
11036
+ const admin = driver.admin;
11037
+ if (!isSQLAdmin(admin)) return void 0;
11038
+ const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
11039
+ let ready;
11040
+ let retryEnsureAt = 0;
11041
+ let warnedAnonymous = false;
11042
+ let warnedMissing = false;
11043
+ /**
11044
+ * What a backend that may not create the table does instead: read it, with
11045
+ * every column the statements below name, so a table an older release made
11046
+ * — without `fingerprint` — counts as absent rather than failing each claim.
11047
+ */
11048
+ const probe = async () => {
11049
+ try {
11050
+ await exec(`SELECT key, uid, fingerprint, response, created_at FROM ${TABLE} WHERE false`);
11051
+ return true;
11052
+ } catch (error) {
11053
+ retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
11054
+ if (!warnedMissing) {
11055
+ warnedMissing = true;
11056
+ logger.warn(`Idempotency keys unavailable — ${TABLE} is missing or out of date, and this backend changes nothing in its database. Idempotency-Key is ignored, so a replayed offline write may insert a duplicate row, until the table is created by a provisioning run.`, { detail: error instanceof Error ? error.message : String(error) });
11057
+ }
11058
+ return false;
11059
+ }
11060
+ };
11061
+ /** Said once per process: it is a deployment fact, not a per-request event. */
11062
+ const warnAnonymous = () => {
11063
+ if (warnedAnonymous) return;
11064
+ warnedAnonymous = true;
11065
+ logger.warn("Idempotency-Key ignored for an unauthenticated request — keys are scoped to a signed-in principal, and honouring one here would replay a stored response to whoever sends the same key next. A replayed write from an anonymous client may insert a duplicate row.");
11066
+ };
11067
+ /** Created on first use: most deployments never send a key at all. */
11068
+ const ensure = () => {
11069
+ if (ready) return ready;
11070
+ if (Date.now() < retryEnsureAt) return Promise.resolve(false);
11071
+ ready = (options.provision === false ? probe() : (async () => {
11072
+ try {
11073
+ await exec("CREATE SCHEMA IF NOT EXISTS rebase");
11074
+ await exec(`
11075
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
11076
+ key TEXT NOT NULL,
11077
+ uid TEXT NOT NULL,
11078
+ response JSONB,
11079
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11080
+ PRIMARY KEY (uid, key)
11081
+ )
11082
+ `);
11083
+ await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
11084
+ await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
11085
+ await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
11086
+ return true;
11087
+ } catch (error) {
11088
+ retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
11089
+ logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
11090
+ return false;
11091
+ }
11092
+ })()).then((ok) => {
11093
+ if (!ok) ready = void 0;
11094
+ return ok;
11095
+ });
11096
+ return ready;
11097
+ };
11098
+ return {
11099
+ async claim(key, uid, fingerprint) {
11100
+ const owner = principal(uid);
11101
+ if (key && !owner) warnAnonymous();
11102
+ if (!key || !owner || !await ensure()) return { status: "claimed" };
11103
+ try {
11104
+ if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
11105
+ VALUES ($1, $2, $3, NULL, NOW())
11106
+ ON CONFLICT (uid, key) DO UPDATE
11107
+ SET response = NULL, created_at = NOW(), fingerprint = EXCLUDED.fingerprint
11108
+ WHERE (${TABLE}.response IS NULL
11109
+ AND ${TABLE}.created_at < NOW() - INTERVAL '${PENDING_LEASE_SECONDS} seconds')
11110
+ OR (${TABLE}.response IS NOT NULL
11111
+ AND ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours')
11112
+ RETURNING 1 AS claimed`, [
11113
+ key,
11114
+ owner,
11115
+ fingerprint
11116
+ ])).length > 0) return { status: "claimed" };
11117
+ const row = (await exec(`SELECT response, response IS NULL AS pending, fingerprint FROM ${TABLE}
11118
+ WHERE uid = $1 AND key = $2`, [owner, key]))[0];
11119
+ if (!row) return { status: "claimed" };
11120
+ if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
11121
+ if (row.pending) return { status: "in-flight" };
11122
+ return {
11123
+ status: "replay",
11124
+ response: row.response
11125
+ };
11126
+ } catch {
11127
+ return { status: "claimed" };
11128
+ }
11129
+ },
11130
+ async complete(key, uid, response) {
11131
+ const owner = principal(uid);
11132
+ if (!key || !owner || !await ensure()) return;
11133
+ try {
11134
+ await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
11135
+ owner,
11136
+ key,
11137
+ JSON.stringify(response ?? null)
11138
+ ]);
11139
+ if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
11140
+ } catch {}
11141
+ },
11142
+ async release(key, uid) {
11143
+ const owner = principal(uid);
11144
+ if (!key || !owner || !await ensure()) return;
11145
+ try {
11146
+ await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
11147
+ } catch {}
11148
+ },
11149
+ ensureTable: ensure
11150
+ };
11151
+ }
11152
+ /** The header the client sends. Matches the widely used Stripe/IETF spelling. */
11153
+ var IDEMPOTENCY_HEADER = "Idempotency-Key";
11154
+ //#endregion
10724
11155
  //#region src/boot/version-skew.ts
10725
11156
  /**
10726
11157
  * Telling a *stale* driver apart from a driver that never had the feature.
@@ -10930,10 +11361,65 @@ function readRuntimeVersion(roots) {
10930
11361
  function describeAdapter(target) {
10931
11362
  return target.driverPackage ? `the adapter from "${target.driverPackage}" (engine "${target.engine}")` : `this project's "${target.engine}" adapter`;
10932
11363
  }
11364
+ /** `REBASE_MIGRATE_ON_BOOT=none` opts a deployment out of both phases. */
10933
11365
  function provisioningDisabled(env = process.env) {
10934
11366
  return (env.REBASE_MIGRATE_ON_BOOT || "ensure") === "none";
10935
11367
  }
10936
11368
  /**
11369
+ * `REBASE_PROVISION`: whether this deployment may change its own database.
11370
+ *
11371
+ * `false` is the answer for a database whose schema changes only through a
11372
+ * change process somebody else runs. The backend is then built with
11373
+ * `provision: false`: no table, column, index, type, function, trigger,
11374
+ * policy, role or grant is created or altered — at boot, or later by a request
11375
+ * that would have created a table on first use — and none of the bookkeeping
11376
+ * writes a provisioning boot makes (the first admin account, version stamps)
11377
+ * happens either. What the database lacks is reported, by name, instead.
11378
+ *
11379
+ * Wider than `REBASE_MIGRATE_ON_BOOT=none`, and deliberately a second
11380
+ * variable rather than a new meaning for that one. `none` has only ever turned
11381
+ * off the *collection* schema; the framework's own tables stayed current on
11382
+ * every boot, and deployments rely on that — the chart's migration-Job
11383
+ * topology runs every api pod with `none`. Widening `none` would stop those
11384
+ * pods keeping `rebase.*` current on the next upgrade, silently.
11385
+ *
11386
+ * A blank value is unset, for the same reason as `REBASE_PROVISION_ONLY`: a
11387
+ * compose file writes `${REBASE_PROVISION}` with nothing behind it all the
11388
+ * time. Anything else that is not a yes or a no is refused rather than
11389
+ * guessed at — read as `true`, a typo would leave a change-managed database
11390
+ * being changed.
11391
+ */
11392
+ function resolveProvisionEnv(env = process.env) {
11393
+ const raw = (env.REBASE_PROVISION ?? "").trim();
11394
+ if (raw === "" || raw === "true") return true;
11395
+ if (raw === "false") return false;
11396
+ throw new Error(`REBASE_PROVISION must be "true" or "false" (got "${env.REBASE_PROVISION}"). \`false\` keeps every schema change out of this backend's boot.`);
11397
+ }
11398
+ /**
11399
+ * Create, now, the tables a serving process would otherwise create on first
11400
+ * use: the idempotency keys every keyed write needs, the cron tables the
11401
+ * hourly sweeps claim their slots in, and the shared rate-limit counters when
11402
+ * `REBASE_RATE_LIMIT_STORE=sql` asks for them.
11403
+ *
11404
+ * What a provisioning run calls. A deployment whose serving processes run with
11405
+ * `REBASE_PROVISION=false` never creates anything, so this run is the only
11406
+ * place those tables can come from — and its recorded SQL is only the whole
11407
+ * schema those processes need if it made them.
11408
+ */
11409
+ async function provisionFirstUseTables(driver, env = process.env) {
11410
+ await createCronStore(driver)?.ensureTable();
11411
+ const idempotency = createIdempotencyStore(driver);
11412
+ if (idempotency && !await idempotency.ensureTable()) logger.warn("Provisioning: the idempotency-key table could not be created; see the error above.");
11413
+ if (resolveRateLimitStoreKind(env) === "sql") {
11414
+ const rateLimits = createSqlRateLimitStore(driver);
11415
+ if (rateLimits && !await rateLimits.ensureTable()) logger.warn("Provisioning: the shared rate-limit table could not be created; see the error above.");
11416
+ }
11417
+ }
11418
+ /** The one line a backend that provisions nothing says about it, at boot. */
11419
+ var PROVISIONING_OFF_NOTICE = "Provisioning is off: this backend changes nothing in its database — no tables, columns, indexes, functions, triggers, policies, roles or grants. What the database lacks is reported below, by name.";
11420
+ /** Why each provisioning step skips on such a backend, phrased to follow "skipped —". */
11421
+ var PROVISIONING_OFF_SKIP_REASON = "this backend changes nothing in its database (provisioning is off), so the schema is left to whatever manages it.";
11422
+ /**
10937
11423
  * Ask the database whether it is there, before boot's first real query.
10938
11424
  *
10939
11425
  * Ordering is the whole point. Provisioning is the first thing in boot that
@@ -10982,7 +11468,7 @@ async function provisionCollectionTables(collections, target, options = {}) {
10982
11468
  reason
10983
11469
  };
10984
11470
  };
10985
- if (options.provision === false) return skip("this process does not provision the schema — another process in this deployment owns it.");
11471
+ if (options.provision === false) return skip(options.skipReason ?? "this process does not provision the schema — another process in this deployment owns it.");
10986
11472
  if (provisioningDisabled(options.env ?? process.env)) return skip("REBASE_MIGRATE_ON_BOOT=none, leaving the database schema untouched.");
10987
11473
  if (options.introspecting) return skip("this project declares no collections, so they are read from the database rather than from code.");
10988
11474
  if (collections.length === 0) return skip("no collections resolved for this data source.");
@@ -11014,10 +11500,10 @@ async function provisionCollectionTables(collections, target, options = {}) {
11014
11500
  */
11015
11501
  async function provisionCollectionPolicies(collections, target, options = {}) {
11016
11502
  if (options.provision === false) {
11017
- logger.info("Collection RLS policies: not applied by this process — another process in this deployment owns them.");
11503
+ logger.info(options.skipReason ? `Collection RLS policies: not applied — ${options.skipReason}` : "Collection RLS policies: not applied by this process — another process in this deployment owns them.");
11018
11504
  return {
11019
11505
  status: "skipped",
11020
- reason: "another process provisions"
11506
+ reason: options.skipReason ?? "another process provisions"
11021
11507
  };
11022
11508
  }
11023
11509
  if (provisioningDisabled(options.env ?? process.env)) return {
@@ -11517,6 +12003,9 @@ function createRoutedRealtimeService(opts) {
11517
12003
  async stopListening() {
11518
12004
  await Promise.all(all().map((p) => p.stopListening?.()));
11519
12005
  },
12006
+ setScopeRunner(run) {
12007
+ for (const p of all()) p.setScopeRunner?.(run);
12008
+ },
11520
12009
  health() {
11521
12010
  return Object.entries(providers).flatMap(([key, p]) => (p.health?.() ?? []).map((listener) => ({
11522
12011
  ...listener,
@@ -12568,11 +13057,11 @@ function versionProperty(collection) {
12568
13057
  * fetched two ways from carrying two different tags, which would make every
12569
13058
  * `If-Match` a coin toss.
12570
13059
  */
12571
- function stableStringify$1(value) {
13060
+ function stableStringify(value) {
12572
13061
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
12573
13062
  if (value instanceof Date) return JSON.stringify(value.toISOString());
12574
- if (Array.isArray(value)) return `[${value.map(stableStringify$1).join(",")}]`;
12575
- return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify$1(v)}`).join(",")}}`;
13063
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
13064
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
12576
13065
  }
12577
13066
  /**
12578
13067
  * The entity tag for a row: quoted, per RFC 9110, and strong.
@@ -12587,7 +13076,7 @@ async function rowETag(row, collection) {
12587
13076
  if (!row) return void 0;
12588
13077
  const versionKey = versionProperty(collection);
12589
13078
  const version = versionKey !== void 0 ? row[versionKey] : void 0;
12590
- return `"${await sha256Hex(version !== void 0 && version !== null ? `v:${renderVersion(version)}` : `r:${stableStringify$1(row)}`)}"`;
13079
+ return `"${await sha256Hex(version !== void 0 && version !== null ? `v:${renderVersion(version)}` : `r:${stableStringify(row)}`)}"`;
12591
13080
  }
12592
13081
  /**
12593
13082
  * The version column's value, as the text a tag is hashed from.
@@ -12600,7 +13089,7 @@ async function rowETag(row, collection) {
12600
13089
  */
12601
13090
  function renderVersion(version) {
12602
13091
  if (version instanceof Date) return version.toISOString();
12603
- return typeof version === "object" ? stableStringify$1(version) : String(version);
13092
+ return typeof version === "object" ? stableStringify(version) : String(version);
12604
13093
  }
12605
13094
  /**
12606
13095
  * Split an `If-Match` header into the tags it lists.
@@ -12964,231 +13453,6 @@ async function deletingAuthCollectionUsers(adapter, collection, uids, remove) {
12964
13453
  return result;
12965
13454
  }
12966
13455
  //#endregion
12967
- //#region src/api/rest/idempotency.ts
12968
- /**
12969
- * Remembering what a write already answered, so replaying it does not do it twice.
12970
- *
12971
- * The offline queue replays a mutation whenever it did not see the response —
12972
- * which includes every case where the write *committed* and the ACK was lost to
12973
- * a dropped connection. For a collection whose id the client chooses, the replay
12974
- * collides on that id and the client can recognise its own earlier attempt. For
12975
- * a collection with a serial id it cannot: the server ignored the id the client
12976
- * invented and assigned its own, so the replay is indistinguishable from a new
12977
- * row and inserts a second one. The scaffold's own collections use
12978
- * `isId: "increment"`, so that is the default case, not an exotic one.
12979
- *
12980
- * A key is honoured only for the principal that created it. Mutation ids are
12981
- * generated on the client, so keying on the id alone would let anyone who
12982
- * learned (or guessed) another user's id replay their key and be handed that
12983
- * user's row back — a read of someone else's data through a write endpoint.
12984
- * That is also why an unauthenticated caller gets no idempotency at all: every
12985
- * anonymous request shares one principal, so honouring a key there would hand
12986
- * one visitor's stored response to the next one who sent the same string.
12987
- */
12988
- var TABLE = "\"rebase\".\"idempotency_keys\"";
12989
- /**
12990
- * How long a replay is recognised. Long enough to cover an offline stretch and
12991
- * a retry schedule; short enough that the table stays small and a key cannot be
12992
- * replayed indefinitely. Rows past this are pruned opportunistically rather than
12993
- * by a scheduled job — there is no cron guaranteed to be running.
12994
- */
12995
- var TTL_HOURS = 24;
12996
- /**
12997
- * How long a claim that has not answered yet is presumed to still be running.
12998
- *
12999
- * A claim is taken before the write and given back after it, so every exit the
13000
- * process does not survive — a rolled pod, an OOM kill, a node drain, a
13001
- * `complete` that itself fails — leaves the row pending with nobody coming back
13002
- * for it. Reusing the 24-hour replay window as the lease made that
13003
- * indistinguishable from a request still in flight, so every retry of that key
13004
- * was refused for a day: the mechanism turned a write that plain retrying would
13005
- * have completed into permanent loss, which is worse than not having it at all.
13006
- *
13007
- * The number trades the two ways it can be wrong, and both are bounded. Too
13008
- * short and a genuinely slow write has its key taken over by its own retry —
13009
- * the duplicate this exists to prevent. Too long and a crashed claim refuses
13010
- * its retries for that long. A minute outlasts any write the row cap allows
13011
- * and stays well inside the window the offline queue keeps retrying for.
13012
- */
13013
- var PENDING_LEASE_SECONDS = 60;
13014
- /**
13015
- * How long to wait before attempting the table creation again after it failed.
13016
- *
13017
- * `ensure`'s answer used to be memoised unconditionally, so one transient DDL
13018
- * failure — a database still starting, a lock on the `rebase` schema, a sibling
13019
- * pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
13020
- * that pod for the life of the process after a single log line. A failure is
13021
- * retried instead, and the cooldown is what stops every keyed request re-running
13022
- * DDL against a database that is genuinely unhappy.
13023
- */
13024
- var ENSURE_RETRY_COOLDOWN_MS = 3e4;
13025
- /**
13026
- * The principal a key belongs to, or `undefined` when there is nobody to scope
13027
- * it to and the key must therefore be ignored.
13028
- *
13029
- * Anonymous callers are a supported configuration — `auth.requireAuth: false`
13030
- * delegates access control to RLS — and they used to share one sentinel
13031
- * principal, which is exactly the cross-principal replay the note above says
13032
- * must not happen: two visitors of a public form who send the same key string
13033
- * are handed each other's stored response. Refusing the key degrades them to no
13034
- * idempotency, which is what they had before this table existed.
13035
- */
13036
- function principal(uid) {
13037
- return uid && uid.length > 0 ? uid : void 0;
13038
- }
13039
- /**
13040
- * A stable rendering of a JSON value: one body always produces one string,
13041
- * whatever order its keys arrived in.
13042
- *
13043
- * `JSON.stringify` preserves insertion order, so two encodings of the same
13044
- * object would fingerprint differently and a legitimate retry would be refused
13045
- * as a reused key. Only JSON types reach here — the value came from `JSON.parse`.
13046
- */
13047
- function stableStringify(value) {
13048
- if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
13049
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
13050
- return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
13051
- }
13052
- /**
13053
- * A query string with its parameters in a fixed order, so one request written
13054
- * two ways is one request. Sorted by name only: the sort is stable, so a
13055
- * repeated parameter keeps the order its values were sent in.
13056
- */
13057
- function canonicalQuery(query) {
13058
- return [...query.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join("&");
13059
- }
13060
- /**
13061
- * What a key was claimed *for*, so presenting it on a different request is
13062
- * caught instead of silently replaying the first one's answer.
13063
- *
13064
- * Nothing about the request used to enter the key, so `createMany(rows, { key })`
13065
- * followed by `deleteMany(ids, { key })` — the reusable business id the docs
13066
- * recommended — answered the delete with the create's `200` body and deleted
13067
- * nothing. Method and path separate the routes; the body separates a re-run
13068
- * carrying corrected rows from the retry of an unanswered one, which is the
13069
- * difference between "here is your earlier answer" and silently discarding a
13070
- * correction.
13071
- *
13072
- * The query string separates instructions sent to one path: `?hard=true` purges
13073
- * where the same `DELETE` without it soft-deletes, and `?on_conflict=` makes a
13074
- * create an upsert. Left out, a purge sent under the soft delete's key was
13075
- * answered with that delete's `204` and never ran. A request with no query
13076
- * string fingerprints exactly as it did before one could enter.
13077
- */
13078
- function requestFingerprint(method, path, body, query) {
13079
- const search = query ? canonicalQuery(query) : "";
13080
- return sha256Hex(`${method.toUpperCase()} ${path}${search ? `?${search}` : ""}\n${stableStringify(body)}`);
13081
- }
13082
- /**
13083
- * Returns `undefined` when the driver cannot run SQL, which disables the whole
13084
- * mechanism rather than failing writes: a document backend has no table to put
13085
- * this in, and refusing to serve is far worse than the duplicate this prevents.
13086
- *
13087
- * Every method swallows its own errors for the same reason. A write must not
13088
- * fail because the bookkeeping around it did — the worst case of a failed
13089
- * `remember` is the duplicate we already have today, while a thrown error would
13090
- * reject a write the database has already accepted.
13091
- */
13092
- function createIdempotencyStore(driver) {
13093
- const admin = driver.admin;
13094
- if (!isSQLAdmin(admin)) return void 0;
13095
- const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
13096
- let ready;
13097
- let retryEnsureAt = 0;
13098
- let warnedAnonymous = false;
13099
- /** Said once per process: it is a deployment fact, not a per-request event. */
13100
- const warnAnonymous = () => {
13101
- if (warnedAnonymous) return;
13102
- warnedAnonymous = true;
13103
- logger.warn("Idempotency-Key ignored for an unauthenticated request — keys are scoped to a signed-in principal, and honouring one here would replay a stored response to whoever sends the same key next. A replayed write from an anonymous client may insert a duplicate row.");
13104
- };
13105
- /** Created on first use: most deployments never send a key at all. */
13106
- const ensure = () => {
13107
- if (ready) return ready;
13108
- if (Date.now() < retryEnsureAt) return Promise.resolve(false);
13109
- ready = (async () => {
13110
- try {
13111
- await exec("CREATE SCHEMA IF NOT EXISTS rebase");
13112
- await exec(`
13113
- CREATE TABLE IF NOT EXISTS ${TABLE} (
13114
- key TEXT NOT NULL,
13115
- uid TEXT NOT NULL,
13116
- response JSONB,
13117
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
13118
- PRIMARY KEY (uid, key)
13119
- )
13120
- `);
13121
- await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
13122
- await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
13123
- await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
13124
- return true;
13125
- } catch (error) {
13126
- retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
13127
- logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
13128
- return false;
13129
- }
13130
- })().then((ok) => {
13131
- if (!ok) ready = void 0;
13132
- return ok;
13133
- });
13134
- return ready;
13135
- };
13136
- return {
13137
- async claim(key, uid, fingerprint) {
13138
- const owner = principal(uid);
13139
- if (key && !owner) warnAnonymous();
13140
- if (!key || !owner || !await ensure()) return { status: "claimed" };
13141
- try {
13142
- if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
13143
- VALUES ($1, $2, $3, NULL, NOW())
13144
- ON CONFLICT (uid, key) DO UPDATE
13145
- SET response = NULL, created_at = NOW(), fingerprint = EXCLUDED.fingerprint
13146
- WHERE (${TABLE}.response IS NULL
13147
- AND ${TABLE}.created_at < NOW() - INTERVAL '${PENDING_LEASE_SECONDS} seconds')
13148
- OR (${TABLE}.response IS NOT NULL
13149
- AND ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours')
13150
- RETURNING 1 AS claimed`, [
13151
- key,
13152
- owner,
13153
- fingerprint
13154
- ])).length > 0) return { status: "claimed" };
13155
- const row = (await exec(`SELECT response, response IS NULL AS pending, fingerprint FROM ${TABLE}
13156
- WHERE uid = $1 AND key = $2`, [owner, key]))[0];
13157
- if (!row) return { status: "claimed" };
13158
- if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
13159
- if (row.pending) return { status: "in-flight" };
13160
- return {
13161
- status: "replay",
13162
- response: row.response
13163
- };
13164
- } catch {
13165
- return { status: "claimed" };
13166
- }
13167
- },
13168
- async complete(key, uid, response) {
13169
- const owner = principal(uid);
13170
- if (!key || !owner || !await ensure()) return;
13171
- try {
13172
- await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
13173
- owner,
13174
- key,
13175
- JSON.stringify(response ?? null)
13176
- ]);
13177
- if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
13178
- } catch {}
13179
- },
13180
- async release(key, uid) {
13181
- const owner = principal(uid);
13182
- if (!key || !owner || !await ensure()) return;
13183
- try {
13184
- await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
13185
- } catch {}
13186
- }
13187
- };
13188
- }
13189
- /** The header the client sends. Matches the widely used Stripe/IETF spelling. */
13190
- var IDEMPOTENCY_HEADER = "Idempotency-Key";
13191
- //#endregion
13192
13456
  //#region src/api/rest/query-body.ts
13193
13457
  /**
13194
13458
  * `POST /<collection>/query`: a listing whose parameters travel in the body.
@@ -13376,11 +13640,12 @@ var RestApiGenerator = class RestApiGenerator {
13376
13640
  maxBulkRows;
13377
13641
  listLimits;
13378
13642
  authAdapter;
13379
- constructor(collections, driver, authAdapter, maxBulkRows = DEFAULT_MAX_BULK_ROWS, listLimits = {}) {
13643
+ constructor(collections, driver, authAdapter, maxBulkRows = DEFAULT_MAX_BULK_ROWS, listLimits = {}, storeOptions = {}) {
13380
13644
  this.collections = collections;
13381
13645
  this.driver = driver;
13382
13646
  this.authAdapter = authAdapter;
13383
13647
  this.maxBulkRows = maxBulkRows;
13648
+ this.provision = storeOptions.provision;
13384
13649
  this.listLimits = {
13385
13650
  defaultLimit: listLimits.defaultLimit ?? 50,
13386
13651
  maxLimit: listLimits.maxLimit ?? 1e3
@@ -13392,8 +13657,9 @@ var RestApiGenerator = class RestApiGenerator {
13392
13657
  * for SQL support and creates a table, and most requests never send a key.
13393
13658
  */
13394
13659
  idempotencyStore;
13660
+ provision;
13395
13661
  idempotency() {
13396
- this.idempotencyStore ??= createIdempotencyStore(this.driver) ?? null;
13662
+ this.idempotencyStore ??= createIdempotencyStore(this.driver, { provision: this.provision }) ?? null;
13397
13663
  return this.idempotencyStore ?? void 0;
13398
13664
  }
13399
13665
  /**
@@ -13636,10 +13902,12 @@ var RestApiGenerator = class RestApiGenerator {
13636
13902
  * an API key or a token.
13637
13903
  */
13638
13904
  enforceApiKeyPermission(c, collectionSlug, operationOverride) {
13905
+ const operation = operationOverride ?? httpMethodToOperation(c.req.method);
13906
+ refuseSimulatedWrite(c, operation);
13639
13907
  const narrowed = c.get("scopes");
13640
13908
  if (!Array.isArray(narrowed)) return;
13641
13909
  const held = narrowed.filter((entry) => typeof entry === "string");
13642
- const scope = `data:${operationOverride ?? httpMethodToOperation(c.req.method)}`;
13910
+ const scope = `data:${operation}`;
13643
13911
  if (!scopeGrants(held, scope, collectionSlug)) throw new ApiError(403, "SCOPE_MISSING", `This credential does not hold "${scope}" for collection "${collectionSlug}". A key needs "${scope}" or "${scope}:${collectionSlug}".`, { requiredScope: `${scope}:${collectionSlug}` });
13644
13912
  }
13645
13913
  /**
@@ -14693,6 +14961,41 @@ function createUnconfiguredEmailService() {
14693
14961
  };
14694
14962
  }
14695
14963
  //#endregion
14964
+ //#region src/auth/adapter-service-key.ts
14965
+ function withBackendServiceKey(adapter, serviceKey) {
14966
+ let key = serviceKey;
14967
+ const prototype = Object.getPrototypeOf(adapter);
14968
+ const plainObject = prototype === Object.prototype || prototype === null;
14969
+ const bound = /* @__PURE__ */ new Map();
14970
+ return new Proxy(adapter, {
14971
+ get(target, property, receiver) {
14972
+ if (property === "serviceKey") return key;
14973
+ if (plainObject) return Reflect.get(target, property, receiver);
14974
+ const value = Reflect.get(target, property, target);
14975
+ if (typeof value !== "function") return value;
14976
+ const original = value;
14977
+ const cached = bound.get(property);
14978
+ if (cached && cached.original === original) return cached.bound;
14979
+ const fresh = original.bind(target);
14980
+ bound.set(property, {
14981
+ original,
14982
+ bound: fresh
14983
+ });
14984
+ return fresh;
14985
+ },
14986
+ set(target, property, value, receiver) {
14987
+ if (property === "serviceKey") {
14988
+ key = value;
14989
+ return true;
14990
+ }
14991
+ return plainObject ? Reflect.set(target, property, value, receiver) : Reflect.set(target, property, value, target);
14992
+ },
14993
+ has(target, property) {
14994
+ return property === "serviceKey" || Reflect.has(target, property);
14995
+ }
14996
+ });
14997
+ }
14998
+ //#endregion
14696
14999
  //#region src/api/schema-drift.ts
14697
15000
  /**
14698
15001
  * Notice that a caller was generated against an older schema than this one.
@@ -14791,11 +15094,12 @@ function installRootErrorHandler(app) {
14791
15094
  * `boot.ts` registers `/api/health` *after* `initializeRebaseBackend` returns.
14792
15095
  * A catch-all would have swallowed the health probe.
14793
15096
  *
14794
- * Post-processing has neither problem: it converts a 404 that came back as
14795
- * anything but JSON, and leaves the ones that are already an envelope — a
14796
- * storage miss, an unknown collection, an unknown function — exactly as their
14797
- * route wrote them. Scoped to `basePath`, so an app serving its own pages at `/`
14798
- * keeps answering its own way for the pages.
15097
+ * Post-processing has neither problem: it converts Hono's own default answer —
15098
+ * `404 Not Found`, `text/plain` — and leaves every 404 something wrote exactly
15099
+ * as it was written: the envelopes routes answer (a storage miss, an unknown
15100
+ * collection, an unknown function), a function's own text or HTML 404, and an
15101
+ * application's own `notFound`. Scoped to `basePath`, so an app serving its own
15102
+ * pages at `/` keeps answering its own way for the pages.
14799
15103
  *
14800
15104
  * `expected`, so it logs at debug. A 404 from a frontend holding a stale link is
14801
15105
  * routine, and a `⚠️` per request would trade a missing envelope for noise
@@ -14806,12 +15110,31 @@ function installUnmatchedApiEnvelope(app, basePath) {
14806
15110
  app.use(`${prefix}/*`, async (c, next) => {
14807
15111
  await next();
14808
15112
  if (c.res.status !== 404) return;
14809
- if ((c.res.headers.get("content-type") ?? "").includes("application/json")) return;
14810
15113
  if (c.req.matchedRoutes.some((route) => route.method !== "ALL")) return;
15114
+ if (!await isHonoDefaultNotFound(c.res)) return;
14811
15115
  c.res = errorHandler(new ApiError(404, "NOT_FOUND", `No route for ${c.req.method} ${c.req.path} on this backend.`, void 0, true), c);
14812
15116
  });
14813
15117
  logger.debug("Unmatched API routes answer the JSON envelope", { prefix });
14814
15118
  }
15119
+ /** What Hono's built-in not-found handler answers: `c.text("404 Not Found", 404)`. */
15120
+ var HONO_DEFAULT_NOT_FOUND_BODY = "404 Not Found";
15121
+ /**
15122
+ * Whether a 404 is Hono's default answer rather than one a handler wrote.
15123
+ *
15124
+ * Read from a clone, and only for `text/plain` — the content type Hono's
15125
+ * default carries — so a JSON envelope, an HTML page or a stream of any other
15126
+ * type is never read here.
15127
+ */
15128
+ async function isHonoDefaultNotFound(res) {
15129
+ if (!(res.headers.get("content-type") ?? "").startsWith("text/plain")) return false;
15130
+ const length = res.headers.get("content-length");
15131
+ if (length !== null && Number(length) !== 13) return false;
15132
+ try {
15133
+ return await res.clone().text() === HONO_DEFAULT_NOT_FOUND_BODY;
15134
+ } catch {
15135
+ return false;
15136
+ }
15137
+ }
14815
15138
  /** Whether `onError` has been called on this app. */
14816
15139
  function hasOwnErrorHandler(app) {
14817
15140
  const current = app.errorHandler;
@@ -15311,6 +15634,8 @@ var TOPOLOGY_ENV_VARS = [
15311
15634
  "REBASE_RLS_AUDIT",
15312
15635
  "REBASE_MCP_ENABLED",
15313
15636
  "REBASE_MIGRATE_ON_BOOT",
15637
+ "REBASE_PROVISION",
15638
+ "REBASE_PROVISION_ONLY",
15314
15639
  "TRUSTED_PROXY_HOPS",
15315
15640
  "REBASE_RATE_LIMIT_STORE",
15316
15641
  "REBASE_REQUIRE_SCHEMA_MATCH"
@@ -15468,6 +15793,30 @@ function customMetadataOfSidecar(sidecar) {
15468
15793
  }
15469
15794
  /** What the metadata kept beside an object is named: `<key>.metadata.json`. */
15470
15795
  var METADATA_SUFFIX = ".metadata.json";
15796
+ /** A listing page token: `n.` and the last entry name a page read, base64url. */
15797
+ var LISTING_TOKEN_PREFIX = "n.";
15798
+ function encodeListingToken(name) {
15799
+ return LISTING_TOKEN_PREFIX + Buffer.from(name, "utf-8").toString("base64url");
15800
+ }
15801
+ /** The entry name a token this listing issued names, or `undefined` for any other string. */
15802
+ function decodeListingToken(token) {
15803
+ if (!token.startsWith(LISTING_TOKEN_PREFIX)) return void 0;
15804
+ const encoded = token.slice(2);
15805
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return void 0;
15806
+ const name = Buffer.from(encoded, "base64url").toString("utf-8");
15807
+ return name && encodeListingToken(name) === token ? name : void 0;
15808
+ }
15809
+ /** The index of the first of `entries` (in name order) named after `name`. */
15810
+ function firstEntryAfter(entries, name) {
15811
+ let low = 0;
15812
+ let high = entries.length;
15813
+ while (low < high) {
15814
+ const mid = low + high >>> 1;
15815
+ if (entries[mid].name <= name) low = mid + 1;
15816
+ else high = mid;
15817
+ }
15818
+ return low;
15819
+ }
15471
15820
  /**
15472
15821
  * Refuse a key that names one of local storage's own files rather than an
15473
15822
  * object: the metadata beside an object, or a write in progress.
@@ -15609,6 +15958,17 @@ var LocalStorageController = class {
15609
15958
  }
15610
15959
  }
15611
15960
  /**
15961
+ * See {@link StorageController.storageLocation}: the default bucket's
15962
+ * directory, with the storage root's links resolved.
15963
+ */
15964
+ storageLocation() {
15965
+ let root = this.basePath;
15966
+ try {
15967
+ root = fs$3.realpathSync(this.basePath);
15968
+ } catch {}
15969
+ return `local:${path$3.join(root, DEFAULT_BUCKET)}`;
15970
+ }
15971
+ /**
15612
15972
  * Ensure directory exists, creating it if necessary
15613
15973
  */
15614
15974
  async ensureDir(dirPath) {
@@ -15860,14 +16220,15 @@ var LocalStorageController = class {
15860
16220
  const maxResults = options?.maxResults ?? 1e3;
15861
16221
  if (!Number.isInteger(maxResults) || maxResults < 1) throw new InvalidListOptionsError(`maxResults must be a whole number of at least 1, got ${maxResults}.`);
15862
16222
  const pageToken = options?.pageToken;
15863
- if (pageToken && !/^\d+$/.test(pageToken)) throw new InvalidListOptionsError(`"${pageToken}" is not a page token this listing issued.`);
15864
- const startIndex = pageToken ? parseInt(pageToken, 10) : 0;
16223
+ const after = pageToken ? decodeListingToken(pageToken) : void 0;
16224
+ if (pageToken && after === void 0) throw new InvalidListOptionsError(`"${pageToken}" is not a page token this listing issued.`);
15865
16225
  const normalizedPath = normalizeStoragePath(prefix);
15866
16226
  const fullPath = this.getFullPath(normalizedPath, options?.bucket);
15867
16227
  const items = [];
15868
16228
  const prefixes = [];
15869
16229
  try {
15870
- const entries = await this.listingEntries(fullPath, startIndex > 0);
16230
+ const entries = await this.listingEntries(fullPath, after !== void 0);
16231
+ const startIndex = after === void 0 ? 0 : firstEntryAfter(entries, after);
15871
16232
  let count = 0;
15872
16233
  let scanned = startIndex;
15873
16234
  for (let i = startIndex; i < entries.length && count < maxResults; i++) {
@@ -15889,7 +16250,7 @@ var LocalStorageController = class {
15889
16250
  count++;
15890
16251
  }
15891
16252
  await describeListed(items, (ref) => path$3.join(fullPath, ref.name));
15892
- const nextPageToken = scanned < entries.length ? String(scanned) : void 0;
16253
+ const nextPageToken = scanned < entries.length && scanned > 0 ? encodeListingToken(entries[scanned - 1].name) : void 0;
15893
16254
  if (!nextPageToken) this.listingSnapshots.delete(fullPath);
15894
16255
  return {
15895
16256
  items,
@@ -15908,11 +16269,11 @@ var LocalStorageController = class {
15908
16269
  /**
15909
16270
  * A folder's entries for a listing page.
15910
16271
  *
15911
- * A page token is an index into these entries, and every continuation
15912
- * page used to read the whole folder again to find its place: a folder of
16272
+ * A page token names the last of these entries a page read, and every
16273
+ * continuation page used to read the whole folder again to find its place: a folder of
15913
16274
  * n entries listed in pages of p cost n/p full reads. A first page reads
15914
16275
  * the folder and, when there is a next page, keeps the read; a
15915
- * continuation page indexes into it while all of these hold:
16276
+ * continuation page finds its place in it while all of these hold:
15916
16277
  *
15917
16278
  * - the folder's mtime is the one read with it. Adding, removing or
15918
16279
  * renaming an entry changes it, which is the invalidation;
@@ -15928,7 +16289,7 @@ var LocalStorageController = class {
15928
16289
  const kept = this.listingSnapshots.get(fullPath);
15929
16290
  if (continuation && kept && kept.mtimeMs === mtimeMs && readAt - kept.readAt < LISTING_SNAPSHOT_TTL_MS) return kept.entries;
15930
16291
  this.listingSnapshots.delete(fullPath);
15931
- const entries = await fs$3.promises.readdir(fullPath, { withFileTypes: true });
16292
+ const entries = (await fs$3.promises.readdir(fullPath, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
15932
16293
  if (readAt - mtimeMs > LISTING_SNAPSHOT_RACY_MS && entries.length <= LISTING_SNAPSHOT_MAX_ENTRIES) {
15933
16294
  this.listingSnapshots.set(fullPath, {
15934
16295
  entries,
@@ -17124,6 +17485,28 @@ function createDurableRenditionCache() {
17124
17485
  };
17125
17486
  }
17126
17487
  //#endregion
17488
+ //#region src/storage/maintenance-keys.ts
17489
+ /**
17490
+ * Where storage maintenance keeps its own records inside a source: the state
17491
+ * and result of an orphan scan (`rebase storage orphans`), which a worker
17492
+ * writes and any server process reads back.
17493
+ *
17494
+ * Reserved at the route layer like the rendition space, in both directions. A
17495
+ * scan's result is the list a later delete acts on, so a caller who could
17496
+ * write one could name files for an administrator's delete to remove; and it
17497
+ * lists keys a caller may not be allowed to see.
17498
+ */
17499
+ var MAINTENANCE_PREFIX = "_rebase/maintenance/";
17500
+ /**
17501
+ * True for a key in the reserved maintenance space — the prefix itself, or
17502
+ * anything under it. Folded the way the filesystem under a local bucket folds
17503
+ * it, as {@link isRenditionKey} is.
17504
+ */
17505
+ function isMaintenanceKey(key) {
17506
+ const folded = key.replace(/\\/g, "/").normalize("NFKC").toLowerCase();
17507
+ return folded.startsWith("_rebase/maintenance/") || folded === "_rebase/maintenance/".slice(0, -1);
17508
+ }
17509
+ //#endregion
17127
17510
  //#region src/storage/request-keys.ts
17128
17511
  /**
17129
17512
  * The checks a caller-supplied key, bucket and storage source go through
@@ -17169,12 +17552,14 @@ function objectOfPathOrBadRequest(path) {
17169
17552
  }
17170
17553
  /**
17171
17554
  * Run a canonicalization, refuse what it yields when that is the reserved
17172
- * rendition space, and turn either refusal into a 400.
17555
+ * rendition or maintenance space, and turn either refusal into a 400.
17173
17556
  */
17174
17557
  function addressableOrBadRequest(canonicalize) {
17175
17558
  try {
17176
17559
  const canonical = canonicalize();
17177
- if (isRenditionKey(typeof canonical === "string" ? canonical : canonical.key)) throw new InvalidStorageKeyError(`"${RENDITION_PREFIX}" is reserved for derived image renditions and cannot be read or written directly.`);
17560
+ const key = typeof canonical === "string" ? canonical : canonical.key;
17561
+ if (isRenditionKey(key)) throw new InvalidStorageKeyError(`"${RENDITION_PREFIX}" is reserved for derived image renditions and cannot be read or written directly.`);
17562
+ if (isMaintenanceKey(key)) throw new InvalidStorageKeyError(`"${MAINTENANCE_PREFIX}" is reserved for storage maintenance and cannot be read or written directly.`);
17178
17563
  return canonical;
17179
17564
  } catch (err) {
17180
17565
  throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
@@ -18685,11 +19070,11 @@ async function createStorageController(config) {
18685
19070
  switch (config.type) {
18686
19071
  case "local": return new LocalStorageController(config);
18687
19072
  case "s3": {
18688
- const { S3StorageController } = await import("./S3StorageController-D00EFa5P.js").then((n) => n.n);
19073
+ const { S3StorageController } = await import("./S3StorageController-Bl5shp94.js").then((n) => n.n);
18689
19074
  return new S3StorageController(config);
18690
19075
  }
18691
19076
  case "gcs": {
18692
- const { GCSStorageController } = await import("./GCSStorageController-CRd9bFrc.js").then((n) => n.n);
19077
+ const { GCSStorageController } = await import("./GCSStorageController-40FyFeSx.js").then((n) => n.n);
18693
19078
  return new GCSStorageController(config);
18694
19079
  }
18695
19080
  default: throw new Error(`Unknown storage type: ${config.type}. Built-in types: local, s3, gcs. For other providers, implement the StorageController interface directly.`);
@@ -18928,7 +19313,7 @@ function noCollectionsCause(unprotected) {
18928
19313
  * routes wherever and as often as the application asks.
18929
19314
  */
18930
19315
  async function loadOpenApiGenerator() {
18931
- const { generateOpenApiSpec } = await import("./openapi-generator-BjHr6upl.js").then((n) => n.r);
19316
+ const { generateOpenApiSpec } = await import("./openapi-generator-CyxEtYEX.js").then((n) => n.r);
18932
19317
  return generateOpenApiSpec;
18933
19318
  }
18934
19319
  /**
@@ -19275,6 +19660,12 @@ function createShutdown(config) {
19275
19660
  forceTimer?.unref();
19276
19661
  const workDeadline = timeoutMs > 0 ? Date.now() + Math.floor(timeoutMs * WORK_DRAIN_SHARE) : void 0;
19277
19662
  const workBudget = () => workDeadline === void 0 ? void 0 : Math.max(0, workDeadline - Date.now());
19663
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : void 0;
19664
+ const timeLeft = () => deadline === void 0 ? void 0 : Math.max(0, deadline - Date.now() - 100);
19665
+ const drainWaitUntil = async (drainBudget, last) => {
19666
+ const stillPending = await drainBackgroundWork(drainBudget);
19667
+ if (stillPending > 0 && last) logger.warn(`${stillPending} background task(s) handed to waitUntil() did not finish within ${drainBudget}ms and are being dropped. Give them an AbortSignal, or move work this long into a cron job or the job queue, where it is restartable.`);
19668
+ };
19278
19669
  (async () => {
19279
19670
  logger.info("Shutting down Rebase Backend...");
19280
19671
  const cronStopped = config.cronScheduler ? Promise.resolve(config.cronScheduler.stop(workBudget())).then(() => logger.info("Cron scheduler stopped"), (err) => logger.warn("Error stopping the cron scheduler:", { error: err })) : void 0;
@@ -19282,6 +19673,7 @@ function createShutdown(config) {
19282
19673
  const sweepStopped = Promise.resolve(config.authTokenSweep?.stop()).catch((err) => logger.warn("Error stopping the expired-token sweep:", { error: err }));
19283
19674
  const historySweepStopped = Promise.resolve(config.historyRetentionSweep?.stop()).catch((err) => logger.warn("Error stopping the history retention sweep:", { error: err }));
19284
19675
  config.stopMetricsSampler?.();
19676
+ config.ownedRateLimitStore?.dispose?.();
19285
19677
  if (config.jobQueue) {
19286
19678
  await config.jobQueue.stop(workBudget());
19287
19679
  logger.info("Job queue stopped");
@@ -19289,9 +19681,7 @@ function createShutdown(config) {
19289
19681
  await cronStopped;
19290
19682
  await sweepStopped;
19291
19683
  await historySweepStopped;
19292
- const drainBudget = Math.min(5e3, workBudget() ?? 5e3);
19293
- const stillPending = await drainBackgroundWork(drainBudget);
19294
- if (stillPending > 0) logger.warn(`${stillPending} background task(s) handed to waitUntil() did not finish within ${drainBudget}ms and are being dropped. Give them an AbortSignal, or move work this long into a cron job or the job queue, where it is restartable.`);
19684
+ await drainWaitUntil(Math.min(5e3, workBudget() ?? 5e3), !config.server);
19295
19685
  for (const [key, rt] of Object.entries(config.realtimeServices)) try {
19296
19686
  if (typeof rt.destroy === "function") {
19297
19687
  await rt.destroy();
@@ -19316,9 +19706,11 @@ function createShutdown(config) {
19316
19706
  server.close(() => {
19317
19707
  clearInterval(sweepIdle);
19318
19708
  logger.info("HTTP server closed");
19319
- clearTimeout(forceTimer);
19320
- closeEmail();
19321
- resolve();
19709
+ drainWaitUntil(Math.min(5e3, timeLeft() ?? 5e3), true).then(() => {
19710
+ clearTimeout(forceTimer);
19711
+ closeEmail();
19712
+ resolve();
19713
+ });
19322
19714
  });
19323
19715
  })();
19324
19716
  });
@@ -20037,12 +20429,13 @@ function createLiveSchemaRoutes(config) {
20037
20429
  } catch {
20038
20430
  branch = void 0;
20039
20431
  }
20432
+ const databaseOff = config.databaseChangesOff;
20040
20433
  return c.json({
20041
20434
  enabled: true,
20042
20435
  canPlan: capabilities.plan,
20043
- canApply: capabilities.apply,
20044
- applyRefusedBecause: capabilities.reason,
20045
- applyRefusedCode: capabilities.code,
20436
+ canApply: capabilities.apply && !databaseOff,
20437
+ applyRefusedBecause: databaseOff?.message ?? capabilities.reason,
20438
+ applyRefusedCode: databaseOff?.code ?? capabilities.code,
20046
20439
  repository: repository.root,
20047
20440
  branch
20048
20441
  });
@@ -20085,6 +20478,7 @@ function createLiveSchemaRoutes(config) {
20085
20478
  const body = await readBody(c);
20086
20479
  const change = parseProposed(body);
20087
20480
  const sourceOnly = isRecord(body) && body.sourceOnly === true;
20481
+ if (config.databaseChangesOff && !sourceOnly) throw ApiError.conflict(config.databaseChangesOff.message, config.databaseChangesOff.code);
20088
20482
  const admin = requirePlanner();
20089
20483
  const principal = classifyPrincipal(c.get("user"));
20090
20484
  const capabilities = schemaEditCapabilities(principal, config.policy);
@@ -20559,6 +20953,39 @@ async function rewriteRemoteCollection(options, collectionId, change) {
20559
20953
  }
20560
20954
  }
20561
20955
  //#endregion
20956
+ //#region src/schema-edit/deployed-schema-guard.ts
20957
+ var RepositoryAheadOfDeploymentError = class extends Error {
20958
+ paths;
20959
+ constructor(paths, branch) {
20960
+ super(`The repository's ${paths.join(", ")} ${paths.length === 1 ? "is" : "are"} not what the collections this deployment runs generate, so a schema change is committed that this deployment is not running yet: one made here, or one pushed to ${branch}. Deploy it, then make the next change. Until then a change here is planned against the collections this deployment runs, and its commit would rewrite ${paths.length === 1 ? "that file" : "those files"} without the change waiting to deploy. If nothing is waiting to deploy, the file was generated by another Rebase version, reformatted, or edited by hand: run \`rebase schema generate\` with the version this deployment runs, commit the result and deploy it. Nothing was changed.`);
20961
+ this.paths = paths;
20962
+ this.name = "RepositoryAheadOfDeploymentError";
20963
+ }
20964
+ };
20965
+ /** Line endings and trailing whitespace are not schema. */
20966
+ var normalize = (contents) => contents.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trimEnd()).join("\n").trimEnd();
20967
+ /**
20968
+ * Refuse when the repository's generated files differ from `served`, the files
20969
+ * the collections this deployment serves generate.
20970
+ *
20971
+ * A file the repository does not have is not a difference: nothing has
20972
+ * committed one, so there is no change in it for a commit to leave out. A
20973
+ * repository that cannot read a file cannot be checked, and is refused: the
20974
+ * remote write path reads every file it edits, so that repository could not
20975
+ * make the change either.
20976
+ */
20977
+ async function refuseWhenRepositoryIsAhead(repository, served) {
20978
+ if (!repository.readFile) throw new Error("This repository cannot read a file, so whether it holds a change this deployment is not running yet cannot be checked. Nothing was changed.");
20979
+ const differing = [];
20980
+ for (const file of served) {
20981
+ if (file.deleted) continue;
20982
+ const committed = await repository.readFile(file.path);
20983
+ if (committed === void 0) continue;
20984
+ if (normalize(committed) !== normalize(file.contents)) differing.push(file.path);
20985
+ }
20986
+ if (differing.length > 0) throw new RepositoryAheadOfDeploymentError(differing, await repository.currentBranch());
20987
+ }
20988
+ //#endregion
20562
20989
  //#region src/schema-edit/local-source.ts
20563
20990
  /**
20564
20991
  * Writing a live schema change into the collection source on this machine.
@@ -21063,7 +21490,7 @@ function startExpiredTokenSweep(wiring) {
21063
21490
  const repo = wiring.authRepo;
21064
21491
  if (!wiring.ownsTimers || !repo || typeof repo.deleteExpiredTokens !== "function") return void 0;
21065
21492
  const deleteExpiredTokens = repo.deleteExpiredTokens.bind(repo);
21066
- const store = wiring.persistClaims !== false && isSQLAdmin(wiring.driver.admin) ? createCronStore(wiring.driver) : void 0;
21493
+ const store = wiring.persistClaims !== false && isSQLAdmin(wiring.driver.admin) ? createCronStore(wiring.driver, { provision: wiring.provision }) : void 0;
21067
21494
  let ready;
21068
21495
  const sweep = createExpiredTokenSweep({
21069
21496
  authRepo: { deleteExpiredTokens },
@@ -21156,7 +21583,7 @@ function startHistoryRetentionSweep(wiring) {
21156
21583
  const service = wiring.historyService;
21157
21584
  if (!wiring.ownsTimers || !service || typeof service.pruneExpired !== "function") return void 0;
21158
21585
  const pruneExpired = service.pruneExpired.bind(service);
21159
- const store = wiring.persistClaims !== false && isSQLAdmin(wiring.driver.admin) ? createCronStore(wiring.driver) : void 0;
21586
+ const store = wiring.persistClaims !== false && isSQLAdmin(wiring.driver.admin) ? createCronStore(wiring.driver, { provision: wiring.provision }) : void 0;
21160
21587
  let ready;
21161
21588
  const sweep = createHistoryRetentionSweep({
21162
21589
  pruneExpired,
@@ -21411,6 +21838,15 @@ function forEachString(value, visit, depth = 0) {
21411
21838
  else if (typeof value === "object" && !(value instanceof Date)) for (const element of Object.values(value)) forEachString(element, visit, depth + 1);
21412
21839
  }
21413
21840
  /**
21841
+ * Whether reading a collection through `fetchCollection` could hide a row or
21842
+ * change a value: a `beforeQuery` or an `afterRead`, the collection's own, a
21843
+ * global one, or a property's.
21844
+ */
21845
+ function hasReadHooks(collection, globalCallbacks) {
21846
+ const own = collection.callbacks;
21847
+ return Boolean(own?.beforeQuery || own?.afterRead || globalCallbacks?.beforeQuery || globalCallbacks?.afterRead || collection.properties && buildPropertyCallbacks(collection.properties)?.afterRead);
21848
+ }
21849
+ /**
21414
21850
  * Remove from `keys` every key some row names.
21415
21851
  *
21416
21852
  * Every row of every collection is read — soft-deleted rows too, since a
@@ -21418,51 +21854,162 @@ function forEachString(value, visit, depth = 0) {
21418
21854
  * serves the collection. What is left in `keys` afterwards is named by no row.
21419
21855
  * A collection that could not be read is returned, and its rows count for
21420
21856
  * nothing: a caller must not treat what is left as unused when any is.
21857
+ *
21858
+ * Rows are read as stored, through the driver's `scanStoredRows`. A driver
21859
+ * without it is read through `fetchCollection` only for a collection with no
21860
+ * read hooks: a `beforeQuery` scoping rows to `context.user` — there is none
21861
+ * here — or an `afterRead` rewriting a file field would hide a reference, and
21862
+ * the file it names would be deleted as unused. Such a collection is
21863
+ * returned as unscanned instead.
21421
21864
  */
21422
21865
  async function removeReferencedKeys(keys, collections, driverFor, options = {}) {
21423
21866
  const pageSize = options.pageSize ?? 500;
21424
21867
  const scanned = [];
21425
21868
  const unscanned = [];
21869
+ const progress = options.progress;
21426
21870
  const visit = (text) => {
21427
21871
  if (keys.size === 0) return;
21428
21872
  for (const key of keysNamedBy(text)) keys.delete(key);
21429
21873
  };
21874
+ const pageRead = (rows) => {
21875
+ for (const row of rows) forEachString(row, visit);
21876
+ if (progress) progress.rowsScanned += rows.length;
21877
+ options.onProgress?.();
21878
+ options.signal?.throwIfAborted();
21879
+ };
21880
+ const notRead = (entry) => {
21881
+ unscanned.push(entry);
21882
+ progress?.unscanned.push(entry);
21883
+ };
21884
+ if (progress) progress.collectionsTotal += collections.length;
21430
21885
  for (const collection of collections) {
21431
- const driver = driverFor(collection);
21432
- if (!driver) {
21433
- unscanned.push({
21434
- collection: collection.slug,
21435
- reason: "no data driver serves it here"
21436
- });
21437
- continue;
21438
- }
21886
+ options.signal?.throwIfAborted();
21887
+ if (progress) progress.currentCollection = collection.slug;
21439
21888
  try {
21440
- let after;
21441
- for (;;) {
21442
- const rows = await driver.fetchCollection({
21889
+ const driver = driverFor(collection);
21890
+ if (!driver) {
21891
+ notRead({
21892
+ collection: collection.slug,
21893
+ reason: "no data driver serves it here"
21894
+ });
21895
+ continue;
21896
+ }
21897
+ try {
21898
+ if (driver.scanStoredRows) for await (const rows of driver.scanStoredRows({
21443
21899
  path: collection.slug,
21444
21900
  collection,
21445
- limit: pageSize,
21446
- withDeleted: true,
21447
- ...after ? { startAfter: after } : {}
21901
+ pageSize
21902
+ })) pageRead(rows);
21903
+ else if (hasReadHooks(collection, options.globalCallbacks)) {
21904
+ notRead({
21905
+ collection: collection.slug,
21906
+ reason: "it has a beforeQuery or afterRead hook, and its data driver cannot read its rows past them"
21907
+ });
21908
+ continue;
21909
+ } else {
21910
+ let after;
21911
+ for (;;) {
21912
+ const rows = await driver.fetchCollection({
21913
+ path: collection.slug,
21914
+ collection,
21915
+ limit: pageSize,
21916
+ withDeleted: true,
21917
+ ...after ? { startAfter: after } : {}
21918
+ });
21919
+ pageRead(rows);
21920
+ if (rows.length < pageSize) break;
21921
+ after = rows[rows.length - 1];
21922
+ }
21923
+ }
21924
+ scanned.push(collection.slug);
21925
+ } catch (error) {
21926
+ if (options.signal?.aborted) throw error;
21927
+ notRead({
21928
+ collection: collection.slug,
21929
+ reason: error instanceof Error ? error.message : String(error)
21448
21930
  });
21449
- for (const row of rows) forEachString(row, visit);
21450
- if (rows.length < pageSize) break;
21451
- after = rows[rows.length - 1];
21452
21931
  }
21453
- scanned.push(collection.slug);
21454
- } catch (error) {
21455
- unscanned.push({
21456
- collection: collection.slug,
21457
- reason: error instanceof Error ? error.message : String(error)
21932
+ } finally {
21933
+ if (progress) {
21934
+ progress.collectionsScanned++;
21935
+ progress.currentCollection = void 0;
21936
+ }
21937
+ }
21938
+ }
21939
+ options.onProgress?.();
21940
+ return {
21941
+ scanned,
21942
+ unscanned
21943
+ };
21944
+ }
21945
+ /** How row history is named among the scanned and unscanned collections. */
21946
+ var HISTORY_SCAN_NAME = "(row history)";
21947
+ /**
21948
+ * Remove from `keys` every key a history entry names, in its values or its
21949
+ * previous values. Resolves to why history could not be read, if it could not.
21950
+ */
21951
+ async function removeKeysInHistory(keys, history, options = {}) {
21952
+ if (!history.scanStoredValues) return {
21953
+ collection: HISTORY_SCAN_NAME,
21954
+ reason: "this backend's history service cannot be scanned"
21955
+ };
21956
+ try {
21957
+ for await (const values of history.scanStoredValues()) {
21958
+ if (keys.size === 0) return void 0;
21959
+ forEachString(values, (text) => {
21960
+ for (const key of keysNamedBy(text)) keys.delete(key);
21458
21961
  });
21962
+ if (options.progress) options.progress.historyEntriesScanned += values.length;
21963
+ options.onProgress?.();
21964
+ options.signal?.throwIfAborted();
21459
21965
  }
21966
+ return;
21967
+ } catch (error) {
21968
+ if (options.signal?.aborted) throw error;
21969
+ return {
21970
+ collection: HISTORY_SCAN_NAME,
21971
+ reason: error instanceof Error ? error.message : String(error)
21972
+ };
21973
+ }
21974
+ }
21975
+ /**
21976
+ * Remove from `keys` every key a row or a history entry names: the rows of
21977
+ * every collection, then history when the backend keeps it. What is left is
21978
+ * named by nothing — unless `unscanned` is not empty.
21979
+ */
21980
+ async function removeKeysNamedAnywhere(keys, options) {
21981
+ const { scanned, unscanned } = await removeReferencedKeys(keys, options.collections, options.driverFor, {
21982
+ globalCallbacks: options.globalCallbacks,
21983
+ progress: options.progress,
21984
+ onProgress: options.onProgress,
21985
+ signal: options.signal
21986
+ });
21987
+ if (options.history) {
21988
+ if (options.progress) options.progress.phase = "history";
21989
+ options.onProgress?.();
21990
+ const history = await removeKeysInHistory(keys, options.history, options);
21991
+ if (history) {
21992
+ unscanned.push(history);
21993
+ options.progress?.unscanned.push(history);
21994
+ } else scanned.push(HISTORY_SCAN_NAME);
21460
21995
  }
21461
21996
  return {
21462
21997
  scanned,
21463
21998
  unscanned
21464
21999
  };
21465
22000
  }
22001
+ /** A progress record at the start of a scan. */
22002
+ function emptyScanProgress() {
22003
+ return {
22004
+ phase: "listing",
22005
+ filesScanned: 0,
22006
+ rowsScanned: 0,
22007
+ historyEntriesScanned: 0,
22008
+ collectionsScanned: 0,
22009
+ collectionsTotal: 0,
22010
+ unscanned: []
22011
+ };
22012
+ }
21466
22013
  /**
21467
22014
  * The objects of one source that no row names.
21468
22015
  *
@@ -21471,18 +22018,24 @@ async function removeReferencedKeys(keys, collections, driverFor, options = {})
21471
22018
  * is saved. One whose age the listing does not say is not either.
21472
22019
  */
21473
22020
  async function findOrphans(options) {
22021
+ const progress = options.progress;
21474
22022
  const all = [];
21475
22023
  let cursor;
22024
+ if (progress) progress.phase = "listing";
21476
22025
  do {
22026
+ options.signal?.throwIfAborted();
21477
22027
  const page = await walkObjects(options.controller, {
21478
22028
  cursor,
21479
22029
  limit: 1e3
21480
22030
  });
21481
22031
  all.push(...page.objects);
22032
+ if (progress) progress.filesScanned = all.length;
22033
+ options.onProgress?.();
21482
22034
  cursor = page.cursor;
21483
22035
  } while (cursor);
21484
22036
  const unnamed = new Set(all.map((object) => object.key));
21485
- const { scanned, unscanned } = await removeReferencedKeys(unnamed, options.collections, options.driverFor);
22037
+ if (progress) progress.phase = "rows";
22038
+ const { scanned, unscanned } = await removeKeysNamedAnywhere(unnamed, options);
21486
22039
  const cutoff = (options.now ?? Date.now()) - options.olderThanMs;
21487
22040
  const orphans = [];
21488
22041
  let recent = 0;
@@ -21493,6 +22046,7 @@ async function findOrphans(options) {
21493
22046
  else recent++;
21494
22047
  }
21495
22048
  orphans.sort((a, b) => a.key.localeCompare(b.key));
22049
+ if (progress) progress.phase = "done";
21496
22050
  return {
21497
22051
  objects: all.length,
21498
22052
  orphans,
@@ -21501,6 +22055,93 @@ async function findOrphans(options) {
21501
22055
  unscanned
21502
22056
  };
21503
22057
  }
22058
+ /** How far a stored object's time may stray from the listing's and still be the same version. */
22059
+ var SAME_VERSION_SLACK_MS = 1e3;
22060
+ /**
22061
+ * Delete the orphans an earlier scan found — each only if it is still one.
22062
+ *
22063
+ * Every row and history entry is read again, against the candidates alone
22064
+ * (the source is not listed again): a row written since the scan may name one
22065
+ * now. When a collection cannot be read, nothing is deleted. Then, right
22066
+ * before each delete, the object is asked for again: one that is gone, was
22067
+ * written again since the scan (another size or time), or is now inside the
22068
+ * grace period is kept.
22069
+ */
22070
+ async function deleteScannedOrphans(options) {
22071
+ const progress = options.progress;
22072
+ const unnamed = new Set(options.candidates.map((candidate) => candidate.key));
22073
+ if (progress) progress.phase = "rows";
22074
+ const { unscanned } = await removeKeysNamedAnywhere(unnamed, options);
22075
+ const result = {
22076
+ deleted: [],
22077
+ kept: [],
22078
+ failed: [],
22079
+ unscanned
22080
+ };
22081
+ if (unscanned.length > 0) return result;
22082
+ if (progress) progress.phase = "deleting";
22083
+ options.onProgress?.();
22084
+ const now = options.now ?? Date.now;
22085
+ for (const candidate of options.candidates) {
22086
+ options.signal?.throwIfAborted();
22087
+ if (!unnamed.has(candidate.key)) {
22088
+ result.kept.push({
22089
+ key: candidate.key,
22090
+ reason: "a row or a history entry names it now"
22091
+ });
22092
+ continue;
22093
+ }
22094
+ try {
22095
+ const current = await versionOf(options.controller, candidate.key, candidate);
22096
+ const scannedAt = candidate.updated ? Date.parse(candidate.updated) : NaN;
22097
+ if (current === null) result.kept.push({
22098
+ key: candidate.key,
22099
+ reason: "it is gone already"
22100
+ });
22101
+ else if (candidate.size !== void 0 && current.size !== candidate.size || !Number.isFinite(scannedAt) || Math.abs(current.lastModified - scannedAt) > SAME_VERSION_SLACK_MS) result.kept.push({
22102
+ key: candidate.key,
22103
+ reason: "it was written again since the scan"
22104
+ });
22105
+ else if (current.lastModified > now() - options.olderThanMs) result.kept.push({
22106
+ key: candidate.key,
22107
+ reason: "it is newer than the grace period"
22108
+ });
22109
+ else {
22110
+ await options.controller.deleteObject(candidate.key);
22111
+ result.deleted.push(candidate.key);
22112
+ options.onDeleted?.(candidate.key);
22113
+ }
22114
+ } catch (error) {
22115
+ result.failed.push({
22116
+ key: candidate.key,
22117
+ error: error instanceof Error ? error.message : String(error)
22118
+ });
22119
+ }
22120
+ options.onProgress?.();
22121
+ }
22122
+ if (progress) progress.phase = "done";
22123
+ return result;
22124
+ }
22125
+ /**
22126
+ * An object's size and modification time now, or `null` when there is no such
22127
+ * object. A source without `statObject` says only its size, and the time is
22128
+ * then taken to be the one the scan listed.
22129
+ */
22130
+ async function versionOf(controller, key, listed) {
22131
+ if (controller.statObject) {
22132
+ const info = await controller.statObject(key);
22133
+ return info ? {
22134
+ size: info.size,
22135
+ lastModified: info.lastModified
22136
+ } : null;
22137
+ }
22138
+ const size = await sizeIn(controller, key);
22139
+ if (size === void 0) return null;
22140
+ return {
22141
+ size,
22142
+ lastModified: listed.updated ? Date.parse(listed.updated) : NaN
22143
+ };
22144
+ }
21504
22145
  /**
21505
22146
  * Copy the objects under a folder of one source into another, under the same
21506
22147
  * keys, `limit` objects per call. Hand the returned `cursor` back for the next
@@ -21567,6 +22208,77 @@ async function moveObjects(options) {
21567
22208
  cursor: page.cursor
21568
22209
  };
21569
22210
  }
22211
+ /**
22212
+ * List the source again after a move and name every file it did not move,
22213
+ * `limit` source objects per call; hand the returned `cursor` back for the
22214
+ * next ones.
22215
+ *
22216
+ * Defence in depth: a move reports what its own walk saw, and a walk that
22217
+ * skipped files (as the local listing's page token once did, over a folder
22218
+ * the move was emptying) reported success over the half it moved. A fresh
22219
+ * listing does not share the walk's state. With `deleteSource`, every file
22220
+ * still in the source is not moved; without it, a file the target holds no
22221
+ * copy of the same size of.
22222
+ */
22223
+ async function verifyMove(options) {
22224
+ const page = await walkObjects(options.from, {
22225
+ prefix: options.prefix,
22226
+ cursor: options.cursor,
22227
+ limit: options.limit ?? 100
22228
+ });
22229
+ const notMoved = [];
22230
+ for (const object of page.objects) {
22231
+ let copy;
22232
+ try {
22233
+ copy = await sizeIn(options.to, object.key);
22234
+ } catch (error) {
22235
+ notMoved.push({
22236
+ key: object.key,
22237
+ reason: `the target could not be asked for it: ${error instanceof Error ? error.message : String(error)}`
22238
+ });
22239
+ continue;
22240
+ }
22241
+ const held = copy !== void 0 && (object.size === void 0 || copy === object.size);
22242
+ if (options.deleteSource) notMoved.push({
22243
+ key: object.key,
22244
+ reason: held ? "still in the source; the target holds a copy, so running the move again deletes it" : "still in the source, and the target has no copy of the same size"
22245
+ });
22246
+ else if (copy === void 0) notMoved.push({
22247
+ key: object.key,
22248
+ reason: "the target has no copy"
22249
+ });
22250
+ else if (!held) notMoved.push({
22251
+ key: object.key,
22252
+ reason: `the target's copy is ${copy} bytes, the source's ${object.size}`
22253
+ });
22254
+ }
22255
+ return {
22256
+ checked: page.objects.length,
22257
+ notMoved,
22258
+ cursor: page.cursor
22259
+ };
22260
+ }
22261
+ /**
22262
+ * Whether two sources store in the same place — one bucket or directory under
22263
+ * two names. Asked by their `storageLocation` when both can say; otherwise by
22264
+ * a probe object written to `a` under the internal folder, looked for in `b`,
22265
+ * and deleted.
22266
+ */
22267
+ async function storesInSamePlace(a, b) {
22268
+ const here = a.storageLocation?.();
22269
+ const there = b.storageLocation?.();
22270
+ if (here !== void 0 && there !== void 0) return here === there;
22271
+ const key = `${INTERNAL_FOLDER}/move-probe/${randomUUID$1()}`;
22272
+ await a.putObject({
22273
+ file: new File(["probe"], "probe", { type: "text/plain" }),
22274
+ key
22275
+ }, { uploadLimits: false });
22276
+ try {
22277
+ return await sizeIn(b, key) !== void 0;
22278
+ } finally {
22279
+ await a.deleteObject(key).catch(() => void 0);
22280
+ }
22281
+ }
21570
22282
  /** An object's size in a source, or `undefined` when it has no such object. */
21571
22283
  async function sizeIn(controller, key) {
21572
22284
  if (controller.statObject) return (await controller.statObject(key))?.size;
@@ -21580,42 +22292,411 @@ async function customMetadataOf(controller, key) {
21580
22292
  return custom && Object.keys(custom).length > 0 ? custom : void 0;
21581
22293
  }
21582
22294
  //#endregion
22295
+ //#region src/storage/orphan-scans.ts
22296
+ /**
22297
+ * The orphan scan as background work: `rebase storage orphans` on a project
22298
+ * too big to scan inside one HTTP request.
22299
+ *
22300
+ * A scan reads every object of a source and every row of every collection,
22301
+ * plus history, before it can call anything unused. On a big project that is
22302
+ * minutes, and an ingress in front of the server answers 504 long before. So
22303
+ * a scan is started, runs in the background, and is asked about:
22304
+ *
22305
+ * start — writes the scan's record, then enqueues a `storage.orphans` job
22306
+ * on the durable job queue, or — on a backend that runs no job
22307
+ * queue — runs it in this process, in the background, and says so;
22308
+ * status — the record: progress while it runs (files listed, rows read,
22309
+ * collections that could not be read), the report when it is done;
22310
+ * delete — only from a completed scan, recent, that read every collection.
22311
+ * It is background work too: every row and history entry is read
22312
+ * again against the scan's candidates alone, and each file is asked
22313
+ * for again right before it is deleted.
22314
+ *
22315
+ * The record lives in the scanned source itself, under the reserved
22316
+ * `_rebase/maintenance/` folder, so whichever process runs the scan (a worker
22317
+ * pod) and whichever answers the status (any api pod) read the same one, on
22318
+ * any database driver, with nothing added to the schema.
22319
+ */
22320
+ /** The job task a scan, or a scan's delete, runs as. Frozen: workers of another release claim it by name. */
22321
+ var STORAGE_ORPHANS_TASK = "storage.orphans";
22322
+ /** Where scan records are kept inside the scanned source. */
22323
+ var ORPHAN_SCAN_FOLDER = `${MAINTENANCE_PREFIX}orphan-scans`;
22324
+ /**
22325
+ * How long after it completed a scan's result may still be deleted from.
22326
+ * Every candidate is checked again before it goes, so this is not what keeps
22327
+ * a file in use; it keeps a delete acting on what the person just read.
22328
+ */
22329
+ var ORPHAN_SCAN_TTL_MS = 15 * 6e4;
22330
+ /** A queued scan nobody has started after this long gets a note saying why that may be. */
22331
+ var QUEUED_NOTE_AFTER_MS = 6e4;
22332
+ /** The least time between two writes of a running scan's record. */
22333
+ var PROGRESS_WRITE_INTERVAL_MS = 2e3;
22334
+ /** Records older than this are deleted when the next scan of their source starts. */
22335
+ var RECORD_RETENTION_MS = 168 * 36e5;
22336
+ var SCAN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
22337
+ /**
22338
+ * The storage source a request names, or the 404 that lists the ones served.
22339
+ */
22340
+ function resolveStorageSource(sources, rawId, label) {
22341
+ if (rawId !== void 0 && typeof rawId !== "string") throw ApiError.badRequest(`\`${label}\` must be a storage source key.`);
22342
+ const id = canonicalStorageId(rawId);
22343
+ const controller = sources.registry ? sources.registry.get(id) : id === "(default)" ? sources.controller : void 0;
22344
+ if (!controller) throw new ApiError(404, "UNKNOWN_STORAGE_SOURCE", `This server has no storage source "${id}". It serves ${(sources.registry?.list() ?? (sources.controller ? ["(default)"] : [])).map((key) => `"${key}"`).join(", ") || "none"}.`);
22345
+ return controller;
22346
+ }
22347
+ /** `olderThanHours` as a request sends it, checked. */
22348
+ function parseOlderThanHours(raw) {
22349
+ if (raw === void 0 || raw === "") return 24;
22350
+ const hours = Number(raw);
22351
+ if (!Number.isFinite(hours) || hours < 0) throw ApiError.badRequest("`olderThanHours` must be a number of hours, 0 or more.");
22352
+ return hours;
22353
+ }
22354
+ var recordKey = (id) => `${ORPHAN_SCAN_FOLDER}/${id}.json`;
22355
+ var messageOf = (error) => error instanceof Error ? error.message : String(error);
22356
+ /** Whether a value read back from a source is a scan record. Only shape enough to act on it safely. */
22357
+ function isScanState(value, id) {
22358
+ if (!value || typeof value !== "object") return false;
22359
+ const record = value;
22360
+ return record.id === id && typeof record.storageId === "string" && typeof record.status === "string" && typeof record.olderThanHours === "number" && !!record.progress && typeof record.progress === "object";
22361
+ }
22362
+ /**
22363
+ * Writes a running scan's record at most every {@link PROGRESS_WRITE_INTERVAL_MS},
22364
+ * one write at a time, in order.
22365
+ */
22366
+ function progressWriter(write, now) {
22367
+ let last = 0;
22368
+ let chain = Promise.resolve();
22369
+ const enqueue = () => {
22370
+ chain = chain.then(write).catch((error) => {
22371
+ logger.warn("[storage] Could not record an orphan scan's progress", { error });
22372
+ });
22373
+ return chain;
22374
+ };
22375
+ return {
22376
+ tick() {
22377
+ if (now() - last < PROGRESS_WRITE_INTERVAL_MS) return;
22378
+ last = now();
22379
+ enqueue();
22380
+ },
22381
+ /** Write now, after any write in flight, and wait for it. */
22382
+ async flush() {
22383
+ last = now();
22384
+ await enqueue();
22385
+ }
22386
+ };
22387
+ }
22388
+ function createOrphanScanService(config) {
22389
+ const now = config.now ?? Date.now;
22390
+ const iso = () => new Date(now()).toISOString();
22391
+ const sourceOf = (rawId) => resolveStorageSource(config, rawId, "storageId");
22392
+ async function read(controller, id) {
22393
+ if (!SCAN_ID.test(id)) return void 0;
22394
+ const file = await controller.getObject(recordKey(id));
22395
+ if (!file) return void 0;
22396
+ let parsed;
22397
+ try {
22398
+ parsed = JSON.parse(await file.text());
22399
+ } catch {
22400
+ return;
22401
+ }
22402
+ return isScanState(parsed, id) ? parsed : void 0;
22403
+ }
22404
+ async function write(controller, state) {
22405
+ state.updatedAt = iso();
22406
+ if (state.deletion && (state.deletion.status === "queued" || state.deletion.status === "running")) state.deletion.updatedAt = state.updatedAt;
22407
+ const body = JSON.stringify(state);
22408
+ await controller.putObject({
22409
+ file: new File([body], `${state.id}.json`, { type: "application/json" }),
22410
+ key: recordKey(state.id)
22411
+ }, { uploadLimits: false });
22412
+ }
22413
+ /** Delete records past retention, best effort: a scan never fails over housekeeping. */
22414
+ async function prune(controller) {
22415
+ try {
22416
+ const cutoff = now() - RECORD_RETENTION_MS;
22417
+ let pageToken;
22418
+ do {
22419
+ const page = await controller.listObjects(ORPHAN_SCAN_FOLDER, {
22420
+ maxResults: 1e3,
22421
+ pageToken
22422
+ });
22423
+ for (const item of page.items) {
22424
+ const written = item.updated ? Date.parse(item.updated) : NaN;
22425
+ if (Number.isFinite(written) && written < cutoff) await controller.deleteObject(item.fullPath).catch(() => void 0);
22426
+ }
22427
+ pageToken = page.nextPageToken;
22428
+ } while (pageToken);
22429
+ } catch (error) {
22430
+ logger.debug("[storage] Could not prune old orphan scan records", { error });
22431
+ }
22432
+ }
22433
+ /** Hand a scan or a delete to the job queue, or run it here in the background. Resolves with who runs it. */
22434
+ async function dispatch(payload) {
22435
+ const queue = config.jobs?.();
22436
+ if (queue) {
22437
+ const jobId = await queue.enqueue(STORAGE_ORPHANS_TASK, payload, { maxAttempts: 1 });
22438
+ return {
22439
+ runner: "worker",
22440
+ ...jobId ? { jobId } : {}
22441
+ };
22442
+ }
22443
+ setImmediate(() => {
22444
+ runTask(payload).catch((error) => {
22445
+ logger.error("[storage] An orphan scan running in this process failed", {
22446
+ scanId: payload.scanId,
22447
+ error
22448
+ });
22449
+ });
22450
+ });
22451
+ return { runner: "in-process" };
22452
+ }
22453
+ /** The record as a caller should read it: a stalled run said as failed, a long queue explained. */
22454
+ function asReported(state) {
22455
+ const reported = { ...state };
22456
+ const age = (at) => now() - (at ? Date.parse(at) : 0);
22457
+ const waiting = `It is a "${STORAGE_ORPHANS_TASK}" job on the job queue, run by a process whose job workers are on (the all or worker role, REBASE_JOB_WORKERS) and that runs this release.`;
22458
+ if (state.status === "running" && age(state.updatedAt) > 6e5) {
22459
+ reported.status = "failed";
22460
+ reported.error = `The scan stopped reporting progress ${Math.round(age(state.updatedAt) / 6e4)} minutes ago: the process running it stopped (a deploy, a restart or a crash). Start another scan.`;
22461
+ } else if (state.status === "queued" && age(state.createdAt) > QUEUED_NOTE_AFTER_MS) if (state.runner === "worker") reported.note = `No job worker has started this scan yet. ${waiting}`;
22462
+ else {
22463
+ reported.status = "failed";
22464
+ reported.error = "The scan was to run in the server process that was asked, and never started: that process restarted. Start another scan.";
22465
+ }
22466
+ const deletion = state.deletion;
22467
+ if (deletion && deletion.status === "running" && age(deletion.updatedAt) > 6e5) reported.deletion = {
22468
+ ...deletion,
22469
+ status: "failed",
22470
+ error: "The delete stopped reporting progress: the process running it stopped. Some files may have been deleted. Start another scan to see what is left."
22471
+ };
22472
+ else if (deletion && deletion.status === "queued" && age(deletion.requestedAt) > QUEUED_NOTE_AFTER_MS) reported.deletion = deletion.runner === "worker" ? {
22473
+ ...deletion,
22474
+ note: `No job worker has started this delete yet. ${waiting}`
22475
+ } : {
22476
+ ...deletion,
22477
+ status: "failed",
22478
+ error: "The delete was to run in the server process that was asked, and never started. Nothing was deleted."
22479
+ };
22480
+ return reported;
22481
+ }
22482
+ async function runScan(controller, state, signal) {
22483
+ state.status = "running";
22484
+ state.startedAt = iso();
22485
+ state.progress = emptyScanProgress();
22486
+ await write(controller, state);
22487
+ const writer = progressWriter(() => write(controller, state), now);
22488
+ try {
22489
+ state.result = await findOrphans({
22490
+ controller,
22491
+ collections: config.collections(),
22492
+ driverFor: config.driverFor,
22493
+ globalCallbacks: config.globalCallbacks?.(),
22494
+ history: config.history,
22495
+ olderThanMs: state.olderThanHours * 36e5,
22496
+ now: now(),
22497
+ progress: state.progress,
22498
+ onProgress: () => writer.tick(),
22499
+ signal
22500
+ });
22501
+ state.status = "completed";
22502
+ state.completedAt = iso();
22503
+ } catch (error) {
22504
+ state.status = "failed";
22505
+ state.error = signal?.aborted ? `The scan was stopped: ${messageOf(signal.reason ?? error)}. A scan is one job attempt, limited by jobs.timeoutMs (15 minutes by default); raise it, or set it to 0, for a source this big.` : messageOf(error);
22506
+ throw error;
22507
+ } finally {
22508
+ await writer.flush();
22509
+ }
22510
+ }
22511
+ async function runDeletion(controller, state, signal) {
22512
+ const deletion = state.deletion;
22513
+ const result = state.result;
22514
+ if (!deletion || !result) return;
22515
+ deletion.status = "running";
22516
+ deletion.startedAt = iso();
22517
+ deletion.progress = {
22518
+ ...emptyScanProgress(),
22519
+ phase: "rows",
22520
+ candidates: result.orphans.length,
22521
+ deleted: 0
22522
+ };
22523
+ await write(controller, state);
22524
+ const writer = progressWriter(() => write(controller, state), now);
22525
+ try {
22526
+ const outcome = await deleteScannedOrphans({
22527
+ controller,
22528
+ candidates: result.orphans,
22529
+ collections: config.collections(),
22530
+ driverFor: config.driverFor,
22531
+ globalCallbacks: config.globalCallbacks?.(),
22532
+ history: config.history,
22533
+ olderThanMs: state.olderThanHours * 36e5,
22534
+ now,
22535
+ progress: deletion.progress,
22536
+ onProgress: () => writer.tick(),
22537
+ onDeleted: () => {
22538
+ deletion.progress.deleted++;
22539
+ },
22540
+ signal
22541
+ });
22542
+ deletion.result = {
22543
+ deleted: outcome.deleted,
22544
+ kept: outcome.kept,
22545
+ failed: outcome.failed
22546
+ };
22547
+ if (outcome.unscanned.length > 0) {
22548
+ deletion.status = "failed";
22549
+ deletion.error = "Some collections could not be read again, so a file they name now would look unused. Nothing was deleted: " + outcome.unscanned.map((entry) => `${entry.collection} (${entry.reason})`).join("; ");
22550
+ } else deletion.status = "completed";
22551
+ deletion.completedAt = iso();
22552
+ } catch (error) {
22553
+ deletion.status = "failed";
22554
+ deletion.error = messageOf(signal?.aborted ? signal.reason ?? error : error);
22555
+ throw error;
22556
+ } finally {
22557
+ await writer.flush();
22558
+ }
22559
+ }
22560
+ async function runTask(payload, signal) {
22561
+ const controller = sourceOf(payload.storageId);
22562
+ const state = await read(controller, payload.scanId);
22563
+ if (!state) {
22564
+ logger.warn("[storage] An orphan scan job names a scan its source holds no record of; nothing to do", { ...payload });
22565
+ return;
22566
+ }
22567
+ if (payload.mode === "scan") {
22568
+ if (state.status !== "queued") return;
22569
+ await runScan(controller, state, signal);
22570
+ } else {
22571
+ if (state.deletion?.status !== "queued") return;
22572
+ await runDeletion(controller, state, signal);
22573
+ }
22574
+ }
22575
+ return {
22576
+ async start(options) {
22577
+ const controller = sourceOf(options.storageId);
22578
+ const storageId = canonicalStorageId(options.storageId);
22579
+ const olderThanHours = options.olderThanHours ?? 24;
22580
+ await prune(controller);
22581
+ const at = iso();
22582
+ const state = {
22583
+ id: randomUUID$1(),
22584
+ storageId,
22585
+ olderThanHours,
22586
+ runner: config.jobs?.() ? "worker" : "in-process",
22587
+ status: "queued",
22588
+ createdAt: at,
22589
+ updatedAt: at,
22590
+ progress: emptyScanProgress()
22591
+ };
22592
+ await write(controller, state);
22593
+ const { runner, jobId } = await dispatch({
22594
+ scanId: state.id,
22595
+ storageId,
22596
+ mode: "scan"
22597
+ });
22598
+ return {
22599
+ ...state,
22600
+ runner,
22601
+ ...jobId ? { jobId } : {}
22602
+ };
22603
+ },
22604
+ async status(storageId, id) {
22605
+ const state = await read(sourceOf(storageId), id);
22606
+ return state ? asReported(state) : void 0;
22607
+ },
22608
+ async requestDeletion(storageId, id) {
22609
+ const controller = sourceOf(storageId);
22610
+ const stored = await read(controller, id);
22611
+ if (!stored) throw new ApiError(404, "STORAGE_SCAN_NOT_FOUND", `Source "${canonicalStorageId(storageId)}" holds no orphan scan "${id}". Scans are kept for 7 days; start another.`);
22612
+ const state = asReported(stored);
22613
+ if (state.status !== "completed" || !state.result || !state.completedAt) throw new ApiError(409, "STORAGE_SCAN_NOT_COMPLETE", state.status === "failed" ? `Scan "${id}" failed (${state.error ?? "no reason recorded"}), so it found nothing to delete. Start another scan.` : `Scan "${id}" is ${state.status}. Nothing is deleted until it has completed; ask for its status until it has.`, { status: state.status });
22614
+ const unscanned = state.result.unscanned;
22615
+ if (unscanned.length > 0) throw ApiError.conflict("Some collections could not be read, so a file they name would look unused. Nothing was deleted: " + unscanned.map((entry) => `${entry.collection} (${entry.reason})`).join("; "), "STORAGE_SCAN_INCOMPLETE", { unscanned });
22616
+ const age = now() - Date.parse(state.completedAt);
22617
+ if (!(age <= 9e5)) throw new ApiError(409, "STORAGE_SCAN_EXPIRED", `Scan "${id}" completed ${Math.round(age / 6e4)} minutes ago; a delete acts only on a scan that completed in the last ${ORPHAN_SCAN_TTL_MS / 6e4} minutes. Nothing was deleted. Start another scan.`);
22618
+ if (state.deletion && state.deletion.status !== "failed") throw new ApiError(409, "STORAGE_SCAN_DELETION_STARTED", `The orphans of scan "${id}" are already being deleted (${state.deletion.status}). Ask for its status.`, { status: state.deletion.status });
22619
+ const at = iso();
22620
+ stored.deletion = {
22621
+ status: "queued",
22622
+ runner: config.jobs?.() ? "worker" : "in-process",
22623
+ requestedAt: at,
22624
+ updatedAt: at,
22625
+ progress: {
22626
+ ...emptyScanProgress(),
22627
+ phase: "rows",
22628
+ candidates: state.result.orphans.length,
22629
+ deleted: 0
22630
+ }
22631
+ };
22632
+ await write(controller, stored);
22633
+ const { runner, jobId } = await dispatch({
22634
+ scanId: id,
22635
+ storageId: stored.storageId,
22636
+ mode: "delete"
22637
+ });
22638
+ stored.deletion.runner = runner;
22639
+ if (jobId) stored.deletion.jobId = jobId;
22640
+ return asReported(stored);
22641
+ },
22642
+ runTask
22643
+ };
22644
+ }
22645
+ //#endregion
21583
22646
  //#region src/storage/maintenance-routes.ts
21584
22647
  /**
21585
22648
  * `/admin/storage` — the routes behind `rebase storage orphans` and
21586
22649
  * `rebase storage move`. Admin-only; the caller mounts them behind the admin
21587
- * gate (`database:read` to read, `database:write` to delete or move).
22650
+ * gate (`database:read` to read, `database:write` for every other method).
21588
22651
  *
21589
- * GET /orphans?storageId=&olderThanHours= objects no row names
21590
- * DELETE /orphans?storageId=&olderThanHours= delete them, found again first
22652
+ * POST /orphans/scans?storageId=&olderThanHours= start a scan, in the background
22653
+ * GET /orphans/scans/:id?storageId= its progress, then its report
22654
+ * DELETE /orphans?scan=:id&storageId= delete a completed scan's orphans, in the background
22655
+ * GET /orphans?storageId=&olderThanHours= deprecated: a scan inside the request
22656
+ * DELETE /orphans?storageId=&olderThanHours= deprecated: scan and delete inside the request
21591
22657
  * POST /move { from, to, prefix?, cursor?, limit?, deleteSource? }
22658
+ * POST /move/verify (the same body) what a move left behind
21592
22659
  */
21593
- /** The default grace period: an upload whose row is not saved yet is a day old at most. */
21594
- var DEFAULT_OLDER_THAN_HOURS = 24;
22660
+ /** Where the scan-in-a-request routes send their callers. */
22661
+ var SCAN_SUCCESSOR = "/api/admin/storage/orphans/scans";
22662
+ /** Mark a response of a deprecated scan-in-a-request route, and say so once per process. */
22663
+ function deprecatedScan(c, what) {
22664
+ c.header("Deprecation", "true");
22665
+ c.header("Link", `<${SCAN_SUCCESSOR}>; rel="successor-version"`);
22666
+ warnDeprecated(`storage.orphans.${what}`, `${what} /api/admin/storage/orphans without ?scan= scans inside the request, which an ingress times out on a big project. Start a scan with POST /api/admin/storage/orphans/scans, ask GET /api/admin/storage/orphans/scans/:id, and delete with DELETE /api/admin/storage/orphans?scan=:id. \`rebase storage orphans\` does this; the in-request form will be removed in a later release.`);
22667
+ }
21595
22668
  function createStorageMaintenanceRoutes(config) {
21596
22669
  const router = new Hono();
21597
- const sourceOf = (rawId, label) => {
21598
- if (rawId !== void 0 && typeof rawId !== "string") throw ApiError.badRequest(`\`${label}\` must be a storage source key.`);
21599
- const id = canonicalStorageId(rawId);
21600
- const controller = config.registry ? config.registry.get(id) : id === "(default)" ? config.controller : void 0;
21601
- if (!controller) throw new ApiError(404, "UNKNOWN_STORAGE_SOURCE", `This server has no storage source "${id}". It serves ${(config.registry?.list() ?? (config.controller ? ["(default)"] : [])).map((key) => `"${key}"`).join(", ") || "none"}.`);
21602
- return controller;
21603
- };
21604
- const olderThanMs = (raw) => {
21605
- if (raw === void 0 || raw === "") return DEFAULT_OLDER_THAN_HOURS * 36e5;
21606
- const hours = Number(raw);
21607
- if (!Number.isFinite(hours) || hours < 0) throw ApiError.badRequest("`olderThanHours` must be a number of hours, 0 or more.");
21608
- return hours * 36e5;
21609
- };
22670
+ const scans = config.orphanScans ?? createOrphanScanService(config);
22671
+ const sourceOf = (rawId, label) => resolveStorageSource(config, rawId, label);
21610
22672
  const report = async (storageId, olderThan) => {
21611
22673
  return findOrphans({
21612
22674
  controller: sourceOf(storageId, "storageId"),
21613
22675
  collections: config.collections(),
21614
22676
  driverFor: config.driverFor,
21615
- olderThanMs: olderThanMs(olderThan)
22677
+ globalCallbacks: config.globalCallbacks?.(),
22678
+ history: config.history,
22679
+ olderThanMs: parseOlderThanHours(olderThan) * 36e5
21616
22680
  });
21617
22681
  };
22682
+ router.post("/orphans/scans", async (c) => {
22683
+ const olderThanHours = parseOlderThanHours(c.req.query("olderThanHours"));
22684
+ const state = await scans.start({
22685
+ storageId: c.req.query("storageId"),
22686
+ olderThanHours
22687
+ });
22688
+ return c.json({ data: state }, 202);
22689
+ });
22690
+ router.get("/orphans/scans/:id", async (c) => {
22691
+ const storageId = c.req.query("storageId");
22692
+ const id = c.req.param("id");
22693
+ const state = await scans.status(storageId, id);
22694
+ if (!state) throw new ApiError(404, "STORAGE_SCAN_NOT_FOUND", `Source "${canonicalStorageId(storageId)}" holds no orphan scan "${id}". Scans are kept for 7 days; start another.`);
22695
+ return c.json({ data: state });
22696
+ });
22697
+ /** @deprecated Start a scan with `POST /orphans/scans` instead. Kept for one release. */
21618
22698
  router.get("/orphans", async (c) => {
22699
+ deprecatedScan(c, "GET");
21619
22700
  const storageId = c.req.query("storageId");
21620
22701
  const found = await report(storageId, c.req.query("olderThanHours"));
21621
22702
  return c.json({ data: {
@@ -21625,6 +22706,12 @@ function createStorageMaintenanceRoutes(config) {
21625
22706
  });
21626
22707
  router.delete("/orphans", async (c) => {
21627
22708
  const storageId = c.req.query("storageId");
22709
+ const scanId = c.req.query("scan");
22710
+ if (scanId !== void 0) {
22711
+ const state = await scans.requestDeletion(storageId, scanId);
22712
+ return c.json({ data: state }, 202);
22713
+ }
22714
+ deprecatedScan(c, "DELETE");
21628
22715
  const controller = sourceOf(storageId, "storageId");
21629
22716
  const found = await report(storageId, c.req.query("olderThanHours"));
21630
22717
  if (found.unscanned.length > 0) throw ApiError.conflict("Some collections could not be read, so a file they name would look unused. Nothing was deleted: " + found.unscanned.map((entry) => `${entry.collection} (${entry.reason})`).join("; "), "STORAGE_SCAN_INCOMPLETE", { unscanned: found.unscanned });
@@ -21646,7 +22733,8 @@ function createStorageMaintenanceRoutes(config) {
21646
22733
  recent: found.recent
21647
22734
  } });
21648
22735
  });
21649
- router.post("/move", async (c) => {
22736
+ /** A move's JSON body, checked: the two sources, and the walk's options. */
22737
+ const moveRequest = async (c) => {
21650
22738
  const body = await c.req.json().catch(() => void 0);
21651
22739
  if (!body || typeof body !== "object" || Array.isArray(body)) throw ApiError.badRequest("The body must be a JSON object: { from, to, prefix?, cursor?, limit?, deleteSource? }.");
21652
22740
  const field = (name) => body[name];
@@ -21658,10 +22746,45 @@ function createStorageMaintenanceRoutes(config) {
21658
22746
  const limit = field("limit");
21659
22747
  const deleteSource = field("deleteSource");
21660
22748
  if (prefix !== void 0 && typeof prefix !== "string" || cursor !== void 0 && typeof cursor !== "string" || limit !== void 0 && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) || deleteSource !== void 0 && typeof deleteSource !== "boolean") throw ApiError.badRequest("`prefix` and `cursor` are strings, `limit` a whole number from 1 to 500, `deleteSource` a boolean.");
22749
+ return {
22750
+ fromId,
22751
+ toId,
22752
+ from: sourceOf(field("from"), "from"),
22753
+ to: sourceOf(field("to"), "to"),
22754
+ prefix,
22755
+ cursor,
22756
+ limit,
22757
+ deleteSource
22758
+ };
22759
+ };
22760
+ router.post("/move", async (c) => {
22761
+ const { fromId, toId, from, to, prefix, cursor, limit, deleteSource } = await moveRequest(c);
22762
+ if (deleteSource && await storesInSamePlace(from, to)) throw new ApiError(400, "STORAGE_SAME_LOCATION", `"${fromId}" and "${toId}" store in the same place, so deleting the source's copy would delete the only one. Nothing was moved or deleted. Point the property at the other source instead; its files are already there.`);
21661
22763
  try {
21662
22764
  const result = await moveObjects({
21663
- from: sourceOf(field("from"), "from"),
21664
- to: sourceOf(field("to"), "to"),
22765
+ from,
22766
+ to,
22767
+ prefix,
22768
+ cursor,
22769
+ limit,
22770
+ deleteSource
22771
+ });
22772
+ return c.json({ data: {
22773
+ from: fromId,
22774
+ to: toId,
22775
+ ...result
22776
+ } });
22777
+ } catch (error) {
22778
+ if (error instanceof InvalidMaintenanceRequestError) throw ApiError.badRequest(error.message);
22779
+ throw error;
22780
+ }
22781
+ });
22782
+ router.post("/move/verify", async (c) => {
22783
+ const { fromId, toId, from, to, prefix, cursor, limit, deleteSource } = await moveRequest(c);
22784
+ try {
22785
+ const result = await verifyMove({
22786
+ from,
22787
+ to,
21665
22788
  prefix,
21666
22789
  cursor,
21667
22790
  limit,
@@ -21711,11 +22834,12 @@ var AUTHORIZATION_CODE_TTL_MS = 6e4;
21711
22834
  * server that cannot persist a code is not a degraded authorization server, it
21712
22835
  * is one that hands out credentials it cannot check.
21713
22836
  */
21714
- function createOAuthStore(driver) {
22837
+ function createOAuthStore(driver, options = {}) {
21715
22838
  const admin = driver.admin;
21716
22839
  if (!isSQLAdmin(admin)) return null;
21717
- const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
21718
- const ddl = createDdlBootstrapper((sql) => exec(sql), "oauth-store");
22840
+ const run = provisioningExec((sql, execOptions) => admin.executeSql(sql, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
22841
+ const exec = (sql, params) => run(sql, params ? { params } : void 0);
22842
+ const ddl = createDdlBootstrapper(run, "oauth-store");
21719
22843
  const sweepCodes = () => exec(`DELETE FROM ${CODES} WHERE expires_at < now() - interval '1 hour'`);
21720
22844
  const sweepRefreshTokens = () => exec(`DELETE FROM ${REFRESH} WHERE expires_at < now() - interval '30 days'`);
21721
22845
  let lastSweep = Date.now();
@@ -21792,6 +22916,7 @@ function createOAuthStore(driver) {
21792
22916
  lastSweep = Date.now();
21793
22917
  const missing = [];
21794
22918
  for (const table of OAUTH_TABLES) if ((await exec(`SELECT to_regclass($1) AS present`, [table]))[0]?.present == null) missing.push(table);
22919
+ if (missing.length > 0 && options.provision === false) throw new Error(`The OAuth tables do not exist: ${missing.join(", ")}, and this backend changes nothing in its database. A provisioning run with the MCP surface on creates them.`);
21795
22920
  if (missing.length > 0) throw new Error(`The OAuth tables were not created: ${missing.join(", ")}. The MCP surface cannot be served without them — check the boot log above for the statement that failed, which is usually a permissions problem on the \`rebase\` schema.`);
21796
22921
  },
21797
22922
  async registerClient(client) {
@@ -22277,6 +23402,9 @@ function consentPageHeaders(nonce) {
22277
23402
  }
22278
23403
  function renderConsentPage(params) {
22279
23404
  const clientName = escapeHtml$2(params.clientName.slice(0, 120));
23405
+ const resource = escapeHtml$2(params.resource);
23406
+ const destination = describeRedirect(params.redirectUri);
23407
+ const permissions = params.scopeDescriptions.map((s) => `<li><strong>${escapeHtml$2(s.description)}</strong></li>`).join("");
22280
23408
  return `<!doctype html>
22281
23409
  <html lang="en">
22282
23410
  <head>
@@ -22328,15 +23456,31 @@ function renderConsentPage(params) {
22328
23456
  button[disabled] { opacity: .55; cursor: progress; }
22329
23457
  .error { color: var(--danger); font-size: 13px; margin-top: 12px; min-height: 1em; }
22330
23458
  .who { font-size: 13px; color: var(--muted); margin-top: 4px; }
23459
+ .destination {
23460
+ margin: 0 0 18px; padding: 12px 14px; border-radius: 9px; font-size: 13.5px;
23461
+ border: 1px solid var(--line); background: var(--bg); word-break: break-all;
23462
+ }
23463
+ .destination strong { font-size: 15px; color: var(--ink); }
23464
+ .destination span { display: block; color: var(--muted); margin-top: 4px; word-break: normal; }
23465
+ .note { font-size: 12.5px; color: var(--muted); margin: 14px 0 0; }
23466
+ input[inputmode=text] {
23467
+ width: 100%; padding: 9px 11px; border-radius: 8px; letter-spacing: .08em;
23468
+ border: 1px solid var(--line); background: var(--bg); color: var(--ink); font: inherit;
23469
+ }
22331
23470
  </style>
22332
23471
  </head>
22333
23472
  <body>
22334
23473
  <main class="card">
22335
23474
  <h1>&ldquo;${clientName}&rdquo; wants access to your account</h1>
22336
- <p class="resource">${escapeHtml$2(params.resource)}</p>
23475
+ <p class="resource">${resource}</p>
23476
+
23477
+ <p class="destination">
23478
+ After you allow, you will be sent back to <strong>${escapeHtml$2(destination.label)}</strong>
23479
+ <span>${escapeHtml$2(destination.note)}</span>
23480
+ </p>
22337
23481
 
22338
23482
  <p style="margin:0 0 8px">If you allow it, this application will be able to:</p>
22339
- <ul>${params.scopeDescriptions.map((s) => `<li><strong>${escapeHtml$2(s.description)}</strong></li>`).join("")}</ul>
23483
+ <ul>${permissions}</ul>
22340
23484
 
22341
23485
  <div class="limits">
22342
23486
  It will act <strong>as you</strong>, and can never see more than you can:
@@ -22355,6 +23499,13 @@ function renderConsentPage(params) {
22355
23499
  <input type="email" id="email" name="email" autocomplete="username" required>
22356
23500
  <label for="password">Password</label>
22357
23501
  <input type="password" id="password" name="password" autocomplete="current-password" required>
23502
+ <p class="note">Signed in to this app with Google, GitHub or an email link before? This screen
23503
+ needs a password. Use &ldquo;Forgot password&rdquo; on the app's sign-in screen to set one, then
23504
+ come back.</p>
23505
+ </div>
23506
+ <div id="mfa" hidden>
23507
+ <label for="mfa_code">Code from your authenticator app, or a recovery code</label>
23508
+ <input type="text" id="mfa_code" name="mfa_code" inputmode="text" autocomplete="one-time-code">
22358
23509
  </div>
22359
23510
  <p class="who" id="who" hidden></p>
22360
23511
 
@@ -22371,20 +23522,136 @@ function renderConsentPage(params) {
22371
23522
  var form = document.getElementById("consent");
22372
23523
  var email = document.getElementById("email");
22373
23524
  var password = document.getElementById("password");
23525
+ var signin = document.getElementById("signin");
23526
+ var mfa = document.getElementById("mfa");
23527
+ var mfaCode = document.getElementById("mfa_code");
22374
23528
  var sessionToken = document.getElementById("session_token");
22375
23529
  var decision = document.getElementById("decision");
22376
23530
  var error = document.getElementById("error");
22377
23531
  var allow = document.getElementById("allow");
22378
23532
 
23533
+ // Set once the password was accepted and the account answered MFA_REQUIRED.
23534
+ var mfaToken = null;
23535
+ var factorId = null;
23536
+ var challengeId = null;
23537
+
22379
23538
  document.getElementById("deny").addEventListener("click", function () {
22380
23539
  decision.value = "deny";
22381
- // The password fields are required, and a denial must not be blocked by
23540
+ // The credential fields are required, and a denial must not be blocked by
22382
23541
  // them: refusing is the one answer that should never need a credential.
22383
- email.removeAttribute("required");
22384
- password.removeAttribute("required");
23542
+ email.required = false;
23543
+ password.required = false;
23544
+ mfaCode.required = false;
22385
23545
  form.submit();
22386
23546
  });
22387
23547
 
23548
+ // The words to show for a failed answer. The canonical envelope is
23549
+ // { error: { message, code } }; the flat spellings are what an OAuth-style
23550
+ // body or a deployment's transformed auth response may carry instead.
23551
+ function messageOf(r, fallback) {
23552
+ var b = r.body;
23553
+ if (b && b.error && typeof b.error === "object" && typeof b.error.message === "string") return b.error.message;
23554
+ if (b && typeof b.error_description === "string") return b.error_description;
23555
+ if (b && typeof b.message === "string") return b.message;
23556
+ if (b && typeof b.error === "string") return b.error;
23557
+ return fallback + (r.status ? " (HTTP " + r.status + ")." : ".");
23558
+ }
23559
+ function codeOf(r) {
23560
+ var b = r.body;
23561
+ return (b && b.error && typeof b.error === "object" && b.error.code) || null;
23562
+ }
23563
+ function failure(message) {
23564
+ var err = new Error(message);
23565
+ err.shown = true;
23566
+ return err;
23567
+ }
23568
+
23569
+ // A body that is not JSON (a proxy's HTML error page) is a failure with no
23570
+ // message, not a parse error to print.
23571
+ function post(url, body, bearer) {
23572
+ var headers = { "Content-Type": "application/json" };
23573
+ if (bearer) headers.Authorization = "Bearer " + bearer;
23574
+ return fetch(url, { method: "POST", headers: headers, body: JSON.stringify(body) })
23575
+ .then(function (res) {
23576
+ return res.json().then(
23577
+ function (b) { return { ok: res.ok, status: res.status, body: b }; },
23578
+ function () { return { ok: res.ok, status: res.status, body: null }; }
23579
+ );
23580
+ });
23581
+ }
23582
+
23583
+ // buildAuthResponse returns { user, tokens: { accessToken } }. The flatter
23584
+ // spellings are fallbacks rather than guesses: the response passes through
23585
+ // applyTransformHook, so a deployment can legitimately reshape it.
23586
+ //
23587
+ // No backticks anywhere in this script: it lives inside a template literal,
23588
+ // and one would end the string early.
23589
+ function sessionFrom(r) {
23590
+ var tokens = (r.body && r.body.tokens) || {};
23591
+ return tokens.accessToken || (r.body && (r.body.accessToken || r.body.access_token)) || null;
23592
+ }
23593
+
23594
+ function finish(token) {
23595
+ sessionToken.value = token;
23596
+ form.submit();
23597
+ }
23598
+
23599
+ function openChallenge() {
23600
+ return post(${JSON.stringify(params.mfaChallengeUrl)}, { factorId: factorId }, mfaToken).then(function (r) {
23601
+ if (!r.ok || !r.body || !r.body.challengeId) throw failure(messageOf(r, "Could not start the second step"));
23602
+ challengeId = r.body.challengeId;
23603
+ });
23604
+ }
23605
+
23606
+ function signIn() {
23607
+ // The password goes to the EXISTING login endpoint and nowhere else. This
23608
+ // page never posts it to the OAuth surface, which sees only the resulting
23609
+ // session token.
23610
+ return post(${JSON.stringify(params.loginUrl)}, { email: email.value, password: password.value }).then(function (r) {
23611
+ var token = sessionFrom(r);
23612
+ if (r.ok && token) return finish(token);
23613
+
23614
+ var details = r.body && r.body.error && r.body.error.details;
23615
+ if (codeOf(r) === "MFA_REQUIRED" && details && details.mfaToken) {
23616
+ var factors = details.factors || [];
23617
+ var factor = null;
23618
+ for (var i = 0; i < factors.length; i++) {
23619
+ if (factors[i].factorType === "totp") { factor = factors[i]; break; }
23620
+ }
23621
+ factor = factor || factors[0];
23622
+ if (!factor) throw failure("This account needs a second factor, but has none this screen can use.");
23623
+ mfaToken = details.mfaToken;
23624
+ factorId = factor.id;
23625
+ return openChallenge().then(function () {
23626
+ signin.hidden = true;
23627
+ email.required = false;
23628
+ password.required = false;
23629
+ mfa.hidden = false;
23630
+ mfaCode.required = true;
23631
+ allow.textContent = "Verify and allow";
23632
+ mfaCode.focus();
23633
+ });
23634
+ }
23635
+ throw failure(messageOf(r, "Sign-in failed"));
23636
+ });
23637
+ }
23638
+
23639
+ function verifyCode() {
23640
+ var code = String(mfaCode.value || "").trim();
23641
+ return post(${JSON.stringify(params.mfaVerifyUrl)}, { challengeId: challengeId, code: code }, mfaToken).then(function (r) {
23642
+ var token = sessionFrom(r);
23643
+ if (r.ok && token) return finish(token);
23644
+ var message = messageOf(r, "Verification failed");
23645
+ var errorCode = codeOf(r);
23646
+ // A spent or expired challenge cannot take another guess; open a fresh
23647
+ // one so the person can simply type the next code.
23648
+ if (errorCode === "CHALLENGE_EXHAUSTED" || errorCode === "INVALID_CHALLENGE") {
23649
+ return openChallenge().then(function () { throw failure(message + " Enter a new code."); });
23650
+ }
23651
+ throw failure(message);
23652
+ });
23653
+ }
23654
+
22388
23655
  form.addEventListener("submit", function (event) {
22389
23656
  if (decision.value === "deny") return;
22390
23657
  if (sessionToken.value) return; // already signed in; let it post
@@ -22393,37 +23660,11 @@ function renderConsentPage(params) {
22393
23660
  error.textContent = "";
22394
23661
  allow.disabled = true;
22395
23662
 
22396
- // The password goes to the EXISTING login endpoint and nowhere else. This
22397
- // page never posts it to the OAuth surface, which sees only the resulting
22398
- // session token.
22399
- fetch(${JSON.stringify(params.loginUrl)}, {
22400
- method: "POST",
22401
- headers: { "Content-Type": "application/json" },
22402
- body: JSON.stringify({ email: email.value, password: password.value })
22403
- })
22404
- .then(function (res) { return res.json().then(function (b) { return { ok: res.ok, body: b }; }); })
22405
- .then(function (r) {
22406
- // buildAuthResponse returns { user, tokens: { accessToken } }. The
22407
- // flatter spellings are fallbacks rather than guesses: the login
22408
- // response passes through applyTransformHook, so a deployment can
22409
- // legitimately reshape it, and a consent screen that broke because
22410
- // someone customised their auth response would be a very hard failure
22411
- // to attribute.
22412
- //
22413
- // No backticks anywhere in this script: it lives inside a template
22414
- // literal, and one would end the string early — which is exactly how
22415
- // this comment broke the build the first time it was written.
22416
- var tokens = (r.body && r.body.tokens) || {};
22417
- var token = tokens.accessToken || (r.body && (r.body.accessToken || r.body.access_token));
22418
- if (!r.ok || !token) {
22419
- throw new Error((r.body && (r.body.error || r.body.message)) || "Sign-in failed.");
22420
- }
22421
- sessionToken.value = token;
22422
- form.submit();
22423
- })
23663
+ (mfaToken ? verifyCode() : signIn())
23664
+ .then(function () { allow.disabled = false; })
22424
23665
  .catch(function (err) {
22425
23666
  allow.disabled = false;
22426
- error.textContent = err.message || "Sign-in failed.";
23667
+ error.textContent = (err && err.shown && err.message) || "Sign-in failed.";
22427
23668
  });
22428
23669
  });
22429
23670
  })();
@@ -22431,6 +23672,41 @@ function renderConsentPage(params) {
22431
23672
  </body>
22432
23673
  </html>`;
22433
23674
  }
23675
+ /**
23676
+ * What to call the redirect target on the consent screen.
23677
+ *
23678
+ * The host, not the whole URI: a path is the registrant's to dress up
23679
+ * ("/claude/official/callback"), the host is what they must own. A loopback
23680
+ * redirect is a native client on this person's own machine; a private-use
23681
+ * scheme (`com.example.app:/cb`) has no host, so it is shown whole.
23682
+ */
23683
+ function describeRedirect(redirectUri) {
23684
+ const unverified = "This server has not verified that this address belongs to the application named above. Allow only if you recognise it.";
23685
+ let url;
23686
+ try {
23687
+ url = new URL(redirectUri);
23688
+ } catch {
23689
+ return {
23690
+ label: redirectUri.slice(0, 200),
23691
+ note: unverified
23692
+ };
23693
+ }
23694
+ if (url.protocol === "http:" || url.protocol === "https:") {
23695
+ const host = url.host;
23696
+ if (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1" || url.hostname === "localhost") return {
23697
+ label: `${host} (this computer)`,
23698
+ note: "An application running on this computer receives the access. It is not verified by this server."
23699
+ };
23700
+ return {
23701
+ label: host,
23702
+ note: unverified
23703
+ };
23704
+ }
23705
+ return {
23706
+ label: redirectUri.slice(0, 200),
23707
+ note: unverified
23708
+ };
23709
+ }
22434
23710
  function escapeHtml$2(value) {
22435
23711
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22436
23712
  }
@@ -22661,6 +23937,9 @@ function createOAuthRoutes(config) {
22661
23937
  scopeDescriptions: describeScopes(scope),
22662
23938
  requestToken,
22663
23939
  loginUrl: `${config.authBasePath}/login`,
23940
+ mfaChallengeUrl: `${config.authBasePath}/mfa/challenge`,
23941
+ mfaVerifyUrl: `${config.authBasePath}/mfa/challenge/verify`,
23942
+ redirectUri,
22664
23943
  decisionUrl: `${c.req.path}/decision`,
22665
23944
  resource: canonicalResource,
22666
23945
  nonce
@@ -23091,7 +24370,8 @@ async function scopedDriver(ctx) {
23091
24370
  return scopeDataDriver(ctx.driver, {
23092
24371
  uid: ctx.caller.uid,
23093
24372
  roles: ctx.caller.roles,
23094
- isAnonymous: false
24373
+ isAnonymous: false,
24374
+ ...ctx.caller.credential ? { credential: ctx.caller.credential } : {}
23095
24375
  });
23096
24376
  }
23097
24377
  /**
@@ -23223,6 +24503,10 @@ function readOptions(args, collection, ctx) {
23223
24503
  }
23224
24504
  if (args.limit !== void 0 && args.limit !== null) query.limit = String(args.limit);
23225
24505
  if (args.offset !== void 0 && args.offset !== null) query.offset = String(args.offset);
24506
+ if (args.deleted !== void 0 && args.deleted !== null) {
24507
+ if (args.deleted !== "include" && args.deleted !== "only") throw new McpToolError("`deleted` takes \"include\" (live rows and trashed ones) or \"only\" (trashed rows alone). Omit it for the live rows.");
24508
+ query.deleted = args.deleted;
24509
+ }
23226
24510
  return parseQueryOptions(query, {}, {
23227
24511
  collection,
23228
24512
  viewer: viewerOf(ctx)
@@ -23261,6 +24545,16 @@ var WHERE_ARG = {
23261
24545
  type: "object",
23262
24546
  description: "Filter, as the SDK's `where` and REST's `?where=`: field → [operator, value], e.g. {\"status\": [\"==\", \"paid\"], \"total\": [\">\", 100]}. Several conditions on one field: {\"total\": [[\">=\", 10], [\"<\", 100]]}. A belongsTo relation is filtered by its foreign key, as the rows carry it (e.g. \"authorId\"). Operators: " + ALL_WHERE_FILTER_OPS.join(", ") + "."
23263
24547
  };
24548
+ /**
24549
+ * REST's `?deleted=`. Without it every read hides the trash, so a row
24550
+ * delete_document sent there could never be found again to restore — while
24551
+ * describe_collection told the model that update_document restores it.
24552
+ */
24553
+ var DELETED_ARG = {
24554
+ type: "string",
24555
+ enum: ["include", "only"],
24556
+ description: "On a collection with a `softDeleteField` (see describe_collection): \"only\" reads the trashed rows alone, \"include\" reads them with the live ones. Omit it for the live rows."
24557
+ };
23264
24558
  var SEARCH_ARG = {
23265
24559
  type: "string",
23266
24560
  description: "Text search, as REST's `searchString`: matches rows whose text fields contain it (or the collection's declared full-text search)."
@@ -23329,7 +24623,8 @@ var MCP_TOOLS = [
23329
24623
  offset: {
23330
24624
  type: "number",
23331
24625
  description: "Rows to skip."
23332
- }
24626
+ },
24627
+ deleted: DELETED_ARG
23333
24628
  },
23334
24629
  required: ["collection"],
23335
24630
  additionalProperties: false
@@ -23354,7 +24649,8 @@ var MCP_TOOLS = [
23354
24649
  properties: {
23355
24650
  collection: COLLECTION_ARG,
23356
24651
  where: WHERE_ARG,
23357
- searchString: SEARCH_ARG
24652
+ searchString: SEARCH_ARG,
24653
+ deleted: DELETED_ARG
23358
24654
  },
23359
24655
  required: ["collection"],
23360
24656
  additionalProperties: false
@@ -23484,7 +24780,7 @@ var MCP_TOOLS = [
23484
24780
  },
23485
24781
  {
23486
24782
  name: "delete_document",
23487
- description: "Delete a row by id. This cannot be undone.",
24783
+ description: "Delete a row by id. On a collection with a `softDeleteField` (see describe_collection) the row goes to the trash: query_collection with `deleted: \"only\"` finds it, and update_document setting that field to null restores it. On any other collection the delete is permanent.",
23488
24784
  requiredScope: "data:delete",
23489
24785
  inputSchema: {
23490
24786
  type: "object",
@@ -23650,7 +24946,12 @@ function createMcpRoutes(config) {
23650
24946
  uid: resolved.uid,
23651
24947
  roles: resolved.roles,
23652
24948
  scopes: resolved.scopes,
23653
- clientId: `api-key:${resolved.apiKey.id}`
24949
+ clientId: `api-key:${resolved.apiKey.id}`,
24950
+ credential: {
24951
+ kind: resolved.apiKey.kind,
24952
+ id: resolved.apiKey.id,
24953
+ name: resolved.apiKey.name
24954
+ }
23654
24955
  };
23655
24956
  }
23656
24957
  const payload = await jwt.verifyMcpAccessToken(token, canonicalResource);
@@ -23658,9 +24959,36 @@ function createMcpRoutes(config) {
23658
24959
  error: "invalid_token",
23659
24960
  error_description: "The access token is invalid, expired, or for another resource."
23660
24961
  }, 401, { "WWW-Authenticate": bearerChallenge(config.publicUrl, config.mcpPath, DEFAULT_MCP_SCOPE) });
24962
+ let roles = payload.roles;
24963
+ if (config.identity) {
24964
+ const refused = () => c.json({
24965
+ error: "invalid_token",
24966
+ error_description: "The account behind this access token is no longer active, or signed out."
24967
+ }, 401, { "WWW-Authenticate": bearerChallenge(config.publicUrl, config.mcpPath, DEFAULT_MCP_SCOPE) });
24968
+ const revocation = config.identity.revocation;
24969
+ if (revocation && await isAccessTokenRevoked(revocation, {
24970
+ uid: payload.uid,
24971
+ iat: payload.iat
24972
+ })) return refused();
24973
+ let current;
24974
+ try {
24975
+ current = await config.identity.currentRoles(payload.uid);
24976
+ } catch (error) {
24977
+ logger.error("[mcp] Could not read the account behind an access token", {
24978
+ uid: payload.uid,
24979
+ error
24980
+ });
24981
+ return c.json({
24982
+ error: "temporarily_unavailable",
24983
+ error_description: "The account could not be checked. Try again."
24984
+ }, 503, { "Retry-After": "1" });
24985
+ }
24986
+ if (!current) return refused();
24987
+ roles = current;
24988
+ }
23661
24989
  return {
23662
24990
  uid: payload.uid,
23663
- roles: payload.roles,
24991
+ roles,
23664
24992
  scopes: scopeList(upgradeStoredMcpScope(payload.scope)),
23665
24993
  clientId: payload.clientId
23666
24994
  };
@@ -24479,6 +25807,7 @@ function createHistoryRoutes(params) {
24479
25807
  * {@link RestApiGenerator.getScopedDriver}.
24480
25808
  */
24481
25809
  async function authorizeEntityRead(c, collection, id) {
25810
+ refuseSimulatedWrite(c, httpMethodToOperation(c.req.method));
24482
25811
  const narrowed = c.get("scopes");
24483
25812
  const scope = `data:${httpMethodToOperation(c.req.method)}`;
24484
25813
  if (narrowed && !scopeGrants(narrowed, scope, collection.slug)) throw new ApiError(403, "SCOPE_MISSING", `This credential does not hold "${scope}" for collection "${collection.slug}".`, { requiredScope: `${scope}:${collection.slug}` });
@@ -26210,12 +27539,14 @@ function resolveProvision(provision) {
26210
27539
  if (typeof provision === "boolean") return {
26211
27540
  database: provision,
26212
27541
  collections: provision,
26213
- realtime: provision
27542
+ realtime: provision,
27543
+ off: !provision
26214
27544
  };
26215
27545
  return {
26216
27546
  database: true,
26217
27547
  collections: provision.collections ?? true,
26218
- realtime: provision.realtime ?? true
27548
+ realtime: provision.realtime ?? true,
27549
+ off: false
26219
27550
  };
26220
27551
  }
26221
27552
  /**
@@ -26233,6 +27564,12 @@ function resolveProvision(provision) {
26233
27564
  * its backend. The one process-wide setting it touches is the log level, and
26234
27565
  * only when `logging.level` is given: the logger is the process's.
26235
27566
  *
27567
+ * What it reads process-wide is the resource graph: the `database()`,
27568
+ * `bucket()`, `topic()` and `queue()` declarations are module-level values,
27569
+ * and every backend in the process sees all of them. So a process holds one
27570
+ * project's resources — several backends of that project are fine, two
27571
+ * projects declaring different resources are not.
27572
+ *
26236
27573
  * What the build logs at info is written when the backend first serves — see
26237
27574
  * `boot-announcements.ts` — so a script that builds one and never mounts it
26238
27575
  * prints nothing of the boot's on its stdout.
@@ -26249,8 +27586,13 @@ async function createRebaseBackend(options) {
26249
27586
  }
26250
27587
  }
26251
27588
  async function buildRebaseBackend(options, announcements) {
26252
- const config = options;
27589
+ const config = {
27590
+ ...options,
27591
+ auth: options.auth && !isAuthAdapter(options.auth) ? { ...options.auth } : options.auth
27592
+ };
26253
27593
  const provision = resolveProvision(config.provision);
27594
+ if (provision.off) logger.info(PROVISIONING_OFF_NOTICE);
27595
+ const provisionSkipReason = provision.off ? PROVISIONING_OFF_SKIP_REASON : void 0;
26254
27596
  if (config.logging?.level !== void 0) setLogLevel(config.logging.level);
26255
27597
  assertNoReplacedResourceConfig(config);
26256
27598
  assertOneSourceEach(config);
@@ -26324,7 +27666,8 @@ async function buildRebaseBackend(options, announcements) {
26324
27666
  await verifyProvisioningConnection(provisionTarget);
26325
27667
  let schemaOutcome = await provisionCollectionTables(provisionable, provisionTarget, {
26326
27668
  introspecting: introspectCollections,
26327
- provision: provision.collections
27669
+ provision: provision.collections,
27670
+ skipReason: provisionSkipReason
26328
27671
  });
26329
27672
  for (const target of provisionTargets.slice(1)) {
26330
27673
  const mine = provisionableBy.get(target) ?? [];
@@ -26333,7 +27676,8 @@ async function buildRebaseBackend(options, announcements) {
26333
27676
  await verifyProvisioningConnection(target);
26334
27677
  const outcome = await provisionCollectionTables(mine, target, {
26335
27678
  introspecting: introspectCollections,
26336
- provision: provision.collections
27679
+ provision: provision.collections,
27680
+ skipReason: provisionSkipReason
26337
27681
  });
26338
27682
  if (outcome.status === "applied" && schemaOutcome.status === "applied") schemaOutcome = {
26339
27683
  status: "applied",
@@ -26459,7 +27803,8 @@ async function buildRebaseBackend(options, announcements) {
26459
27803
  }
26460
27804
  const policyOutcome = await provisionCollectionPolicies(provisionable, provisionTarget, {
26461
27805
  introspecting: introspectCollections,
26462
- provision: provision.collections
27806
+ provision: provision.collections,
27807
+ skipReason: provisionSkipReason
26463
27808
  });
26464
27809
  for (const target of provisionTargets.slice(1)) {
26465
27810
  const mine = provisionableBy.get(target) ?? [];
@@ -26467,7 +27812,8 @@ async function buildRebaseBackend(options, announcements) {
26467
27812
  if (provision.collections && !provisioningDisabled() && target.bootstrapper.ensureRlsRuntime) await target.bootstrapper.ensureRlsRuntime(target.driverResult);
26468
27813
  await provisionCollectionPolicies(mine, target, {
26469
27814
  introspecting: introspectCollections,
26470
- provision: provision.collections
27815
+ provision: provision.collections,
27816
+ skipReason: provisionSkipReason
26471
27817
  });
26472
27818
  }
26473
27819
  for (const bootstrapper of bootstrappers) {
@@ -26493,14 +27839,15 @@ async function buildRebaseBackend(options, announcements) {
26493
27839
  }
26494
27840
  logger.info("Entity history initialized");
26495
27841
  } else logger.warn("History requested but default bootstrapper does not support initializeHistory");
27842
+ if (config.functionsUpstream && !surfaces.functions && !serviceKey) throw new Error("REBASE_FUNCTIONS_UPSTREAM is set but REBASE_SERVICE_KEY is not. This process's own rebase.functions calls reach the functions process with the service key, so set REBASE_SERVICE_KEY to the same value on this process and on the functions process.");
26496
27843
  const internalServiceKey = serviceKey || randomBytes(48).toString("base64");
26497
27844
  if (!serviceKey) logger.info("No REBASE_SERVICE_KEY configured. Generated internal per-boot key for singleton control-plane APIs.");
26498
- if (authAdapter && !authAdapter.serviceKey) authAdapter.serviceKey = internalServiceKey;
27845
+ if (authAdapter && !authAdapter.serviceKey) authAdapter = withBackendServiceKey(authAdapter, internalServiceKey);
26499
27846
  let apiKeyStore;
26500
- const apiKeyStoreResult = createApiKeyStore(defaultDriver);
27847
+ const apiKeyStoreResult = createApiKeyStore(defaultDriver, { provision: provision.database });
26501
27848
  if (apiKeyStoreResult) {
26502
27849
  apiKeyStore = apiKeyStoreResult;
26503
- if (provision.database) await apiKeyStore.ensureTable();
27850
+ await apiKeyStore.ensureTable();
26504
27851
  logger.debug("Service API Keys initialized");
26505
27852
  }
26506
27853
  const adminIdentityRepo = authConfigResult?.authRepository ?? authConfigResult?.userService;
@@ -26528,7 +27875,7 @@ async function buildRebaseBackend(options, announcements) {
26528
27875
  const platformTokenPreAuth = createPlatformTokenPreAuth(await platformTokensFromEnv(process.env));
26529
27876
  let backupLinkRouter;
26530
27877
  if (surfaces.admin) {
26531
- const { createBackupLinkRoutes, backupDestinationFromEnv } = await import("./backup-B9m3cb3X.js").then((n) => n.t);
27878
+ const { createBackupLinkRoutes, backupDestinationFromEnv } = await import("./backup-BVbHoKo8.js").then((n) => n.t);
26532
27879
  backupLinkRouter = createBackupLinkRoutes({ getDestination: () => backupDestinationFromEnv(process.env) });
26533
27880
  }
26534
27881
  const apiKeyRouter = apiKeyStore && surfaces.admin ? createApiKeyRoutes({
@@ -26555,7 +27902,7 @@ async function buildRebaseBackend(options, announcements) {
26555
27902
  ...adminGateIdentity
26556
27903
  });
26557
27904
  const rateLimitWindowMs = config.rateLimit?.windowMs ?? 900 * 1e3;
26558
- const sharedRateLimitStore = config.rateLimit?.store ?? (resolveRateLimitStoreKind(process.env) === "sql" ? createSqlRateLimitStore(defaultDriver) : void 0);
27905
+ const sharedRateLimitStore = config.rateLimit?.store ?? (resolveRateLimitStoreKind(process.env) === "sql" ? createSqlRateLimitStore(defaultDriver, { provision: provision.database }) : void 0);
26559
27906
  const rateLimitStore = sharedRateLimitStore ?? new MemoryRateLimitStore(rateLimitWindowMs);
26560
27907
  const authLimiters = createAuthLimiters(sharedRateLimitStore);
26561
27908
  const rateLimitConfig = config.rateLimit?.enabled !== false ? {
@@ -26673,7 +28020,7 @@ async function buildRebaseBackend(options, announcements) {
26673
28020
  ]) {
26674
28021
  const providerConfig = safeAuthConfig[key];
26675
28022
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
26676
- const createFn = (await import("./auth-Dkvo2tTJ.js").then((n) => n.t))[factory];
28023
+ const createFn = (await import("./auth-izI7AEzC.js").then((n) => n.t))[factory];
26677
28024
  oauthProviders.push(createFn(providerConfig));
26678
28025
  }
26679
28026
  }
@@ -26714,11 +28061,12 @@ async function buildRebaseBackend(options, announcements) {
26714
28061
  const authRoutes = authAdapter.createAuthRoutes();
26715
28062
  if (authRoutes) adapterAuthRouter = adapterRouter(authRoutes, "Auth");
26716
28063
  }
26717
- if (authAdapter && authAdapter.createAdminRoutes && surfaces.admin) {
28064
+ if (authAdapter && authAdapter.createAdminRoutes) {
26718
28065
  const adminRoutes = authAdapter.createAdminRoutes();
26719
28066
  if (adminRoutes) adapterAdminRouter = adapterRouter(adminRoutes, "Admin");
26720
28067
  }
26721
28068
  if (provision.database) await seedInitialAdmin(authAdapter?.userManagement);
28069
+ else if (process.env.REBASE_ADMIN_EMAIL?.trim()) logger.info("REBASE_ADMIN_EMAIL is set, but this backend changes nothing in its database, so no admin account is seeded here. The provisioning run seeds it.");
26722
28070
  }
26723
28071
  services.authAdapter = authAdapter;
26724
28072
  let mcpReady;
@@ -26729,11 +28077,11 @@ async function buildRebaseBackend(options, announcements) {
26729
28077
  else if (!jwt.isConfigured()) logger.warn("[mcp] Surface enabled but no JWT secret is configured — not mounting.");
26730
28078
  else if (!driver || !supportsRlsScoping(driver)) logger.error("[mcp] Surface enabled but this data driver cannot scope by user — not mounting. Every MCP call must run under the caller's own row-level security; a driver without `withAuth()` would run them as the database owner instead.");
26731
28079
  else {
26732
- const store = createOAuthStore(driver);
28080
+ const store = createOAuthStore(driver, { provision: provision.database });
26733
28081
  if (!store) logger.error("[mcp] Surface enabled but the driver cannot persist OAuth state — not mounting.");
26734
28082
  else {
26735
28083
  let ready = true;
26736
- if (provision.database) try {
28084
+ try {
26737
28085
  await store.ensureTables();
26738
28086
  } catch (error) {
26739
28087
  ready = false;
@@ -26756,8 +28104,10 @@ async function buildRebaseBackend(options, announcements) {
26756
28104
  const { store, publicUrl } = mcpReady;
26757
28105
  const mcpPath = "/mcp";
26758
28106
  const oauthBasePath = `${basePath}/oauth`;
28107
+ const identity = grantIdentityFromRepository(adminIdentityRepo);
26759
28108
  const mcpConfig = {
26760
28109
  jwt,
28110
+ identity,
26761
28111
  publicUrl,
26762
28112
  mcpPath,
26763
28113
  oauthBasePath,
@@ -26773,16 +28123,16 @@ async function buildRebaseBackend(options, announcements) {
26773
28123
  resolveApiKey: apiKeyStore ? (token) => resolveApiKey(apiKeyStore, token, access) : void 0
26774
28124
  };
26775
28125
  app.route("/", createMcpWellKnownRoutes(mcpConfig));
26776
- app.route(mcpPath, createMcpRoutes(mcpConfig));
26777
- app.route(oauthBasePath, createOAuthRoutes({
28126
+ app.route(mcpPath, group(createMcpRoutes(mcpConfig)));
28127
+ app.route(oauthBasePath, group(createOAuthRoutes({
26778
28128
  store,
26779
28129
  jwt,
26780
28130
  publicUrl,
26781
28131
  mcpPath,
26782
28132
  authBasePath: `${basePath}/auth`,
26783
28133
  allowDynamicRegistration: parseEnvBoolean(process.env.REBASE_MCP_OPEN_REGISTRATION) !== false,
26784
- identity: grantIdentityFromRepository(adminIdentityRepo)
26785
- }));
28134
+ identity
28135
+ })));
26786
28136
  if (announce) logger.info(`MCP endpoint mounted at ${publicUrl}${mcpPath} (OAuth at ${oauthBasePath})`);
26787
28137
  };
26788
28138
  const mountWarnings = [];
@@ -26864,7 +28214,7 @@ async function buildRebaseBackend(options, announcements) {
26864
28214
  let schemaEditorRoutes;
26865
28215
  let schemaSourceEditor;
26866
28216
  if (!schemaEditorOff && config.collectionsDir) try {
26867
- const editorModule = await import("./schema-editor-routes-Bsut0bCC.js");
28217
+ const editorModule = await import("./schema-editor-routes-Dc3DU_jm.js");
26868
28218
  schemaSourceEditor = editorModule.createSchemaSourceEditor(config.collectionsDir);
26869
28219
  schemaEditorRoutes = editorModule.createSchemaEditorRoutes(schemaSourceEditor);
26870
28220
  } catch (err) {
@@ -26914,7 +28264,7 @@ async function buildRebaseBackend(options, announcements) {
26914
28264
  let remoteEditingOff;
26915
28265
  if (remoteRepo) {
26916
28266
  try {
26917
- remoteEditor = await import("./ast-schema-editor-mCeSC03x.js");
28267
+ remoteEditor = await import("./ast-schema-editor-madTa9Hh.js");
26918
28268
  } catch (err) {
26919
28269
  if (err?.code !== "ERR_MODULE_NOT_FOUND") throw err;
26920
28270
  remoteEditingOff = {
@@ -26938,7 +28288,6 @@ async function buildRebaseBackend(options, announcements) {
26938
28288
  const collectionsDir = config.collectionsDir;
26939
28289
  const repositoryRoot = collectionsDir ? await findRepositoryRoot(collectionsDir) : void 0;
26940
28290
  const liveSchemaRouter = new Hono();
26941
- let undeployedCommit;
26942
28291
  applyAdminGate(liveSchemaRouter, "Live schema editing", "schema:read");
26943
28292
  liveSchemaRouter.use("/*", sayEditorNeedsTsMorph);
26944
28293
  const commitPaths = remoteRepo ? commitPathsFor(path.join("/", remoteRepo.collectionsPath ?? "config/collections"), "/") : collectionsDir && repositoryRoot ? commitPathsFor(collectionsDir, repositoryRoot) : void 0;
@@ -26946,29 +28295,23 @@ async function buildRebaseBackend(options, announcements) {
26946
28295
  liveSchemaRouter.route("/", createLiveSchemaRoutes({
26947
28296
  commitPaths,
26948
28297
  sourceEditingOff: remoteRepo ? remoteEditingOff : schemaEditorOff,
28298
+ databaseChangesOff: provision.off ? {
28299
+ code: "SCHEMA_EDITING_PROVISIONING_OFF",
28300
+ message: "This deployment changes nothing in its database (provisioning is off), so a change cannot be applied from here. Commit it as source only, and let the process that changes this database apply it."
28301
+ } : void 0,
26949
28302
  usesVersionedMigrations: collectionsDir ? () => usesVersionedMigrations(collectionsDir) : void 0,
26950
28303
  policy: { allowMachineApply: config.liveSchema?.allowMachineApply ?? parseEnvBoolean(process.env.REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY) === true },
26951
28304
  getCollections: () => collectionRegistry.getRawCollections(),
26952
28305
  getAdmin: () => defaultDriver.admin,
26953
28306
  resolveAuthorIdentity: (uid) => readAccountIdentity(authAdapter, uid),
26954
28307
  getRepository: (author) => {
26955
- if (remoteRepo) {
26956
- const repository = createGitHubRepository({
26957
- auth: remoteRepo.auth,
26958
- owner: remoteRepo.owner,
26959
- repo: remoteRepo.repo,
26960
- branch: remoteRepo.branch ?? "main",
26961
- author
26962
- });
26963
- return {
26964
- ...repository,
26965
- commit: async (paths, message) => {
26966
- const sha = await repository.commit(paths, message);
26967
- undeployedCommit = sha;
26968
- return sha;
26969
- }
26970
- };
26971
- }
28308
+ if (remoteRepo) return createGitHubRepository({
28309
+ auth: remoteRepo.auth,
28310
+ owner: remoteRepo.owner,
28311
+ repo: remoteRepo.repo,
28312
+ branch: remoteRepo.branch ?? "main",
28313
+ author
28314
+ });
26972
28315
  if (!repositoryRoot) return void 0;
26973
28316
  return createLocalGitRepository({
26974
28317
  root: repositoryRoot,
@@ -26976,15 +28319,20 @@ async function buildRebaseBackend(options, announcements) {
26976
28319
  });
26977
28320
  },
26978
28321
  writeSource: remoteRepo ? remoteEditor ? async (change) => {
26979
- if (undeployedCommit) throw new Error(`This deployment already committed a schema change (${undeployedCommit.slice(0, 9)}) that it is not running yet. Deploy that commit, then make the next change: until then a change here is planned against the collections this deployment runs, and its generated schema files would leave the first change out. Nothing was changed.`);
28322
+ const repository = createGitHubRepository({
28323
+ auth: remoteRepo.auth,
28324
+ owner: remoteRepo.owner,
28325
+ repo: remoteRepo.repo,
28326
+ branch: remoteRepo.branch ?? "main"
28327
+ });
28328
+ const admin = defaultDriver.admin;
28329
+ if (isSchemaEditingAdmin(admin)) {
28330
+ const served = collectionRegistry.getRawCollections();
28331
+ await refuseWhenRepositoryIsAhead(repository, (await admin.planSchemaChange(served, served, { paths: commitPaths })).files);
28332
+ }
26980
28333
  const { AstSchemaEditor } = remoteEditor;
26981
28334
  return rewriteRemoteCollection({
26982
- repository: createGitHubRepository({
26983
- auth: remoteRepo.auth,
26984
- owner: remoteRepo.owner,
26985
- repo: remoteRepo.repo,
26986
- branch: remoteRepo.branch ?? "main"
26987
- }),
28335
+ repository,
26988
28336
  collectionsPath: remoteRepo.collectionsPath ?? "config/collections",
26989
28337
  edit: async (dir, collectionId, edit) => {
26990
28338
  const editor = new AstSchemaEditor(dir);
@@ -27016,6 +28364,8 @@ async function buildRebaseBackend(options, announcements) {
27016
28364
  const storageAuthorizeData = {};
27017
28365
  let storageSurface;
27018
28366
  let storageMaintenanceSurface;
28367
+ /** The orphan scan's background half, whenever storage is on: the job queue's `storage.orphans` handler. */
28368
+ let orphanScans;
27019
28369
  /** Whether the storage group carries its own upload body limit, in place of the global one. */
27020
28370
  let storageCarriesUploadLimit = false;
27021
28371
  if (storageRegistry || storageController) {
@@ -27034,6 +28384,19 @@ async function buildRebaseBackend(options, announcements) {
27034
28384
  publicRead: config.storagePublicRead === true,
27035
28385
  allowAnyAuthenticated: config.storageInsecureAllowAnyAuthenticated === true
27036
28386
  }, isProduction);
28387
+ const maintenanceSources = {
28388
+ registry: storageRegistry,
28389
+ controller: storageController,
28390
+ collections: () => collectionRegistry.getRawCollections(),
28391
+ driverFor: (collection) => {
28392
+ const key = resolveDataSource(collection, dataSourceRegistry).key;
28393
+ return !key || key === "(default)" ? defaultDriver : driverRegistry.get(key);
28394
+ },
28395
+ globalCallbacks: () => collectionRegistry.getGlobalCallbacks(),
28396
+ history: historyConfigResult?.historyService,
28397
+ jobs: () => jobQueue
28398
+ };
28399
+ orphanScans = createOrphanScanService(maintenanceSources);
27037
28400
  if (surfaces.admin) {
27038
28401
  const maintenanceRouter = new Hono();
27039
28402
  applyAdminGate(maintenanceRouter, "Storage maintenance", {
@@ -27041,13 +28404,8 @@ async function buildRebaseBackend(options, announcements) {
27041
28404
  write: "database:write"
27042
28405
  });
27043
28406
  maintenanceRouter.route("/", createStorageMaintenanceRoutes({
27044
- registry: storageRegistry,
27045
- controller: storageController,
27046
- collections: () => collectionRegistry.getRawCollections(),
27047
- driverFor: (collection) => {
27048
- const key = resolveDataSource(collection, dataSourceRegistry).key;
27049
- return !key || key === "(default)" ? defaultDriver : driverRegistry.get(key);
27050
- }
28407
+ ...maintenanceSources,
28408
+ orphanScans
27051
28409
  }));
27052
28410
  storageMaintenanceSurface = maintenanceRouter;
27053
28411
  }
@@ -27137,7 +28495,7 @@ async function buildRebaseBackend(options, announcements) {
27137
28495
  });
27138
28496
  dataRouter.route("/", historyRoutes);
27139
28497
  }
27140
- const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
28498
+ const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter, void 0, void 0, { provision: provision.database });
27141
28499
  dataRouter.route("/", restGenerator.generateRoutes());
27142
28500
  dataSurface = dataRouter;
27143
28501
  } else {
@@ -27206,21 +28564,38 @@ async function buildRebaseBackend(options, announcements) {
27206
28564
  }
27207
28565
  const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
27208
28566
  if (isSQLAdmin(driverAdmin)) {
27209
- Object.assign(serverClient, { sql: (query, sqlOptions) => driverAdmin.executeSql(query, sqlOptions && {
27210
- role: sqlOptions.role,
27211
- params: sqlOptions.params,
27212
- ...sqlOptions.database !== void 0 ? { database: resourceKeyOf(sqlOptions.database) } : {}
27213
- }) });
28567
+ const sqlAdminFor = (key) => {
28568
+ if (key === "(default)" || key === defaultDriverId) return driverAdmin;
28569
+ const source = bootstrappers.find((b) => (b.id || b.type) === key);
28570
+ if (!source) {
28571
+ const known = bootstrappers.map((b) => `"${b.id || b.type}"`).join(", ");
28572
+ throw new Error(`rebase.sql: no data source "${key}" is configured on this backend. Configured: ${known}. Declare it with database("${key}") in config/resources.ts and bind it — \`rebase status\` shows which variable.`);
28573
+ }
28574
+ const result = driverResults.get(source);
28575
+ const admin = result ? source.getAdmin?.(result) : void 0;
28576
+ if (!isSQLAdmin(admin)) throw new Error(`rebase.sql: data source "${key}" is not a SQL database, so it cannot run raw SQL.`);
28577
+ return admin;
28578
+ };
28579
+ Object.assign(serverClient, { sql: async (query, sqlOptions) => {
28580
+ return (sqlOptions?.database !== void 0 ? sqlAdminFor(resourceKeyOf(sqlOptions.database)) : driverAdmin).executeSql(query, sqlOptions && {
28581
+ role: sqlOptions.role,
28582
+ params: sqlOptions.params
28583
+ });
28584
+ } });
27214
28585
  logger.debug("SQL capability attached to singleton");
27215
28586
  }
27216
28587
  const serverSingleton = serverClient;
27217
28588
  const ownScope = { rebase: serverSingleton };
28589
+ const runInOwnScope = (fn) => runInBackendScope(ownScope, fn);
28590
+ const realtimeProviders = new Set(Object.values(realtimeServices));
28591
+ if (defaultRealtimeService) realtimeProviders.add(defaultRealtimeService);
28592
+ for (const provider of realtimeProviders) provider.setScopeRunner?.(runInOwnScope);
27218
28593
  injectCallbackClient(driverResults.values(), serverClient);
27219
28594
  let functionsSurface;
27220
28595
  if ((config.functionsDir || config.functions) && surfaces.functions) {
27221
- const { functionList, readFunctionsDirectory } = await import("./function-loader-KKDqTcIr.js").then((n) => n.t);
28596
+ const { functionList, readFunctionsDirectory } = await import("./function-loader-B8YzXEsQ.js").then((n) => n.t);
27222
28597
  const { createFunctionRoutes } = await import("./function-routes-RxrCHNuM.js").then((n) => n.n);
27223
- const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-59crS-9u.js").then((n) => n.t);
28598
+ const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-DVjo-c_8.js").then((n) => n.t);
27224
28599
  const { functions: functionRecord, problems } = config.functions ? {
27225
28600
  functions: config.functions,
27226
28601
  problems: []
@@ -27269,20 +28644,21 @@ async function buildRebaseBackend(options, announcements) {
27269
28644
  });
27270
28645
  else logger.warn(`Function routes are served, but no functions loaded from ${config.functionsDir ?? "the `functions` option"}. ` + (problems.length > 0 ? `${problems.length} file(s) were skipped — see the messages above.` : "The directory holds no .ts/.js function files."));
27271
28646
  }
28647
+ const forwardsFunctionsOnlyInProcess = !surfaces.functions && !surfaces.auth && !surfaces.data && !surfaces.storage && !surfaces.admin && !surfaces.meta;
27272
28648
  if (config.functionsUpstream && !surfaces.functions) {
27273
- const { createFunctionsProxy } = await import("./proxy-CH8Jsovg.js").then((n) => n.t);
28649
+ const { createFunctionsProxy } = await import("./proxy-BYir8tWK.js").then((n) => n.t);
27274
28650
  functionsSurface = createFunctionsProxy({ upstream: config.functionsUpstream });
27275
- logger.info("Forwarding custom functions to another process", { upstream: config.functionsUpstream });
28651
+ logger.info(forwardsFunctionsOnlyInProcess ? "Forwarding this process's own function calls (rebase.functions) to another process" : "Forwarding custom functions to another process", { upstream: config.functionsUpstream });
27276
28652
  }
27277
28653
  let cronScheduler;
27278
28654
  let cronSurface;
27279
28655
  /** How many cron jobs `start()` schedules in this process. */
27280
28656
  let cronJobsToSchedule = 0;
27281
28657
  if (surfaces.cron || (config.cronsDir || config.crons) && ownership.cronScheduler) {
27282
- const { cronJobsFrom, readCronsDirectory } = await import("./cron-loader-Dj6lA4HQ.js").then((n) => n.t);
27283
- const { CronScheduler } = await import("./cron-scheduler-B04ACoUE.js").then((n) => n.n);
27284
- const { createCronRoutes } = await import("./cron-routes-CwbLZguO.js").then((n) => n.n);
27285
- const { createCronStore } = await import("./cron-store-DkyfuLOH.js").then((n) => n.n);
28658
+ const { cronJobsFrom, readCronsDirectory } = await import("./cron-loader-mMhqbVsl.js").then((n) => n.t);
28659
+ const { CronScheduler } = await import("./cron-scheduler-Be7CNghL.js").then((n) => n.n);
28660
+ const { createCronRoutes } = await import("./cron-routes-Cs33j_Kr.js").then((n) => n.n);
28661
+ const { createCronStore } = await import("./cron-store-DLn11M8H.js").then((n) => n.n);
27286
28662
  const { crons: cronRecord, problems: cronProblems } = config.crons ? {
27287
28663
  crons: config.crons,
27288
28664
  problems: []
@@ -27301,9 +28677,9 @@ async function buildRebaseBackend(options, announcements) {
27301
28677
  cronScheduler.setClient(serverSingleton);
27302
28678
  if (loadedCronJobs.length > 0) {
27303
28679
  cronScheduler.registerJobs(loadedCronJobs);
27304
- const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
28680
+ const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver, { provision: provision.database }) : void 0;
27305
28681
  if (store) {
27306
- if (provision.database) await store.ensureTable();
28682
+ await store.ensureTable();
27307
28683
  cronScheduler.setStore(store);
27308
28684
  }
27309
28685
  }
@@ -27343,24 +28719,28 @@ async function buildRebaseBackend(options, announcements) {
27343
28719
  }
27344
28720
  let metricsHistory;
27345
28721
  try {
27346
- const { createMetricsHistory } = await import("./history-recorder-B2rkuhuy.js");
27347
- metricsHistory = createMetricsHistory(defaultDriver);
27348
- if (metricsHistory && provision.database) await metricsHistory.ensure();
28722
+ const { createMetricsHistory } = await import("./history-recorder-Bsau7h4X.js");
28723
+ metricsHistory = createMetricsHistory(defaultDriver, { provision: provision.database });
28724
+ if (metricsHistory) await metricsHistory.ensure();
27349
28725
  } catch (err) {
27350
28726
  logger.warn("[metrics] history is unavailable; the runtime is otherwise unaffected.", { err });
27351
28727
  metricsHistory = void 0;
27352
28728
  }
27353
28729
  let jobQueue;
27354
28730
  if (jobsOptions?.enabled) {
27355
- const { createJobStore, createJobQueue } = await import("./jobs-h1WRq3cp.js").then((n) => n.t);
27356
- const store = createJobStore(defaultDriver);
28731
+ const { createJobStore, createJobQueue } = await import("./jobs-Co6Ni5HP.js").then((n) => n.t);
28732
+ const store = createJobStore(defaultDriver, { provision: provision.database });
27357
28733
  if (store) {
27358
- if (provision.database) await store.ensureTable();
28734
+ await store.ensureTable();
27359
28735
  const tasks = Object.fromEntries(Object.entries(jobsOptions.tasks ?? {}).map(([task, handler]) => [task, (ctx) => runInBackendScope(ownScope, () => handler(ctx))]));
27360
28736
  jobQueue = createJobQueue(store, {
27361
28737
  ...jobsOptions,
27362
28738
  tasks
27363
28739
  });
28740
+ if (orphanScans) {
28741
+ const scans = orphanScans;
28742
+ jobQueue.register(STORAGE_ORPHANS_TASK, (ctx) => runInBackendScope(ownScope, () => scans.runTask(ctx.payload, ctx.signal)));
28743
+ }
27364
28744
  if (declaredTopics.length > 0) {
27365
28745
  ownScope.topics = createTopicRuntime({
27366
28746
  queue: jobQueue,
@@ -27388,11 +28768,12 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27388
28768
  let backupSurface;
27389
28769
  let rlsAuditSurface;
27390
28770
  let sqlExportSurface;
28771
+ let branchSurface;
27391
28772
  let devMailSurface;
27392
28773
  let logsSurface;
27393
28774
  let metaSurface;
27394
28775
  if (surfaces.admin) {
27395
- const { createBackupRoutes, backupDestinationFromEnv, readBackupSchedule } = await import("./backup-B9m3cb3X.js").then((n) => n.t);
28776
+ const { createBackupRoutes, backupDestinationFromEnv, readBackupSchedule } = await import("./backup-BVbHoKo8.js").then((n) => n.t);
27396
28777
  const backupRouter = new Hono();
27397
28778
  applyAdminGate(backupRouter, "Backup", "backups:read");
27398
28779
  const scheduler = cronScheduler;
@@ -27412,13 +28793,36 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27412
28793
  }));
27413
28794
  rlsAuditSurface = rlsAuditRouter;
27414
28795
  }
28796
+ let rlsSimulatorSurface;
28797
+ if (surfaces.admin) {
28798
+ const { createRlsSimulatorRoutes } = await import("./rls-simulator-routes-bEY7-5-u.js");
28799
+ const rlsSimulatorRouter = new Hono();
28800
+ applyAdminGate(rlsSimulatorRouter, "RLS simulator", "schema:read");
28801
+ rlsSimulatorRouter.route("/", createRlsSimulatorRoutes({
28802
+ collections: () => collectionRegistry.getRawCollections(),
28803
+ driverFor: (slug) => {
28804
+ const key = keyForCollectionPath(slug);
28805
+ return !key || key === "(default)" ? defaultDriver : driverRegistry.get(key) ?? defaultDriver;
28806
+ },
28807
+ resolveUser: authAdapter?.resolveUser ? authAdapter.resolveUser.bind(authAdapter) : void 0,
28808
+ apiKeyStore
28809
+ }));
28810
+ rlsSimulatorSurface = rlsSimulatorRouter;
28811
+ }
27415
28812
  if (surfaces.admin) {
27416
- const { createSqlExportRoutes } = await import("./sql-export-routes-DmB_uZ4l.js");
28813
+ const { createSqlExportRoutes } = await import("./sql-export-routes-QycX8W8S.js");
27417
28814
  const sqlExportRouter = new Hono();
27418
28815
  applyAdminGate(sqlExportRouter, "SQL export", "database:write");
27419
28816
  sqlExportRouter.route("/", createSqlExportRoutes({ getAdmin: () => defaultDriver.admin }));
27420
28817
  sqlExportSurface = sqlExportRouter;
27421
28818
  }
28819
+ if (surfaces.admin) {
28820
+ const { createBranchRoutes } = await import("./branch-routes-B2vvEx53.js");
28821
+ const branchRouter = new Hono();
28822
+ applyAdminGate(branchRouter, "Branch review", "database:write");
28823
+ branchRouter.route("/", createBranchRoutes({ getAdmin: () => defaultDriver.admin }));
28824
+ branchSurface = branchRouter;
28825
+ }
27422
28826
  if (surfaces.admin) {
27423
28827
  const devMailRouter = new Hono();
27424
28828
  applyAdminGate(devMailRouter, "Development mailbox", "users:write");
@@ -27426,8 +28830,10 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27426
28830
  code: "DEV_MAILBOX_UNAVAILABLE",
27427
28831
  message: "No development mailbox is active. Mail is captured only when SMTP_HOST is unset and NODE_ENV is not production; with a mail server configured, messages are delivered rather than held here."
27428
28832
  } };
28833
+ const ownEmail = config.auth && !isAuthAdapter(config.auth) ? config.auth.email : void 0;
28834
+ const devMailbox = () => ownEmail ? devEmailSinkFor(ownEmail.sendEmail) : activeDevEmailSink();
27429
28835
  devMailRouter.get("/", (c) => {
27430
- const sink = activeDevEmailSink();
28836
+ const sink = devMailbox();
27431
28837
  if (!sink) return c.json(unavailable, 501);
27432
28838
  return c.json({
27433
28839
  enabled: true,
@@ -27435,7 +28841,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27435
28841
  });
27436
28842
  });
27437
28843
  devMailRouter.delete("/", (c) => {
27438
- const sink = activeDevEmailSink();
28844
+ const sink = devMailbox();
27439
28845
  if (!sink) return c.json(unavailable, 501);
27440
28846
  sink.clear();
27441
28847
  return c.json({ cleared: true });
@@ -27450,7 +28856,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27450
28856
  logsSurface = logsRouter;
27451
28857
  }
27452
28858
  if (surfaces.meta) {
27453
- const { createContractRoutes } = await import("./contract-routes-CsFXW7j4.js").then((n) => n.t);
28859
+ const { createContractRoutes } = await import("./contract-routes-CU2A3JnF.js").then((n) => n.t);
27454
28860
  const contractRouter = new Hono();
27455
28861
  if (adminSurfacesGated) contractRouter.use("/contract", ...adminGate("schema:read"));
27456
28862
  else {
@@ -27509,10 +28915,20 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27509
28915
  /**
27510
28916
  * A group: this backend's services on every request first, then a fresh
27511
28917
  * router over `router`'s handlers.
27512
- */
27513
- const group = (router) => {
28918
+ *
28919
+ * Each group also answers the impersonation and simulation headers itself,
28920
+ * so one mounted on its own (`backend.routes.storage()` at `/v2/storage`)
28921
+ * keeps the rule `mount()` installs on its base path. A group that does
28922
+ * not run requests as someone else refuses them; the data and functions
28923
+ * groups (`"honoured"`), whose auth middleware applies them, report what a
28924
+ * simulated request ran as. Without this a custom server's storage or
28925
+ * admin group answered `x-rebase-impersonate: <B>` as the administrator
28926
+ * who sent it, under B's label.
28927
+ */
28928
+ const group = (router, identitySwap = "refused") => {
27514
28929
  const composed = new Hono();
27515
28930
  composed.use("/*", provideServices);
28931
+ composed.use("/*", identitySwap === "honoured" ? reportSimulatedContext() : refuseUnhonouredImpersonation([]));
27516
28932
  return router ? composed.route("/", router) : composed;
27517
28933
  };
27518
28934
  const requireSurface = (surface) => {
@@ -27532,7 +28948,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27532
28948
  },
27533
28949
  data: () => {
27534
28950
  requireSurface("data");
27535
- return group(dataSurface);
28951
+ return group(dataSurface, "honoured");
27536
28952
  },
27537
28953
  storage: () => {
27538
28954
  requireSurface("storage");
@@ -27540,7 +28956,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27540
28956
  },
27541
28957
  functions: () => {
27542
28958
  if (!functionsSurface) throw new Error("routes.functions(): this backend serves no functions. Pass `functions` (or `functionsDir`) with the functions surface on, or `functionsUpstream` with it off to forward them.");
27543
- return group(functionsSurface);
28959
+ return group(functionsSurface, "honoured");
27544
28960
  },
27545
28961
  admin: () => {
27546
28962
  requireSurface("admin");
@@ -27555,7 +28971,9 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27555
28971
  if (storageMaintenanceSurface) adminGroup.route("/storage", storageMaintenanceSurface);
27556
28972
  if (backupSurface) adminGroup.route("/backups", backupSurface);
27557
28973
  if (rlsAuditSurface) adminGroup.route("/rls-audit", rlsAuditSurface);
28974
+ if (rlsSimulatorSurface) adminGroup.route("/rls-simulator", rlsSimulatorSurface);
27558
28975
  if (sqlExportSurface) adminGroup.route("/sql", sqlExportSurface);
28976
+ if (branchSurface) adminGroup.route("/branches", branchSurface);
27559
28977
  if (devMailSurface) adminGroup.route("/dev/emails", devMailSurface);
27560
28978
  if (logsSurface) adminGroup.route("/logs", logsSurface);
27561
28979
  return adminGroup;
@@ -27617,6 +29035,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27617
29035
  });
27618
29036
  app.use(`${basePath}/*`, refuseUnhonouredImpersonation([`${basePath}/data`, `${basePath}/functions`]));
27619
29037
  if (surfaces.admin) app.route(`${basePath}/admin`, groups.admin());
29038
+ else if (!announce) mountInProcessAdmin(app, basePath);
27620
29039
  if (surfaces.auth) app.route(`${basePath}/auth`, groups.auth());
27621
29040
  if (jwt.isConfigured() && surfaces.auth) {
27622
29041
  app.route("/.well-known", createJwksRoutes(jwt));
@@ -27642,7 +29061,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27642
29061
  unprotectedTables
27643
29062
  });
27644
29063
  }
27645
- if (functionsSurface) {
29064
+ if (functionsSurface && (!announce || !forwardsFunctionsOnlyInProcess)) {
27646
29065
  app.route(`${basePath}/functions`, groups.functions());
27647
29066
  if (announce) logger.debug("Function routes mounted", { path: `${basePath}/functions` });
27648
29067
  }
@@ -27654,27 +29073,64 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27654
29073
  if (surfaces.meta && metaSurface) app.route(`${basePath}/meta`, groups.meta());
27655
29074
  if (announce) logger.debug("Rebase backend mounted", { basePath });
27656
29075
  };
29076
+ const mountInProcessAdmin = (app, basePath) => {
29077
+ if (adapterAdminRouter) {
29078
+ const adminGroup = group();
29079
+ adminGroup.route("/", adapterAdminRouter);
29080
+ app.route(`${basePath}/admin`, adminGroup);
29081
+ }
29082
+ if (cronSurface && !surfaces.cron) app.route(`${basePath}/admin/cron`, group(cronSurface));
29083
+ };
27657
29084
  let inProcess;
27658
29085
  const inProcessApp = () => {
27659
29086
  if (!inProcess) {
27660
29087
  inProcess = new Hono();
27661
29088
  mountInto(inProcess, IN_PROCESS_BASE_PATH, false);
29089
+ refuseSurfacesOffByName(inProcess);
27662
29090
  }
27663
29091
  return inProcess;
27664
29092
  };
29093
+ const refuseSurfacesOffByName = (app) => {
29094
+ const elsewhere = "Call it over HTTP on a process that serves it, or run this code on one.";
29095
+ const off = [];
29096
+ if (!functionsSurface) off.push({
29097
+ path: "functions",
29098
+ surface: "functions",
29099
+ roles: "REBASE_ROLE=worker and REBASE_ROLE=api",
29100
+ consequence: "rebase.functions has nothing to call in this process",
29101
+ remedy: "Set REBASE_FUNCTIONS_UPSTREAM to the functions process's URL on this process to forward these calls there, or call the function over HTTP on a process that serves it."
29102
+ });
29103
+ if (!surfaces.auth) off.push({
29104
+ path: "auth",
29105
+ surface: "auth",
29106
+ roles: "REBASE_ROLE=worker and REBASE_ROLE=functions",
29107
+ consequence: "rebase.auth has nothing to call in this process",
29108
+ remedy: `For account administration from server code use rebase.admin, which works here. ${elsewhere}`
29109
+ });
29110
+ if (!surfaces.admin) off.push({
29111
+ path: "admin",
29112
+ surface: "admin",
29113
+ roles: "REBASE_ROLE=worker and REBASE_ROLE=functions",
29114
+ consequence: adapterAdminRouter || cronSurface ? "this process carries only the user and role administration (rebase.admin) and the cron controls (rebase.cron) in-process, and this is neither" : "rebase.admin and rebase.cron have nothing to call in this process",
29115
+ remedy: elsewhere
29116
+ });
29117
+ for (const { path, surface, consequence, roles, remedy } of off) app.all(`${IN_PROCESS_BASE_PATH}/${path}/*`, (c) => errorHandler(new ApiError(404, "NOT_FOUND", `No route for ${c.req.method} ${c.req.path} on this backend: its ${surface} surface is off (${roles} turn it off), so ${consequence}. ${remedy}`, void 0, true), c));
29118
+ };
27665
29119
  let realtimeAttached = false;
27666
29120
  const attachRealtime = async (server, attachOptions = {}) => {
27667
29121
  announcements.serve();
27668
29122
  if (!surfaces.realtime || !defaultBootstrapper.initializeWebsockets || !effectiveRealtimeService) return;
27669
29123
  if (realtimeAttached) throw new Error("attachRealtime(): this backend's websocket is already attached to a server.");
27670
29124
  realtimeAttached = true;
27671
- await defaultBootstrapper.initializeWebsockets(server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter, {
29125
+ const socketAuthConfig = config.auth && isAuthAdapter(config.auth) ? authAdapter : config.auth;
29126
+ await defaultBootstrapper.initializeWebsockets(server, effectiveRealtimeService, defaultDriver, socketAuthConfig, authAdapter, {
27672
29127
  maxPayload: config.maxBodySize,
27673
29128
  dataRateLimit: rateLimitConfig ? createDataRateLimitCheck(rateLimitConfig) : void 0,
27674
29129
  resolveApiKey: apiKeyStore ? (token) => resolveApiKey(apiKeyStore, token, access) : void 0,
27675
29130
  verifyAccessToken: (token) => jwt.isConfigured() ? jwt.verifyAccessToken(token) : Promise.resolve(null),
27676
29131
  accessModel: access.model,
27677
- path: attachOptions.path ?? realtimeSocketPath(mountedBasePath)
29132
+ path: attachOptions.path ?? realtimeSocketPath(mountedBasePath),
29133
+ runInScope: runInOwnScope
27678
29134
  });
27679
29135
  };
27680
29136
  let started = false;
@@ -27715,13 +29171,15 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27715
29171
  authRepo: adminIdentityRepo,
27716
29172
  driver: defaultDriver,
27717
29173
  ownsTimers: ownership.cronScheduler,
27718
- persistClaims: config.cronPersistence !== false
29174
+ persistClaims: config.cronPersistence !== false,
29175
+ provision: provision.database
27719
29176
  });
27720
29177
  historyRetentionSweep = startHistoryRetentionSweep({
27721
29178
  historyService: historyConfigResult?.historyService,
27722
29179
  driver: defaultDriver,
27723
29180
  ownsTimers: ownership.cronScheduler,
27724
- persistClaims: config.cronPersistence !== false
29181
+ persistClaims: config.cronPersistence !== false,
29182
+ provision: provision.database
27725
29183
  });
27726
29184
  };
27727
29185
  const shutdown = (timeoutMs, shutdownOptions = {}) => announcements.run(() => createShutdown({
@@ -27732,6 +29190,7 @@ Use a driver that supports the job queue, or remove the declarations. Refusing t
27732
29190
  authTokenSweep,
27733
29191
  historyRetentionSweep,
27734
29192
  stopMetricsSampler,
29193
+ ownedRateLimitStore: config.rateLimit?.store ? void 0 : sharedRateLimitStore,
27735
29194
  realtimeServices,
27736
29195
  closeLongLivedResponses: () => longLivedResponses.abort(),
27737
29196
  emailService
@@ -27823,7 +29282,12 @@ function setDefaultBackend(backend) {
27823
29282
  *
27824
29283
  * ```ts
27825
29284
  * installUnhandledRejectionHandler();
27826
- * const backend = await createRebaseBackend({ ...options, provision: true });
29285
+ * const backend = await createRebaseBackend({
29286
+ * ...options,
29287
+ * provision: !resolveProvisionEnv(process.env)
29288
+ * ? false
29289
+ * : provisionSchema === false ? { collections: false, realtime: false } : true
29290
+ * });
27827
29291
  * setDefaultBackend(backend);
27828
29292
  * backend.mount(app, { basePath });
27829
29293
  * await backend.attachRealtime(server);
@@ -27844,7 +29308,7 @@ async function initializeRebaseBackend(config) {
27844
29308
  installUnhandledRejectionHandler();
27845
29309
  const backend = await createRebaseBackend({
27846
29310
  ...options,
27847
- provision: provisionSchema === false ? {
29311
+ provision: !resolveProvisionEnv(process.env) ? false : provisionSchema === false ? {
27848
29312
  collections: false,
27849
29313
  realtime: false
27850
29314
  } : true
@@ -27914,7 +29378,7 @@ function typedSingleton() {
27914
29378
  function defineFunction(definition) {
27915
29379
  const app = new Hono();
27916
29380
  const returned = definition(app, { rebase: typedSingleton() });
27917
- return returned instanceof Hono ? returned : app;
29381
+ return isHonoLike(returned) ? returned : app;
27918
29382
  }
27919
29383
  //#endregion
27920
29384
  //#region src/functions/runtime-env.ts
@@ -29681,12 +31145,55 @@ function strictTransportSecurity(includeSubDomains) {
29681
31145
  * that broke any of them would be one every project had to turn off.
29682
31146
  */
29683
31147
  var STATIC_APP_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
31148
+ /**
31149
+ * The `Referrer-Policy` this runtime sends: `strict-origin-when-cross-origin`,
31150
+ * the browsers' own default. A cross-origin request carries the page's origin
31151
+ * and never its path or query.
31152
+ *
31153
+ * Not `no-referrer`, the `secureHeaders()` default it used to send: an origin
31154
+ * check then sees no origin at all. Google's "Sign in with Google" button is the
31155
+ * one that bit — it compares the page's origin with the OAuth client's
31156
+ * authorized JavaScript origins, and under `no-referrer` it refused every
31157
+ * Rebase-hosted app with "The given origin is not allowed for the given client
31158
+ * ID", whatever the client allowed.
31159
+ */
31160
+ var DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
31161
+ /**
31162
+ * The headers a route may set for itself, and what every other response gets.
31163
+ *
31164
+ * `secureHeaders()` overwrites whatever the route set, and some routes set a
31165
+ * stricter value on purpose — the MCP consent page sends `no-referrer`, so the
31166
+ * `client_id` and `state` in its URL go nowhere, and `X-Frame-Options: DENY`,
31167
+ * because a consent screen that can be framed is one an attacker can have
31168
+ * clicked. So these two are sent here instead, and only when the route did not.
31169
+ */
31170
+ var OVERRIDABLE_DEFAULTS = [["Referrer-Policy", DEFAULT_REFERRER_POLICY], ["X-Frame-Options", "SAMEORIGIN"]];
31171
+ /**
31172
+ * Sends `Referrer-Policy: strict-origin-when-cross-origin` and
31173
+ * `X-Frame-Options: SAMEORIGIN` on every response that does not set its own.
31174
+ *
31175
+ * Use it beside `secureHeaders({ referrerPolicy: false, xFrameOptions: false })`;
31176
+ * left on, those two options overwrite the route's value. A static app that
31177
+ * wants a stricter referrer policy says so with `<meta name="referrer">`.
31178
+ */
31179
+ function overridableSecurityHeaders() {
31180
+ return async (c, next) => {
31181
+ await next();
31182
+ for (const [name, value] of OVERRIDABLE_DEFAULTS) if (!c.res.headers.has(name)) c.header(name, value);
31183
+ };
31184
+ }
29684
31185
  /** The security headers both boot paths — backend and static-only — install. */
29685
31186
  function runtimeSecureHeaders(options) {
29686
- return secureHeaders({
31187
+ const headers = secureHeaders({
29687
31188
  crossOriginResourcePolicy: "cross-origin",
29688
31189
  crossOriginOpenerPolicy: "same-origin-allow-popups",
29689
- strictTransportSecurity: strictTransportSecurity(options.hstsIncludeSubDomains)
31190
+ strictTransportSecurity: strictTransportSecurity(options.hstsIncludeSubDomains),
31191
+ referrerPolicy: false,
31192
+ xFrameOptions: false
31193
+ });
31194
+ const overridable = overridableSecurityHeaders();
31195
+ return (c, next) => headers(c, async () => {
31196
+ await overridable(c, next);
29690
31197
  });
29691
31198
  }
29692
31199
  //#endregion
@@ -30324,6 +31831,29 @@ var bootEnvExtension = z$1.object({
30324
31831
  ""
30325
31832
  ]).optional(),
30326
31833
  /**
31834
+ * `false`: this deployment changes nothing in its database. No table,
31835
+ * column, index, function, trigger, policy, role or grant — the framework's
31836
+ * own included, which `REBASE_MIGRATE_ON_BOOT=none` leaves on — is created
31837
+ * or altered, at boot or by a request; what is missing is reported by name
31838
+ * instead. For a database whose schema only changes through a change
31839
+ * process: run a provisioning step (`REBASE_PROVISION_ONLY=true`, with
31840
+ * `REBASE_PROVISION_SQL_OUT` to keep the SQL it ran) under that process.
31841
+ *
31842
+ * See `resolveProvisionEnv` in `boot/provision.ts`.
31843
+ */
31844
+ REBASE_PROVISION: z$1.enum([
31845
+ "true",
31846
+ "false",
31847
+ ""
31848
+ ]).default("true").transform((v) => v !== "false"),
31849
+ /**
31850
+ * A file a provisioning run (`REBASE_PROVISION_ONLY=true`) writes every
31851
+ * schema change it executed to, in order, as a SQL script — the reviewable
31852
+ * record a change process applies with psql. Refused on a process that
31853
+ * serves, which would write it while it kept running.
31854
+ */
31855
+ REBASE_PROVISION_SQL_OUT: z$1.string().optional(),
31856
+ /**
30327
31857
  * Add `includeSubDomains` to the `Strict-Transport-Security` header.
30328
31858
  *
30329
31859
  * Off by default: it tells browsers to refuse plain HTTP on every subdomain
@@ -30663,9 +32193,12 @@ var bootEnvExtension = z$1.object({
30663
32193
  /**
30664
32194
  * Base URL of the process serving `/api/functions/*`.
30665
32195
  *
30666
- * `api` role only. Set it and this process forwards those requests there
30667
- * instead of mounting them, so a split deployment presents the identical URL
30668
- * surface and nobody has to run a reverse proxy to try one.
32196
+ * `api` and `worker` roles only. Set on the api, this process forwards
32197
+ * those requests there instead of mounting them, so a split deployment
32198
+ * presents the identical URL surface and nobody has to run a reverse proxy
32199
+ * to try one. Set on a worker, only the worker's own in-process
32200
+ * `rebase.functions` calls are forwarded; its port serves none. An http(s)
32201
+ * URL, or the boot stops.
30669
32202
  */
30670
32203
  REBASE_FUNCTIONS_UPSTREAM: z$1.string().optional(),
30671
32204
  /**
@@ -30844,7 +32377,8 @@ var CORS_EXPOSED_HEADERS = [
30844
32377
  "Preference-Applied",
30845
32378
  "Location",
30846
32379
  "Upload-Offset",
30847
- "Rebase-Upload-Result"
32380
+ "Rebase-Upload-Result",
32381
+ "X-Rebase-Simulated-Context"
30848
32382
  ];
30849
32383
  /** The runtime's whole CORS config, so a test can drive the same one boot does. */
30850
32384
  function resolveCorsOptions(env) {
@@ -30981,8 +32515,10 @@ function resolveRole(env) {
30981
32515
  const base = ROLES[role];
30982
32516
  if (!base) throw new RoleConfigurationError(`REBASE_ROLE="${role}" is not a role.`, `Use one of: ${Object.keys(ROLES).join(", ")}.`);
30983
32517
  const migrate = env.REBASE_MIGRATE_ON_BOOT || "ensure";
30984
- if (!base.provisionSchema && migrate !== "none") throw new RoleConfigurationError(`REBASE_ROLE="${role}" cannot also provision the database schema (REBASE_MIGRATE_ON_BOOT="${migrate}").`, "Set REBASE_MIGRATE_ON_BOOT=none on this process. Exactly one process in a split deployment provisions the schema, and that is the one running REBASE_ROLE=api (or all).");
30985
- if (env.REBASE_FUNCTIONS_UPSTREAM && role !== "api") throw new RoleConfigurationError(`REBASE_FUNCTIONS_UPSTREAM is only read by REBASE_ROLE=api, and this process is "${role}".`, role === "functions" ? "A functions process serves those routes; it does not forward them. Set the upstream on the api process instead." : "Remove it, or set REBASE_ROLE=api on this process.");
32518
+ if (!base.provisionSchema && migrate !== "none" && env.REBASE_PROVISION !== false) throw new RoleConfigurationError(`REBASE_ROLE="${role}" cannot also provision the database schema (REBASE_MIGRATE_ON_BOOT="${migrate}").`, "Set REBASE_MIGRATE_ON_BOOT=none on this process. Exactly one process in a split deployment provisions the schema, and that is the one running REBASE_ROLE=api (or all).");
32519
+ const functionsUpstream = env.REBASE_FUNCTIONS_UPSTREAM?.trim() || void 0;
32520
+ if (functionsUpstream && role !== "api" && role !== "worker") throw new RoleConfigurationError(`REBASE_FUNCTIONS_UPSTREAM is only read by REBASE_ROLE=api and REBASE_ROLE=worker, and this process is "${role}".`, role === "functions" ? "A functions process serves those routes; it does not forward them. Set the upstream on the api (or worker) process instead." : "This process serves the functions itself. Remove it, or set REBASE_ROLE=api on this process.");
32521
+ if (functionsUpstream) assertUpstreamUrl(functionsUpstream);
30986
32522
  if (Boolean(env.REBASE_FUNCTIONS_ONLY || env.REBASE_FUNCTIONS_EXCLUDE) && role !== "functions") throw new RoleConfigurationError(`REBASE_FUNCTIONS_ONLY / REBASE_FUNCTIONS_EXCLUDE are only read by REBASE_ROLE=functions, and this process is "${role}".`, "Set them on the functions process, or set REBASE_ROLE=functions here.");
30987
32523
  const ownership = { ...base.ownership };
30988
32524
  if (env.REBASE_CRON_SCHEDULER !== void 0) ownership.cronScheduler = env.REBASE_CRON_SCHEDULER;
@@ -30999,9 +32535,19 @@ function resolveRole(env) {
30999
32535
  only: parseNameList(env.REBASE_FUNCTIONS_ONLY),
31000
32536
  exclude: parseNameList(env.REBASE_FUNCTIONS_EXCLUDE)
31001
32537
  },
31002
- functionsUpstream: env.REBASE_FUNCTIONS_UPSTREAM?.trim() || void 0
32538
+ functionsUpstream
31003
32539
  };
31004
32540
  }
32541
+ /** Refuse a `REBASE_FUNCTIONS_UPSTREAM` that is not an absolute http(s) URL. */
32542
+ function assertUpstreamUrl(value) {
32543
+ let url;
32544
+ try {
32545
+ url = new URL(value);
32546
+ } catch {
32547
+ url = void 0;
32548
+ }
32549
+ if (!url || url.protocol !== "http:" && url.protocol !== "https:") throw new RoleConfigurationError(`REBASE_FUNCTIONS_UPSTREAM="${value}" is not an http(s) URL.`, "Set it to the functions process's base URL, e.g. http://functions:8080.");
32550
+ }
31005
32551
  /**
31006
32552
  * ## Why there is no channel-bus refusal here
31007
32553
  *
@@ -31026,6 +32572,32 @@ function resolveRole(env) {
31026
32572
  * beside the replica count, not here.
31027
32573
  */
31028
32574
  //#endregion
32575
+ //#region src/boot/provision-only.ts
32576
+ /**
32577
+ * `REBASE_PROVISION_ONLY`, read the one way every caller reads it.
32578
+ *
32579
+ * Its own module, with nothing imported, so `tooling/scripts/chart-unit-roles.mts`
32580
+ * can ask the same question of a rendered chart unit without loading the boot.
32581
+ */
32582
+ /**
32583
+ * Whether this process should provision the schema and stop, rather than serve.
32584
+ *
32585
+ * The shape a migration Job wants: same image, same bundle, same boot — and no
32586
+ * socket. Nothing else changes, which is the point. A separate migration image
32587
+ * would be a second artifact to build, publish and keep in step with the
32588
+ * runtime, and the failure mode of it drifting is a schema applied by a version
32589
+ * that is not the one about to run against it.
32590
+ *
32591
+ * A blank value is *unset*: `REBASE_PROVISION_ONLY=${SOMETHING}` with SOMETHING
32592
+ * undefined is the ordinary way to write a compose file or a Helm template, and
32593
+ * reading it as true would turn an ordinary deployment into one that migrates
32594
+ * and then refuses to serve.
32595
+ */
32596
+ function resolveProvisionOnly(env) {
32597
+ const raw = (env.REBASE_PROVISION_ONLY ?? "").trim().toLowerCase();
32598
+ return raw === "1" || raw === "true";
32599
+ }
32600
+ //#endregion
31029
32601
  //#region src/boot/static-routing.ts
31030
32602
  /**
31031
32603
  * A request's hostname in the form an app's `host` is written in: lowercase,
@@ -32287,6 +33859,10 @@ function imageModulesDir() {
32287
33859
  * and ran zero crons. The image installs zod (see
32288
33860
  * infra/docker/server.Dockerfile); naming it here is what makes the bundle's
32289
33861
  * copy collapse onto it.
33862
+ *
33863
+ * `hono` and `@hono/node-server` likewise: the bundler strips them, so a
33864
+ * function importing `hono` directly found no copy under the bundle and failed
33865
+ * to load. See `infra/docker/entrypoint.mjs`.
32290
33866
  */
32291
33867
  var RUNTIME_PROVIDED_PACKAGES = [
32292
33868
  "@rebasepro/server",
@@ -32294,7 +33870,9 @@ var RUNTIME_PROVIDED_PACKAGES = [
32294
33870
  "@rebasepro/client",
32295
33871
  "@rebasepro/common",
32296
33872
  "@rebasepro/utils",
32297
- "zod"
33873
+ "zod",
33874
+ "hono",
33875
+ "@hono/node-server"
32298
33876
  ];
32299
33877
  /**
32300
33878
  * Collapse a duplicate framework copy in the bundle onto the image's.
@@ -32506,6 +34084,323 @@ function bundleRootIn(directory) {
32506
34084
  return null;
32507
34085
  }
32508
34086
  //#endregion
34087
+ //#region src/boot/schema-change-recorder.ts
34088
+ /**
34089
+ * The schema changes a provisioning run made, written down as the SQL script a
34090
+ * change process reviews and applies.
34091
+ *
34092
+ * ## Why a recording, not a plan
34093
+ *
34094
+ * The obvious tool is a dry run: compute the DDL boot would execute against a
34095
+ * database, print it, execute nothing. Rebase's bootstraps cannot be planned
34096
+ * that way. Each one reads what it just created — the auth bootstrap
34097
+ * introspects the users table it made a statement earlier to choose the type of
34098
+ * every foreign key after it; the stores probe their own tables before they
34099
+ * revoke on them — so a run that executes nothing takes different branches from
34100
+ * one that executes everything, and a plan computed from it would be a guess
34101
+ * dressed as an answer.
34102
+ *
34103
+ * So the run is real, and it is recorded. Against an empty scratch database the
34104
+ * script is the whole schema this release needs; against a restored copy of
34105
+ * production it is exactly the upgrade, because every branch saw the state it
34106
+ * will see there. Either way it is a list of statements that ran and succeeded,
34107
+ * in order — which is a claim a reviewer can check, where a plan is a claim
34108
+ * about code.
34109
+ *
34110
+ * ## What is recorded
34111
+ *
34112
+ * Statements that change the database's structure or privileges
34113
+ * ({@link isSchemaChange}): that is what a change process controls. Rows the
34114
+ * run writes — the first admin account, version stamps, a backfill — are not
34115
+ * schema, and the first of those carries a password hash that has no business
34116
+ * in a file that is attached to a ticket.
34117
+ *
34118
+ * Captured at the pool, below every driver abstraction: each connection checked
34119
+ * out of a recorded pool reports the statements it completed. That is the one
34120
+ * place every path converges — Drizzle, the SQL admin, a dedicated index-build
34121
+ * session — so nothing needs to remember to report itself.
34122
+ */
34123
+ function isQueryable(value) {
34124
+ return typeof value === "object" && value !== null && "query" in value && typeof value.query === "function";
34125
+ }
34126
+ /** The SQL text of a `query` call: a string, or a `{ text }` config object. */
34127
+ function statementText(first) {
34128
+ if (typeof first === "string") return first;
34129
+ if (typeof first === "object" && first !== null && "text" in first && typeof first.text === "string") return first.text;
34130
+ }
34131
+ function isQueryCallback(value) {
34132
+ return typeof value === "function";
34133
+ }
34134
+ /** Whether a promise-like came back, the shape `query` has without a callback. */
34135
+ function isThenable(value) {
34136
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
34137
+ }
34138
+ /**
34139
+ * Split a query string into its statements, at every `;` that is not inside a
34140
+ * string, a quoted identifier, a dollar-quoted body or a comment.
34141
+ *
34142
+ * Boot sends several statements as one string — `SET LOCAL lock_timeout`, then
34143
+ * a table and its indexes, committed together — so a recording that looked
34144
+ * only at the first word of each query would miss every table it made that
34145
+ * way. Dollar quotes are the case that matters: a `DO` block or a function
34146
+ * body is full of semicolons that end nothing.
34147
+ */
34148
+ function splitStatements(sqlText) {
34149
+ const statements = [];
34150
+ let start = 0;
34151
+ let i = 0;
34152
+ const push = (end) => {
34153
+ const statement = withoutLeadingComments(sqlText.slice(start, end)).trim();
34154
+ if (statement !== "") statements.push(statement);
34155
+ };
34156
+ while (i < sqlText.length) {
34157
+ const char = sqlText[i];
34158
+ const next = sqlText[i + 1];
34159
+ if (char === "-" && next === "-") {
34160
+ const end = sqlText.indexOf("\n", i);
34161
+ i = end === -1 ? sqlText.length : end + 1;
34162
+ } else if (char === "/" && next === "*") {
34163
+ const end = sqlText.indexOf("*/", i + 2);
34164
+ i = end === -1 ? sqlText.length : end + 2;
34165
+ } else if (char === "'" || char === "\"") {
34166
+ let j = i + 1;
34167
+ while (j < sqlText.length) {
34168
+ if (sqlText[j] === char) {
34169
+ if (sqlText[j + 1] === char) {
34170
+ j += 2;
34171
+ continue;
34172
+ }
34173
+ break;
34174
+ }
34175
+ j++;
34176
+ }
34177
+ i = j + 1;
34178
+ } else if (char === "$") {
34179
+ const tag = i > 0 && /[A-Za-z0-9_]/.test(sqlText[i - 1]) ? void 0 : sqlText.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0];
34180
+ if (tag) {
34181
+ const end = sqlText.indexOf(tag, i + tag.length);
34182
+ i = end === -1 ? sqlText.length : end + tag.length;
34183
+ } else i++;
34184
+ } else if (char === ";") {
34185
+ push(i);
34186
+ start = i + 1;
34187
+ i++;
34188
+ } else i++;
34189
+ }
34190
+ push(sqlText.length);
34191
+ return statements;
34192
+ }
34193
+ function transactionStep(statement) {
34194
+ const words = withoutLeadingComments(statement).split(/\s+/).map((word) => word.replace(/;$/, ""));
34195
+ const [first, second, third] = words.map((word) => word.toUpperCase());
34196
+ const name = (word) => (word ?? "").replace(/^"|"$/g, "").toLowerCase();
34197
+ if (first === "BEGIN" || first === "START" && second === "TRANSACTION") return { kind: "begin" };
34198
+ if (first === "COMMIT" || first === "END") return second === "PREPARED" ? void 0 : { kind: "commit" };
34199
+ if (first === "ROLLBACK" || first === "ABORT") {
34200
+ if (second === "TO") return {
34201
+ kind: "rollback-to",
34202
+ name: name(third === "SAVEPOINT" ? words[3] : words[2])
34203
+ };
34204
+ return second === "PREPARED" ? void 0 : { kind: "rollback" };
34205
+ }
34206
+ if (first === "SAVEPOINT") return {
34207
+ kind: "savepoint",
34208
+ name: name(words[1])
34209
+ };
34210
+ if (first === "RELEASE") return {
34211
+ kind: "release",
34212
+ name: name(second === "SAVEPOINT" ? words[2] : words[1])
34213
+ };
34214
+ }
34215
+ /**
34216
+ * One connection's view of what it has done: schema changes it made inside a
34217
+ * transaction are held until that transaction commits, and dropped if it rolls
34218
+ * back — a probe that tries a change and rolls it away must not reach the
34219
+ * script.
34220
+ */
34221
+ var ConnectionLedger = class {
34222
+ commit;
34223
+ open;
34224
+ savepoints = [];
34225
+ constructor(commit) {
34226
+ this.commit = commit;
34227
+ }
34228
+ /** Account for one query that succeeded. */
34229
+ completed(sqlText) {
34230
+ for (const statement of splitStatements(sqlText)) {
34231
+ const step = transactionStep(statement);
34232
+ if (step) this.apply(step);
34233
+ else if (isSchemaChange(statement)) if (this.open) this.open.push(statement);
34234
+ else this.commit([statement]);
34235
+ }
34236
+ }
34237
+ apply(step) {
34238
+ switch (step.kind) {
34239
+ case "begin":
34240
+ this.open ??= [];
34241
+ return;
34242
+ case "commit":
34243
+ if (this.open) this.commit(this.open);
34244
+ this.open = void 0;
34245
+ this.savepoints = [];
34246
+ return;
34247
+ case "rollback":
34248
+ this.open = void 0;
34249
+ this.savepoints = [];
34250
+ return;
34251
+ case "savepoint":
34252
+ if (this.open) this.savepoints.push({
34253
+ name: step.name,
34254
+ at: this.open.length
34255
+ });
34256
+ return;
34257
+ case "release": {
34258
+ const index = this.savepoints.map((s) => s.name).lastIndexOf(step.name);
34259
+ if (index !== -1) this.savepoints.splice(index);
34260
+ return;
34261
+ }
34262
+ case "rollback-to": {
34263
+ const savepoint = [...this.savepoints].reverse().find((s) => s.name === step.name);
34264
+ if (this.open && savepoint) this.open.splice(savepoint.at);
34265
+ return;
34266
+ }
34267
+ }
34268
+ }
34269
+ };
34270
+ /**
34271
+ * Record every schema change completed on connections checked out of `pools`.
34272
+ *
34273
+ * Wraps `connect` on each pool object, and `query` on each connection it hands
34274
+ * out — never a prototype, so nothing outside these pools is touched. Must be
34275
+ * installed before the first statement: a `pg.Pool` connects lazily, so a pool
34276
+ * that has just been created has nothing open yet, and `pool.query` checks out
34277
+ * through `connect` like everything else.
34278
+ */
34279
+ function recordSchemaChanges(pools) {
34280
+ const recorded = [];
34281
+ let active = true;
34282
+ const wrapped = /* @__PURE__ */ new WeakSet();
34283
+ const observe = (client) => {
34284
+ if (!isQueryable(client) || wrapped.has(client)) return;
34285
+ wrapped.add(client);
34286
+ const ledger = new ConnectionLedger((statements) => {
34287
+ if (active) recorded.push(...statements);
34288
+ });
34289
+ const note = (text) => {
34290
+ if (text !== void 0) ledger.completed(text);
34291
+ };
34292
+ const original = client.query;
34293
+ client.query = function recordedQuery(...args) {
34294
+ const text = statementText(args[0]);
34295
+ const last = args[args.length - 1];
34296
+ if (isQueryCallback(last)) {
34297
+ args[args.length - 1] = (err, result) => {
34298
+ if (!err) note(text);
34299
+ return last(err, result);
34300
+ };
34301
+ return original.apply(this, args);
34302
+ }
34303
+ const out = original.apply(this, args);
34304
+ if (isThenable(out)) return out.then((result) => {
34305
+ note(text);
34306
+ return result;
34307
+ });
34308
+ return out;
34309
+ };
34310
+ };
34311
+ for (const pool of pools) {
34312
+ const connect = pool.connect;
34313
+ if (typeof connect !== "function") continue;
34314
+ pool.connect = function recordedConnect(callback) {
34315
+ if (typeof callback === "function") return connect.call(this, (err, client, done) => {
34316
+ if (!err) observe(client);
34317
+ callback(err, client, done);
34318
+ });
34319
+ const checkout = connect.call(this);
34320
+ return checkout === void 0 ? checkout : checkout.then((client) => {
34321
+ observe(client);
34322
+ return client;
34323
+ });
34324
+ };
34325
+ }
34326
+ return {
34327
+ statements: () => [...recorded],
34328
+ stop: () => {
34329
+ active = false;
34330
+ }
34331
+ };
34332
+ }
34333
+ /**
34334
+ * Strip the indentation a statement carries from the template literal it was
34335
+ * written in, so the script reads as SQL rather than as source code.
34336
+ */
34337
+ function dedent(statement) {
34338
+ const lines = statement.replace(/^\s*\n/, "").replace(/\s+$/, "").split("\n");
34339
+ const indents = lines.slice(1).filter((line) => line.trim() !== "").map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0);
34340
+ const strip = indents.length > 0 ? Math.min(...indents) : 0;
34341
+ return lines.map((line, index) => index === 0 ? line.trim() : line.slice(Math.min(strip, line.match(/^[ \t]*/)?.[0].length ?? 0))).join("\n");
34342
+ }
34343
+ /** One statement, terminated exactly once. */
34344
+ function terminated(statement) {
34345
+ const text = dedent(statement);
34346
+ return text.endsWith(";") ? text : `${text};`;
34347
+ }
34348
+ /** Where the run happened, for the script's header — never the credentials. */
34349
+ function describeDatabase(connectionString) {
34350
+ if (!connectionString) return void 0;
34351
+ try {
34352
+ const url = new URL(connectionString);
34353
+ const database = url.pathname.replace(/^\//, "");
34354
+ return `${url.hostname}${url.port ? `:${url.port}` : ""}${database ? `/${database}` : ""}`;
34355
+ } catch {
34356
+ return;
34357
+ }
34358
+ }
34359
+ /**
34360
+ * The script a change process applies: a header saying what it is and how to
34361
+ * run it, then every recorded statement in order.
34362
+ *
34363
+ * Deliberately free of anything that changes between two runs of the same
34364
+ * release against the same state — no timestamp — so that two scripts can be
34365
+ * diffed, and a reviewer comparing this release's script with the last one
34366
+ * sees only what the release changed.
34367
+ */
34368
+ function renderSchemaChangeScript(statements, context) {
34369
+ const header = [
34370
+ "-- Schema changes made by a Rebase provisioning run (REBASE_PROVISION_ONLY=true).",
34371
+ "--",
34372
+ ...context.runtimeVersion ? [`-- Runtime: @rebasepro/server ${context.runtimeVersion}`] : [],
34373
+ ...context.database ? [`-- Recorded against: ${context.database}`] : [],
34374
+ `-- Statements: ${statements.length}`,
34375
+ "--",
34376
+ "-- Every statement below ran, in this order, and succeeded. Recorded against an",
34377
+ "-- empty database, this is the whole schema the release needs; against a copy of",
34378
+ "-- production, it is the upgrade. Apply it as a role that may create in the database:",
34379
+ "--",
34380
+ "-- psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f <this file>",
34381
+ ...statements.some((statement) => /\bCONCURRENTLY\b/i.test(statement)) ? [
34382
+ "--",
34383
+ "-- Not inside one transaction (no `psql -1`, no BEGIN around it): it builds an",
34384
+ "-- index CONCURRENTLY, which PostgreSQL refuses to run in a transaction block."
34385
+ ] : [],
34386
+ "--",
34387
+ "-- Rows the run wrote (the first admin account, version stamps, backfills) are not",
34388
+ "-- schema and are not included."
34389
+ ];
34390
+ if (statements.length === 0) return [
34391
+ ...header,
34392
+ "",
34393
+ "-- Nothing to change: the database already had everything this release needs.",
34394
+ ""
34395
+ ].join("\n");
34396
+ return [
34397
+ ...header,
34398
+ "",
34399
+ statements.map(terminated).join("\n\n"),
34400
+ ""
34401
+ ].join("\n");
34402
+ }
34403
+ //#endregion
32509
34404
  //#region src/boot/boot.ts
32510
34405
  /**
32511
34406
  * Boot a Rebase runtime from a built bundle.
@@ -32552,6 +34447,18 @@ async function bootFromBundle(options = {}) {
32552
34447
  const env = loadBootEnv();
32553
34448
  const isProduction = env.NODE_ENV === "production";
32554
34449
  const runtimeRole = resolveRole(env);
34450
+ const provisionOnlyRun = resolveProvisionOnly(process.env);
34451
+ if (!env.REBASE_PROVISION && provisionOnlyRun) throw new RoleConfigurationError("REBASE_PROVISION=false and REBASE_PROVISION_ONLY=true ask for a provisioning run that may not provision.", "Unset REBASE_PROVISION on the run that provisions — the migration step your change process runs — and keep REBASE_PROVISION=false on the processes that serve.");
34452
+ const sqlOut = env.REBASE_PROVISION_SQL_OUT?.trim() || void 0;
34453
+ if (sqlOut) {
34454
+ if (!provisionOnlyRun) throw new RoleConfigurationError("REBASE_PROVISION_SQL_OUT is written by a provisioning run, and this process serves.", "Set REBASE_PROVISION_ONLY=true beside it: the run provisions, writes the SQL it executed, and exits.");
34455
+ if (!env.REBASE_PROVISION) throw new RoleConfigurationError("REBASE_PROVISION_SQL_OUT records the schema changes a run makes, and REBASE_PROVISION=false makes none.", "Unset REBASE_PROVISION on the run that records.");
34456
+ try {
34457
+ fs.writeFileSync(sqlOut, "");
34458
+ } catch (err) {
34459
+ throw new RoleConfigurationError(`REBASE_PROVISION_SQL_OUT="${sqlOut}" cannot be written: ${err instanceof Error ? err.message : String(err)}`, "Point it at a file in a directory that exists and that this process can write.");
34460
+ }
34461
+ }
32555
34462
  const liveSchema = resolveLiveSchemaOptions(env);
32556
34463
  if (runtimeRole.role !== "all") logger.info(`Runtime role: ${runtimeRole.role}`, {
32557
34464
  provisionsSchema: runtimeRole.provisionSchema,
@@ -32571,6 +34478,12 @@ async function bootFromBundle(options = {}) {
32571
34478
  const rlsAudit = await resolveRlsAuditOptions(env, driverRoots);
32572
34479
  const dataSources = await initializeDataSources(resolvedSources, schema, driverRoots);
32573
34480
  warnOnDriverSkew(dataSources, readRuntimeVersion(driverRoots));
34481
+ let schemaChangeRecorder;
34482
+ if (sqlOut) {
34483
+ const unrecordable = dataSources.filter((source) => typeof source.connection.pool?.connect !== "function");
34484
+ if (unrecordable.length > 0) throw new RoleConfigurationError(`REBASE_PROVISION_SQL_OUT cannot record data source ${unrecordable.map((source) => `"${source.key}"`).join(", ")}: its driver hands back no connection pool to observe.`, "Recording is for SQL drivers that pool their connections, as the Postgres driver does.");
34485
+ schemaChangeRecorder = recordSchemaChanges(dataSources.map((source) => source.connection.pool ?? {}));
34486
+ }
32574
34487
  const app = new Hono();
32575
34488
  installRootErrorHandler(app);
32576
34489
  app.use("/*", runtimeCors(resolveCorsOptions(env)));
@@ -32585,8 +34498,9 @@ async function bootFromBundle(options = {}) {
32585
34498
  const storage = resolveBootStorage(bundle, process.env, storageSourceDefs, isProduction);
32586
34499
  warnOnUnusableBundleShape(bundle);
32587
34500
  installUnhandledRejectionHandler();
34501
+ const ownsSchema = options.provisionSchema ?? runtimeRole.provisionSchema;
32588
34502
  const createdBackend = await createRebaseBackend({
32589
- provision: options.provisionSchema ?? runtimeRole.provisionSchema ? true : {
34503
+ provision: !env.REBASE_PROVISION ? false : ownsSchema ? true : {
32590
34504
  collections: false,
32591
34505
  realtime: false
32592
34506
  },
@@ -32622,7 +34536,7 @@ async function bootFromBundle(options = {}) {
32622
34536
  setDefaultBackend(createdBackend);
32623
34537
  createdBackend.mount(app, { basePath: env.REBASE_BASE_PATH });
32624
34538
  await createdBackend.attachRealtime(server);
32625
- createdBackend.start();
34539
+ if (!provisionOnlyRun) createdBackend.start();
32626
34540
  const backend = {
32627
34541
  ...createdBackend,
32628
34542
  shutdown: (timeoutMs, shutdownOptions) => createdBackend.shutdown(timeoutMs, {
@@ -32683,8 +34597,18 @@ async function bootFromBundle(options = {}) {
32683
34597
  });
32684
34598
  }
32685
34599
  }
34600
+ if (provisionOnlyRun && env.REBASE_PROVISION) await provisionFirstUseTables(createdBackend.driver, process.env);
34601
+ if (schemaChangeRecorder && sqlOut) {
34602
+ schemaChangeRecorder.stop();
34603
+ const statements = schemaChangeRecorder.statements();
34604
+ fs.writeFileSync(sqlOut, renderSchemaChangeScript(statements, {
34605
+ runtimeVersion: readRuntimeVersion(driverRoots),
34606
+ database: describeDatabase(dataSources[0]?.connection.connectionString)
34607
+ }));
34608
+ logger.info(`Wrote the ${statements.length} schema change(s) this run made to ${sqlOut}.`);
34609
+ }
32686
34610
  let port = env.PORT;
32687
- if (!(options.listen === false || resolveProvisionOnly(process.env))) if (isProduction) {
34611
+ if (options.listen !== false && !provisionOnlyRun) if (isProduction) {
32688
34612
  await new Promise((resolve, reject) => {
32689
34613
  server.once("error", reject);
32690
34614
  server.listen(env.PORT, () => {
@@ -32861,24 +34785,6 @@ async function bootStaticApp(bundle, devRoot, options) {
32861
34785
  * answer is "set DATABASE_URL". Anything else keeps its stack, because it is a
32862
34786
  * bug and the trace is the point.
32863
34787
  */
32864
- /**
32865
- * Whether this process should provision the schema and stop, rather than serve.
32866
- *
32867
- * The shape a migration Job wants: same image, same bundle, same boot — and no
32868
- * socket. Nothing else changes, which is the point. A separate migration image
32869
- * would be a second artifact to build, publish and keep in step with the
32870
- * runtime, and the failure mode of it drifting is a schema applied by a version
32871
- * that is not the one about to run against it.
32872
- *
32873
- * A blank value is *unset*: `REBASE_PROVISION_ONLY=${SOMETHING}` with SOMETHING
32874
- * undefined is the ordinary way to write a compose file or a Helm template, and
32875
- * reading it as true would turn an ordinary deployment into one that migrates
32876
- * and then refuses to serve.
32877
- */
32878
- function resolveProvisionOnly(env = process.env) {
32879
- const raw = (env.REBASE_PROVISION_ONLY ?? "").trim().toLowerCase();
32880
- return raw === "1" || raw === "true";
32881
- }
32882
34788
  async function runFromBundle(options = {}) {
32883
34789
  try {
32884
34790
  const booted = await bootFromBundle(options);
@@ -33046,6 +34952,6 @@ function warnOnUnusableBundleShape(bundle) {
33046
34952
  logger.warn(`This bundle declares collections at "${bundle.manifest.entry.collections}", but that directory did not resolve, so no collections were loaded and no tables will be created. Rebuild with \`rebase build\`.`);
33047
34953
  }
33048
34954
  //#endregion
33049
- export { ACCOUNT_SCOPED_STORAGE_BASES, ApiError, BATCH_REF_KEY, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CORS_EXPOSED_HEADERS, CollectionConfigError, CronScheduler, DATABASE_UNAVAILABLE_MESSAGE, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, ETAG_HEADER, FIELD_OPERATORS, GCSStorageController, IF_MATCH_HEADER, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RUNTIME_BUNDLE_MOUNT, RUNTIME_DEFAULT_MAX_BODY_SIZE, RUNTIME_HEALTH_PATH, RUNTIME_LIVENESS_PATH, RUNTIME_MIN_TERMINATION_GRACE_SECONDS, RUNTIME_PRESTOP_DRAIN_SECONDS, RUNTIME_PROBE_PATHS, RUNTIME_STARTUP_BUDGET_SECONDS, RawHtml, S3StorageController, SMTPEmailService, StoragePolicyError, TOPOLOGY_ENV_VARS, TransformCache, TusHandler, UPLOAD_COLLECTION_FIELD, UPLOAD_PROPERTY_FIELD, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, accessModelFromCollections, activeDevEmailSink, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertEveryKindBindable, assertFieldOpsValid, assertIfMatch, assertNestedWriteAllowed, assertNoFieldOpsOnCreate, assertNoUserUpsert, assertQueryFieldsReadable, assertUploadWithinPathLimits, assertUploadWithinPropertyLimits, assertUserCreationBodyValid, assertWriteRequestValid, authJwt, authRoles, authUid, backupStorageFromEnv, blockedAddressReason, bootFromBundle, callerScopes, classifySurface, cleanupDevPortFile, clearActiveDevEmailSink, compileStoragePolicies, composeCallbacks, configureAccess, configureJwt, createAppleProvider, createBackupLinkRoutes, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createCallContext, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDevEmailSink, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createJwtService, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createRebaseBackend, createRlsAudit, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, createUnconfiguredEmailService, createUploadConstraintResolver, createUploadPathResolver, createUserThroughAuthCollection, decideImpersonation, declareCrons, declaredErrorAnswer, declaredUniqueTargets, defaultBackoff, defineCron, defineFunction, drainBackgroundWork, envSuffixForKey, errorHandler, escapeHtml, extractLinks, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getAccessModel, getApiKey, getDriver, getEmailChangeNoticeTemplate, getEmailChangeTemplate, getEmailOtpTemplate, getEmailVerificationTemplate, getEnv, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getRequestId, getRoles, getUser, getUserId, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hasFieldOps, hasRole, hasScope, hashPassword, html, httpMethodToOperation, identityResolved, implicitDeclaration, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, installUnhandledRejectionHandler, isAcceptedFile, isAdmin, isApiKeyToken, isAuthAdapter, isAuthenticated, isConcurrentDdlRace, isDataException, isDatabaseAdapter, isDatabaseUnreachable, isDuplicateObjectRace, isLocalhostOrigin, isNodeRuntime, isTopologyEnvVar, isTransformableImage, isUncountedPath, lazyResource, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadCrons, loadEnv, loadFunctions, loadFunctionsFromDirectory, loadFunctionsWithDiagnostics, loadUsersCollection, logger, looksLikeFieldOp, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseFieldOp, parseTransformOptions, pendingBackgroundWork, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, rawQueryLoggingEnabled, readBackupBytes, readBundleManifest, readUploadPropertyContext, realtimeSocketPath, rebase, registerDevEmailSink, registerResourceResolver, requireAdmin, requireAuth, requireDriver, requireEnv, requireRole, requireScope, resolveApiKey, resolveAuthHooks, resolveAuthOptions, resolveBatchRefs, resolveConflictTarget, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageAccessControl, resolveStorageBackend, resolveStorageSources, resourceResolver, resourceResolvers, rowETag, runFromBundle, runtimeKey, safeCompare, schemaDriftRemedy, serveSPA, setDefaultBackend, setLogOutput, splitFieldOps, summarize as summarizeRlsScan, transformImage, tryVerifyOidcIdToken, unbindableKinds, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyCredential, verifyOidcIdToken, verifyPassword, versionProperty, waitUntil, z };
34955
+ export { ACCOUNT_SCOPED_STORAGE_BASES, ApiError, BATCH_REF_KEY, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CORS_EXPOSED_HEADERS, CollectionConfigError, CronScheduler, DATABASE_UNAVAILABLE_MESSAGE, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_REFERRER_POLICY, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, ETAG_HEADER, FIELD_OPERATORS, GCSStorageController, IF_MATCH_HEADER, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RUNTIME_BUNDLE_MOUNT, RUNTIME_DEFAULT_MAX_BODY_SIZE, RUNTIME_HEALTH_PATH, RUNTIME_LIVENESS_PATH, RUNTIME_MIN_TERMINATION_GRACE_SECONDS, RUNTIME_PRESTOP_DRAIN_SECONDS, RUNTIME_PROBE_PATHS, RUNTIME_STARTUP_BUDGET_SECONDS, RawHtml, S3StorageController, SMTPEmailService, StoragePolicyError, TOPOLOGY_ENV_VARS, TransformCache, TusHandler, UPLOAD_COLLECTION_FIELD, UPLOAD_PROPERTY_FIELD, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, accessModelFromCollections, activeDevEmailSink, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertEveryKindBindable, assertFieldOpsValid, assertIfMatch, assertNestedWriteAllowed, assertNoFieldOpsOnCreate, assertNoUserUpsert, assertQueryFieldsReadable, assertUploadWithinPathLimits, assertUploadWithinPropertyLimits, assertUserCreationBodyValid, assertWriteRequestValid, authJwt, authRoles, authUid, backupStorageFromEnv, blockedAddressReason, bootFromBundle, callerScopes, classifySurface, cleanupDevPortFile, clearActiveDevEmailSink, compileStoragePolicies, composeCallbacks, configureAccess, configureJwt, createAppleProvider, createBackupLinkRoutes, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createCallContext, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDevEmailSink, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createJwtService, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createRebaseBackend, createRlsAudit, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, createUnconfiguredEmailService, createUploadConstraintResolver, createUploadPathResolver, createUserThroughAuthCollection, decideImpersonation, declareCrons, declaredErrorAnswer, declaredUniqueTargets, defaultBackoff, defineCron, defineFunction, devEmailSinkFor, drainBackgroundWork, envSuffixForKey, errorHandler, escapeHtml, extractLinks, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getAccessModel, getApiKey, getDriver, getEmailChangeNoticeTemplate, getEmailChangeTemplate, getEmailOtpTemplate, getEmailVerificationTemplate, getEnv, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getRequestId, getRoles, getUser, getUserId, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hasFieldOps, hasRole, hasScope, hashPassword, html, httpMethodToOperation, identityResolved, implicitDeclaration, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, installUnhandledRejectionHandler, isAcceptedFile, isAdmin, isApiKeyToken, isAuthAdapter, isAuthenticated, isConcurrentDdlRace, isDataException, isDatabaseAdapter, isDatabaseUnreachable, isDuplicateObjectRace, isLocalhostOrigin, isNodeRuntime, isTopologyEnvVar, isTransformableImage, isUncountedPath, lazyResource, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadCrons, loadEnv, loadFunctions, loadFunctionsFromDirectory, loadFunctionsWithDiagnostics, loadUsersCollection, logger, looksLikeFieldOp, oauthCodeFlowSchema, optionalAuth, overridableSecurityHeaders, parseBackupDestination, parseBackupTimestamp, parseFieldOp, parseTransformOptions, pendingBackgroundWork, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, rawQueryLoggingEnabled, readBackupBytes, readBundleManifest, readUploadPropertyContext, realtimeSocketPath, rebase, refuseUnhonouredImpersonation, registerDevEmailSink, registerResourceResolver, reportSimulatedContext, requireAdmin, requireAuth, requireDriver, requireEnv, requireRole, requireScope, resolveApiKey, resolveAuthHooks, resolveAuthOptions, resolveBatchRefs, resolveConflictTarget, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageAccessControl, resolveStorageBackend, resolveStorageSources, resourceResolver, resourceResolvers, rowETag, runFromBundle, runtimeKey, safeCompare, schemaDriftRemedy, serveSPA, setDefaultBackend, setLogOutput, splitFieldOps, summarize as summarizeRlsScan, transformImage, tryVerifyOidcIdToken, unbindableKinds, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyCredential, verifyOidcIdToken, verifyPassword, versionProperty, waitUntil, z };
33050
34956
 
33051
34957
  //# sourceMappingURL=index.es.js.map