@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.
- package/dist/{GCSStorageController-CRd9bFrc.js → GCSStorageController-40FyFeSx.js} +6 -2
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-D00EFa5P.js → S3StorageController-Bl5shp94.js} +6 -2
- package/dist/S3StorageController-Bl5shp94.js.map +1 -0
- package/dist/access-BFAUvsSs.js +1897 -0
- package/dist/access-BFAUvsSs.js.map +1 -0
- package/dist/api/branch-routes.d.ts +25 -0
- package/dist/api/live-schema-routes.d.ts +10 -0
- package/dist/api/rest/api-generator.d.ts +9 -1
- package/dist/api/rest/idempotency.d.ts +15 -1
- package/dist/api/root-error-handler.d.ts +6 -5
- package/dist/api/types.d.ts +12 -0
- package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
- package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
- package/dist/auth/access.d.ts +10 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/adapter-service-key.d.ts +24 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
- package/dist/auth/api-keys/api-key-store.d.ts +8 -1
- package/dist/auth/api-keys/key-grant.d.ts +18 -1
- package/dist/auth/expired-token-sweep.d.ts +5 -0
- package/dist/auth/impersonation.d.ts +3 -1
- package/dist/auth/jwt.d.ts +8 -2
- package/dist/auth/oauth-signin-policy.d.ts +21 -35
- package/dist/auth/rls-scope.d.ts +17 -7
- package/dist/auth/rls-simulator-routes.d.ts +51 -0
- package/dist/auth/simulation-read-only.d.ts +28 -0
- package/dist/auth/simulation.d.ts +141 -0
- package/dist/auth/sql-rate-limit-store.d.ts +15 -1
- package/dist/{auth-Dkvo2tTJ.js → auth-izI7AEzC.js} +370 -1880
- package/dist/auth-izI7AEzC.js.map +1 -0
- package/dist/auth_adapter-B7RUMk6e.js +89 -0
- package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
- package/dist/backend/create-backend.d.ts +6 -0
- package/dist/backend/options.d.ts +9 -4
- package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
- package/dist/backend-CaG_Jyso.js.map +1 -0
- package/dist/{backup-B9m3cb3X.js → backup-BVbHoKo8.js} +6 -6
- package/dist/{backup-B9m3cb3X.js.map → backup-BVbHoKo8.js.map} +1 -1
- package/dist/boot/boot.d.ts +2 -15
- package/dist/boot/ddl-bootstrap.d.ts +34 -0
- package/dist/boot/env.d.ts +6 -0
- package/dist/boot/fetch-bundle.d.ts +5 -1
- package/dist/boot/provision-only.d.ts +23 -0
- package/dist/boot/provision.d.ts +53 -2
- package/dist/boot/role.d.ts +2 -0
- package/dist/boot/schema-change-recorder.d.ts +45 -0
- package/dist/boot/security-headers.d.ts +22 -0
- package/dist/branch-routes-B2vvEx53.js +84 -0
- package/dist/branch-routes-B2vvEx53.js.map +1 -0
- package/dist/{contract-routes-CsFXW7j4.js → contract-routes-CU2A3JnF.js} +2 -2
- package/dist/{contract-routes-CsFXW7j4.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-Dj6lA4HQ.js → cron-loader-mMhqbVsl.js} +2 -2
- package/dist/{cron-loader-Dj6lA4HQ.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-CwbLZguO.js → cron-routes-Cs33j_Kr.js} +3 -3
- package/dist/{cron-routes-CwbLZguO.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-B04ACoUE.js → cron-scheduler-Be7CNghL.js} +2 -2
- package/dist/{cron-scheduler-B04ACoUE.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DkyfuLOH.js → cron-store-DLn11M8H.js} +10 -9
- package/dist/cron-store-DLn11M8H.js.map +1 -0
- package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
- package/dist/data_driver-D0mg1Y_x.js.map +1 -0
- package/dist/{ddl-bootstrap-Ck20GSIX.js → ddl-bootstrap-CjRrn12l.js} +91 -2
- package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/email/dev-sink.d.ts +6 -0
- package/dist/email/index.d.ts +1 -1
- package/dist/{soft-delete-params-BmlVwecC.js → field-access-DYsvjI_P.js} +27 -57
- package/dist/field-access-DYsvjI_P.js.map +1 -0
- package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
- package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
- package/dist/{function-loader-KKDqTcIr.js → function-loader-B8YzXEsQ.js} +21 -14
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +19 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +3 -3
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/history/retention-sweep.d.ts +5 -0
- package/dist/{history-recorder-B2rkuhuy.js → history-recorder-Bsau7h4X.js} +6 -6
- package/dist/history-recorder-Bsau7h4X.js.map +1 -0
- package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
- package/dist/history-store-BQz5YCkW.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.es.js +2449 -543
- package/dist/index.es.js.map +1 -1
- package/dist/init/process-safety.d.ts +4 -3
- package/dist/init/shutdown.d.ts +8 -0
- package/dist/init.d.ts +6 -1
- package/dist/jobs/job-store.d.ts +8 -1
- package/dist/{jobs-h1WRq3cp.js → jobs-Co6Ni5HP.js} +6 -6
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-D3oFauZI.js → jwt-rDS0884_.js} +4 -3
- package/dist/{jwt-D3oFauZI.js.map → jwt-rDS0884_.js.map} +1 -1
- package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
- package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
- package/dist/mcp/consent-page.d.ts +38 -1
- package/dist/mcp/mcp-routes.d.ts +9 -0
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-store.d.ts +12 -5
- package/dist/metrics/history-recorder.d.ts +4 -1
- package/dist/metrics/history-store.d.ts +8 -0
- package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
- package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
- package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
- package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
- package/dist/{openapi-generator-BjHr6upl.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-CH8Jsovg.js → proxy-BYir8tWK.js} +4 -6
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-otFZqJsC.js → query-parser-CDXWf2hH.js} +6 -5
- package/dist/{query-parser-otFZqJsC.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-59crS-9u.js → request-timeout-DVjo-c_8.js} +4 -4
- package/dist/request-timeout-DVjo-c_8.js.map +1 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
- package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
- package/dist/{schema-editor-routes-Bsut0bCC.js → schema-editor-routes-Dc3DU_jm.js} +3 -3
- package/dist/{schema-editor-routes-Bsut0bCC.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
- package/dist/soft-delete-params-BRONCOA2.js +59 -0
- package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
- package/dist/{sql-export-routes-DmB_uZ4l.js → sql-export-routes-QycX8W8S.js} +38 -4
- package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
- package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
- package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +8 -3
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/maintenance-keys.d.ts +17 -0
- package/dist/storage/maintenance-routes.d.ts +28 -5
- package/dist/storage/maintenance.d.ts +136 -4
- package/dist/storage/orphan-scans.d.ts +120 -0
- package/dist/storage/types.d.ts +11 -0
- package/dist/strings.es-B7QLnHh2.js +96 -0
- package/dist/strings.es-B7QLnHh2.js.map +1 -0
- package/dist/upload-validation-Bgo626uY.js.map +1 -1
- package/package.json +5 -5
- package/dist/GCSStorageController-CRd9bFrc.js.map +0 -1
- package/dist/S3StorageController-D00EFa5P.js.map +0 -1
- package/dist/auth-Dkvo2tTJ.js.map +0 -1
- package/dist/auth_adapter-CIItCTc8.js +0 -21
- package/dist/auth_adapter-CIItCTc8.js.map +0 -1
- package/dist/backend-DTAOsLQc.js.map +0 -1
- package/dist/cron-store-DkyfuLOH.js.map +0 -1
- package/dist/ddl-bootstrap-Ck20GSIX.js.map +0 -1
- package/dist/function-loader-KKDqTcIr.js.map +0 -1
- package/dist/history-recorder-B2rkuhuy.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-h1WRq3cp.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BjHr6upl.js.map +0 -1
- package/dist/proxy-CH8Jsovg.js.map +0 -1
- package/dist/request-timeout-59crS-9u.js.map +0 -1
- package/dist/soft-delete-params-BmlVwecC.js.map +0 -1
- package/dist/sql-export-routes-DmB_uZ4l.js.map +0 -1
- 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
|
|
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-
|
|
8
|
-
import { A as
|
|
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-
|
|
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
|
|
13
|
-
import { a as parseEnvBoolean, i as validateCronExpression, t as CronScheduler } from "./cron-scheduler-
|
|
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-
|
|
16
|
-
import {
|
|
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 {
|
|
19
|
-
import {
|
|
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-
|
|
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 {
|
|
26
|
-
import { a as canonicalStorageId, i as canonicalStorageBucket, n as InvalidStorageBucketError, o as canonicalStorageKey, r as InvalidStorageKeyError, t as InvalidListOptionsError } from "./keys-
|
|
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-
|
|
30
|
-
import { t as GCSStorageController } from "./GCSStorageController-
|
|
31
|
-
import { a as
|
|
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-
|
|
36
|
-
import "./request-timeout-
|
|
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-
|
|
39
|
-
import { t as createCronRoutes } from "./cron-routes-
|
|
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-
|
|
41
|
-
import { a as defaultBackoff, i as createJobQueue, n as createJobStore, o as currentAmbientTransaction, r as PermanentJobError } from "./jobs-
|
|
42
|
-
import { t as METRIC_SERIES } from "./history-store-
|
|
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
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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
|
-
*
|
|
6622
|
-
*
|
|
6623
|
-
*
|
|
6624
|
-
*
|
|
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
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
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 (
|
|
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-
|
|
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
|
-
|
|
9236
|
-
if (
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:${
|
|
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
|
|
14795
|
-
*
|
|
14796
|
-
*
|
|
14797
|
-
*
|
|
14798
|
-
*
|
|
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
|
-
|
|
15864
|
-
|
|
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,
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
19320
|
-
|
|
19321
|
-
|
|
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
|
-
|
|
21432
|
-
if (
|
|
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
|
-
|
|
21441
|
-
|
|
21442
|
-
|
|
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
|
-
|
|
21446
|
-
|
|
21447
|
-
|
|
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
|
-
|
|
21454
|
-
|
|
21455
|
-
|
|
21456
|
-
|
|
21457
|
-
|
|
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
|
-
|
|
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`
|
|
22650
|
+
* gate (`database:read` to read, `database:write` for every other method).
|
|
21588
22651
|
*
|
|
21589
|
-
*
|
|
21590
|
-
*
|
|
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
|
-
/**
|
|
21594
|
-
var
|
|
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
|
|
21598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21664
|
-
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
|
|
21718
|
-
const
|
|
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>“${clientName}” wants access to your account</h1>
|
|
22336
|
-
<p class="resource">${
|
|
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>${
|
|
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 “Forgot password” 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
|
|
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.
|
|
22384
|
-
password.
|
|
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
|
-
|
|
22397
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
26957
|
-
|
|
26958
|
-
|
|
26959
|
-
|
|
26960
|
-
|
|
26961
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27045
|
-
|
|
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
|
-
|
|
27210
|
-
|
|
27211
|
-
|
|
27212
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
27283
|
-
const { CronScheduler } = await import("./cron-scheduler-
|
|
27284
|
-
const { createCronRoutes } = await import("./cron-routes-
|
|
27285
|
-
const { createCronStore } = await import("./cron-store-
|
|
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
|
-
|
|
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-
|
|
27347
|
-
metricsHistory = createMetricsHistory(defaultDriver);
|
|
27348
|
-
if (metricsHistory
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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 =
|
|
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 =
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
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`
|
|
30667
|
-
* instead of mounting them, so a split deployment
|
|
30668
|
-
* surface and nobody has to run a reverse proxy
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 (
|
|
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
|