@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
|
@@ -2,7 +2,7 @@ import { createRequire as __rebaseCreateRequire } from "module";
|
|
|
2
2
|
import __rebaseProcess from "process";
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
import {
|
|
5
|
+
import { k as DEFAULT_STORAGE_SOURCE_KEY } from "./src-Bh9LRPb9.js";
|
|
6
6
|
//#region src/storage/keys.ts
|
|
7
7
|
/**
|
|
8
8
|
* Canonical storage keys and bucket names.
|
|
@@ -228,4 +228,4 @@ var InvalidListOptionsError = class extends Error {
|
|
|
228
228
|
//#endregion
|
|
229
229
|
export { canonicalStorageId as a, listingPrefix as c, canonicalStorageBucket as i, InvalidStorageBucketError as n, canonicalStorageKey as o, InvalidStorageKeyError as r, folderKey as s, InvalidListOptionsError as t };
|
|
230
230
|
|
|
231
|
-
//# sourceMappingURL=keys-
|
|
231
|
+
//# sourceMappingURL=keys-BXcdRNhJ.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"keys-COzQ2aE8.js","names":[],"sources":["../src/storage/keys.ts"],"sourcesContent":["/**\n * Canonical storage keys and bucket names.\n *\n * Storage is not under RLS, so a `storageAuthorize` hook is the whole access\n * control model — and a hook can only be correct if the key it is shown is the\n * key that is written. That is the invariant this module exists to hold: one\n * canonical string, computed once per request, handed to the hook, to the\n * controller, and to the download token alike.\n *\n * ## Why rejecting beats stripping\n *\n * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two\n * things were wrong with that, and only one of them was the obvious one.\n *\n * The obvious one: a single pass is not a fixed point. `....//` contains `../`\n * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured\n * the traversal it was there to remove. `users/alice/....//bob/x` came out as\n * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with\n * `users/alice/`) and the filesystem reads as bob's. The hook approved one\n * object and the controller wrote another.\n *\n * The subtler one, and the reason this is a rewrite rather than a loop: even a\n * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and\n * gets an object at `a/b` was not protected, they were misled — and every later\n * read, ownership row and audit line now refers to a path nobody chose. So a key\n * that means something other than what it says is refused (400), not repaired.\n *\n * Note what is NOT traversal under this rule: `....` is an ordinary directory\n * name, and `users/alice/....//bob/x` canonicalizes to\n * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is\n * exactly right. Only a real `..` segment is refused.\n */\n\nimport { DEFAULT_STORAGE_SOURCE_KEY } from \"@rebasepro/types\";\n\n/**\n * `path.posix.normalize`, without `node:path`.\n *\n * A storage key is a POSIX-shaped string that never touches a filesystem, so\n * reaching for `node:path` to fold `.` and `//` out of it was always a little\n * wrong on its own terms — on Windows the platform `path` would have applied\n * different rules to the same key. It also put the module that every storage\n * read, ownership row and audit line goes through on the list of things that\n * only run in a Node process.\n *\n * This is the algorithm Node implements, transcribed: segments are folded left\n * to right, `..` pops unless it would climb past the root of a relative path,\n * and both the leading and the trailing separator survive the round trip. The\n * `..` branch is unreachable from {@link canonicalStorageKey}, which refuses\n * those keys outright before it gets here — it exists so that this function is\n * *the* normalizer rather than a subset of one, and\n * `storage-keys.property.test.ts` holds it to that with a property test against\n * `path.posix.normalize` itself. Exported for exactly that — nothing outside\n * this module should be normalizing a key.\n */\nexport function normalizePosix(input: string): string {\n if (input.length === 0) return \".\";\n const isAbsolute = input.startsWith(\"/\");\n const trailingSeparator = input.endsWith(\"/\");\n\n const segments: string[] = [];\n for (const segment of input.split(\"/\")) {\n if (segment === \"\" || segment === \".\") continue;\n if (segment === \"..\") {\n if (segments.length > 0 && segments[segments.length - 1] !== \"..\") segments.pop();\n else if (!isAbsolute) segments.push(\"..\");\n continue;\n }\n segments.push(segment);\n }\n\n const joined = segments.join(\"/\");\n if (joined === \"\") {\n if (isAbsolute) return \"/\";\n return trailingSeparator ? \"./\" : \".\";\n }\n const withTrailing = trailingSeparator ? `${joined}/` : joined;\n return isAbsolute ? `/${withTrailing}` : withTrailing;\n}\n\n/** Longest key accepted, in UTF-16 code units. Matches the previous cap. */\nexport const MAX_STORAGE_KEY_LENGTH = 1024;\n\n/**\n * A key that cannot be canonicalized. Carries no path back to the caller\n * beyond what they sent, so it is safe to surface as a 400 message.\n */\nexport class InvalidStorageKeyError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageKeyError\";\n }\n}\n\n/**\n * Canonicalize a caller-supplied storage key, or throw\n * {@link InvalidStorageKeyError}.\n *\n * Normalizations applied (safe, idempotent, and meaning-preserving):\n * - leading slashes removed — `/a/b` and `a/b` name the same object\n * - `.` segments and repeated slashes collapsed\n *\n * Refusals (the key means something other than what it says):\n * - any `..` segment, on either separator, at any depth\n * - null bytes\n * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}\n *\n * A trailing slash is preserved: it is how the folder route marks a prefix.\n */\nexport function canonicalStorageKey(rawKey: string): string {\n if (rawKey.includes(\"\\0\")) {\n throw new InvalidStorageKeyError(\"Storage key contains a null byte.\");\n }\n if (rawKey.length > MAX_STORAGE_KEY_LENGTH) {\n throw new InvalidStorageKeyError(\n `Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`\n );\n }\n\n // Split on both separators: `path.join` treats `\\` as a separator on\n // Windows, so `..\\` is traversal there even though POSIX reads it as a\n // filename. Checked against the RAW key, before any normalization — the\n // whole point is that `a/../b` is refused rather than quietly turned into\n // `b`.\n if (rawKey.split(/[\\\\/]/).some((segment) => segment === \"..\")) {\n throw new InvalidStorageKeyError(\n \"Storage key contains a '..' path segment. Keys must name an object directly.\"\n );\n }\n\n const withoutLeadingSlashes = rawKey.replace(/^\\/+/, \"\");\n if (withoutLeadingSlashes === \"\") return \"\";\n\n // Whether the key names a *directory* rather than an object: it ends with a\n // separator, or its last segment is the `.` that means \"this directory\".\n //\n // Recorded before normalizing, because normalization drops the\n // distinction in one of those two spellings and not the other: `public/.`\n // becomes `public` while `public/./` stays `public/`. That asymmetry breaks\n // the rule this module states — a trailing slash is preserved, because it is\n // how the folder route marks a prefix — and it is not cosmetic on `list`,\n // where a prefix of `public` also matches `publicity/` and hands back keys\n // the caller never asked for.\n const denotesDirectory = /(?:^|\\/)\\.?$/.test(withoutLeadingSlashes);\n\n // Safe now: with no `..` segment in the input, `normalize` can only collapse\n // `.` and duplicate slashes — it cannot climb.\n const normalized = normalizePosix(withoutLeadingSlashes);\n\n // `normalize(\".\")`, and `normalize(\"./\")` → `./`; neither names an object.\n if (normalized === \".\" || normalized === \"./\") return \"\";\n\n // Defensive: nothing reaching here still carries a leading `./`, since the\n // leading slashes are already gone and `normalize` only emits `./` for a\n // key that normalizes to nothing — which returned above.\n const key = normalized.replace(/^\\.\\//, \"\").replace(/^\\/+/, \"\");\n if (key === \"\") return \"\";\n return denotesDirectory && !key.endsWith(\"/\") ? `${key}/` : key;\n}\n\n/**\n * Canonicalize, or return `null` when the key is not canonicalizable.\n *\n * For callers that must fail closed without an exception — the download-token\n * middleware compares a request path against a granted path, and a key it\n * cannot canonicalize simply matches nothing.\n */\nexport function tryCanonicalStorageKey(rawKey: string): string | null {\n try {\n return canonicalStorageKey(rawKey);\n } catch {\n return null;\n }\n}\n\n/**\n * Canonicalize the storage *source* a request names (`?storageId=`), so that\n * \"the default source\" has exactly one spelling.\n *\n * The default source can be asked for three ways — the parameter omitted, sent\n * empty, or sent as the literal `(default)` — and all three resolve to the same\n * controller. Without a single spelling, the value derived when a download\n * token is minted and the value derived when it is presented can differ for the\n * same object, which is either a spurious 403 or, if the comparison is dropped\n * to stop those, no scoping at all.\n *\n * Deliberately *not* validated against the registry: this is a naming rule, not\n * an existence check, and it must give the same answer in `auth/` (which has no\n * registry) as in the storage routes. An id that names no source still\n * canonicalizes to itself and simply matches only itself.\n */\nexport function canonicalStorageId(rawStorageId: string | undefined | null): string {\n if (rawStorageId === undefined || rawStorageId === null) return DEFAULT_STORAGE_SOURCE_KEY;\n const trimmed = rawStorageId.trim();\n return trimmed === \"\" ? DEFAULT_STORAGE_SOURCE_KEY : trimmed;\n}\n\n/**\n * Longest bucket name accepted. 63 is the S3/GCS limit, and a local bucket is\n * one directory name, so the tighter of the two bounds everything.\n */\nexport const MAX_STORAGE_BUCKET_LENGTH = 63;\n\n/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */\nexport class InvalidStorageBucketError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageBucketError\";\n }\n}\n\n/**\n * One path segment: letters, digits, `.`, `_`, `-`, first character\n * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS\n * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,\n * absolute paths and anything containing a separator unrepresentable.\n */\nconst STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\n\n/**\n * Canonicalize a caller-supplied bucket name, or throw\n * {@link InvalidStorageBucketError}.\n *\n * The bucket is the *other* caller-controlled routing value in an upload\n * request, and it was the one nobody validated. `LocalStorageController`\n * builds `join(basePath, bucket)` and then checks containment against that\n * result, so a bucket of `../../etc` moved the boundary rather than crossing\n * it — the guard passed because the guard's reference point was the attacker's.\n * The containment check now resolves against the storage root, and this is the\n * check at the route boundary that stops the value before it gets there.\n *\n * A bucket is configuration, not user data: there is no legitimate caller that\n * needs a separator, a leading dot, or a `..` in one. So this refuses rather\n * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten\n * bucket would silently store the object somewhere the caller did not ask for.\n *\n * Returns `undefined` when the caller named no bucket (absent, or an empty\n * form field), which is how every controller spells \"use my default\". An empty\n * string used to reach `getFullPath` and resolve to the storage *root* rather\n * than the `default` bucket, which is a third place a bare key did not\n * round-trip.\n */\nexport function canonicalStorageBucket(rawBucket: string | undefined | null): string | undefined {\n if (rawBucket === undefined || rawBucket === null || rawBucket === \"\") return undefined;\n if (rawBucket.length > MAX_STORAGE_BUCKET_LENGTH) {\n throw new InvalidStorageBucketError(\n `Storage bucket exceeds the maximum length of ${MAX_STORAGE_BUCKET_LENGTH} characters.`\n );\n }\n if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) {\n throw new InvalidStorageBucketError(\n \"Storage bucket must be a single name of letters, digits, '.', '_' or '-', \" +\n \"starting with a letter or digit.\"\n );\n }\n return rawBucket;\n}\n\n/**\n * The prefix an object store's listing should be given for a caller's key, and\n * the key a listing should hand back for a folder.\n *\n * The three controllers answer the same question in two different ways, and\n * they used to disagree about the answer. `LocalStorageController` resolves a\n * *directory* on disk, so `listObjects(\"products/images\")` and\n * `listObjects(\"products/images/\")` both list what is inside it. An object\n * store under `Delimiter: \"/\"` does not: `Prefix: \"products/images\"` matches\n * `products/images.txt` and `productsomething/`, and returns none of the files\n * inside `products/images/`. So the same call — the SDK's own documented one —\n * answered one thing against local dev and another against the S3 bucket in\n * production, which is the worst place for a divergence to live.\n *\n * `listingPrefix` is what to *send*: a trailing slash, always, because that is\n * the only spelling that means \"inside this folder\" to a delimiter listing.\n * `folderKey` is what to *return* for a common prefix: no trailing slash, the\n * form the local controller has always used, so a `fullPath` from a listing is\n * a key you can pass straight back to any of them.\n */\nexport function listingPrefix(rawPrefix: string): string | undefined {\n const key = rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n return key === \"\" ? undefined : `${key}/`;\n}\n\n/** @see listingPrefix */\nexport function folderKey(rawPrefix: string): string {\n return rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n}\n\n/**\n * A listing asked for paging a controller cannot honour: a page size below one,\n * or a page token it never issued.\n *\n * Refused rather than coerced. A page size of 0 answered an empty page whose\n * next token was the one it had been handed, so `while (pageToken)` never\n * ended; a token of `-1` read before the first entry and crashed. The route\n * turns this into a 400.\n */\nexport class InvalidListOptionsError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidListOptionsError\";\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"keys-BXcdRNhJ.js","names":[],"sources":["../src/storage/keys.ts"],"sourcesContent":["/**\n * Canonical storage keys and bucket names.\n *\n * Storage is not under RLS, so a `storageAuthorize` hook is the whole access\n * control model — and a hook can only be correct if the key it is shown is the\n * key that is written. That is the invariant this module exists to hold: one\n * canonical string, computed once per request, handed to the hook, to the\n * controller, and to the download token alike.\n *\n * ## Why rejecting beats stripping\n *\n * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two\n * things were wrong with that, and only one of them was the obvious one.\n *\n * The obvious one: a single pass is not a fixed point. `....//` contains `../`\n * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured\n * the traversal it was there to remove. `users/alice/....//bob/x` came out as\n * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with\n * `users/alice/`) and the filesystem reads as bob's. The hook approved one\n * object and the controller wrote another.\n *\n * The subtler one, and the reason this is a rewrite rather than a loop: even a\n * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and\n * gets an object at `a/b` was not protected, they were misled — and every later\n * read, ownership row and audit line now refers to a path nobody chose. So a key\n * that means something other than what it says is refused (400), not repaired.\n *\n * Note what is NOT traversal under this rule: `....` is an ordinary directory\n * name, and `users/alice/....//bob/x` canonicalizes to\n * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is\n * exactly right. Only a real `..` segment is refused.\n */\n\nimport { DEFAULT_STORAGE_SOURCE_KEY } from \"@rebasepro/types\";\n\n/**\n * `path.posix.normalize`, without `node:path`.\n *\n * A storage key is a POSIX-shaped string that never touches a filesystem, so\n * reaching for `node:path` to fold `.` and `//` out of it was always a little\n * wrong on its own terms — on Windows the platform `path` would have applied\n * different rules to the same key. It also put the module that every storage\n * read, ownership row and audit line goes through on the list of things that\n * only run in a Node process.\n *\n * This is the algorithm Node implements, transcribed: segments are folded left\n * to right, `..` pops unless it would climb past the root of a relative path,\n * and both the leading and the trailing separator survive the round trip. The\n * `..` branch is unreachable from {@link canonicalStorageKey}, which refuses\n * those keys outright before it gets here — it exists so that this function is\n * *the* normalizer rather than a subset of one, and\n * `storage-keys.property.test.ts` holds it to that with a property test against\n * `path.posix.normalize` itself. Exported for exactly that — nothing outside\n * this module should be normalizing a key.\n */\nexport function normalizePosix(input: string): string {\n if (input.length === 0) return \".\";\n const isAbsolute = input.startsWith(\"/\");\n const trailingSeparator = input.endsWith(\"/\");\n\n const segments: string[] = [];\n for (const segment of input.split(\"/\")) {\n if (segment === \"\" || segment === \".\") continue;\n if (segment === \"..\") {\n if (segments.length > 0 && segments[segments.length - 1] !== \"..\") segments.pop();\n else if (!isAbsolute) segments.push(\"..\");\n continue;\n }\n segments.push(segment);\n }\n\n const joined = segments.join(\"/\");\n if (joined === \"\") {\n if (isAbsolute) return \"/\";\n return trailingSeparator ? \"./\" : \".\";\n }\n const withTrailing = trailingSeparator ? `${joined}/` : joined;\n return isAbsolute ? `/${withTrailing}` : withTrailing;\n}\n\n/** Longest key accepted, in UTF-16 code units. Matches the previous cap. */\nexport const MAX_STORAGE_KEY_LENGTH = 1024;\n\n/**\n * A key that cannot be canonicalized. Carries no path back to the caller\n * beyond what they sent, so it is safe to surface as a 400 message.\n */\nexport class InvalidStorageKeyError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageKeyError\";\n }\n}\n\n/**\n * Canonicalize a caller-supplied storage key, or throw\n * {@link InvalidStorageKeyError}.\n *\n * Normalizations applied (safe, idempotent, and meaning-preserving):\n * - leading slashes removed — `/a/b` and `a/b` name the same object\n * - `.` segments and repeated slashes collapsed\n *\n * Refusals (the key means something other than what it says):\n * - any `..` segment, on either separator, at any depth\n * - null bytes\n * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}\n *\n * A trailing slash is preserved: it is how the folder route marks a prefix.\n */\nexport function canonicalStorageKey(rawKey: string): string {\n if (rawKey.includes(\"\\0\")) {\n throw new InvalidStorageKeyError(\"Storage key contains a null byte.\");\n }\n if (rawKey.length > MAX_STORAGE_KEY_LENGTH) {\n throw new InvalidStorageKeyError(\n `Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`\n );\n }\n\n // Split on both separators: `path.join` treats `\\` as a separator on\n // Windows, so `..\\` is traversal there even though POSIX reads it as a\n // filename. Checked against the RAW key, before any normalization — the\n // whole point is that `a/../b` is refused rather than quietly turned into\n // `b`.\n if (rawKey.split(/[\\\\/]/).some((segment) => segment === \"..\")) {\n throw new InvalidStorageKeyError(\n \"Storage key contains a '..' path segment. Keys must name an object directly.\"\n );\n }\n\n const withoutLeadingSlashes = rawKey.replace(/^\\/+/, \"\");\n if (withoutLeadingSlashes === \"\") return \"\";\n\n // Whether the key names a *directory* rather than an object: it ends with a\n // separator, or its last segment is the `.` that means \"this directory\".\n //\n // Recorded before normalizing, because normalization drops the\n // distinction in one of those two spellings and not the other: `public/.`\n // becomes `public` while `public/./` stays `public/`. That asymmetry breaks\n // the rule this module states — a trailing slash is preserved, because it is\n // how the folder route marks a prefix — and it is not cosmetic on `list`,\n // where a prefix of `public` also matches `publicity/` and hands back keys\n // the caller never asked for.\n const denotesDirectory = /(?:^|\\/)\\.?$/.test(withoutLeadingSlashes);\n\n // Safe now: with no `..` segment in the input, `normalize` can only collapse\n // `.` and duplicate slashes — it cannot climb.\n const normalized = normalizePosix(withoutLeadingSlashes);\n\n // `normalize(\".\")`, and `normalize(\"./\")` → `./`; neither names an object.\n if (normalized === \".\" || normalized === \"./\") return \"\";\n\n // Defensive: nothing reaching here still carries a leading `./`, since the\n // leading slashes are already gone and `normalize` only emits `./` for a\n // key that normalizes to nothing — which returned above.\n const key = normalized.replace(/^\\.\\//, \"\").replace(/^\\/+/, \"\");\n if (key === \"\") return \"\";\n return denotesDirectory && !key.endsWith(\"/\") ? `${key}/` : key;\n}\n\n/**\n * Canonicalize, or return `null` when the key is not canonicalizable.\n *\n * For callers that must fail closed without an exception — the download-token\n * middleware compares a request path against a granted path, and a key it\n * cannot canonicalize simply matches nothing.\n */\nexport function tryCanonicalStorageKey(rawKey: string): string | null {\n try {\n return canonicalStorageKey(rawKey);\n } catch {\n return null;\n }\n}\n\n/**\n * Canonicalize the storage *source* a request names (`?storageId=`), so that\n * \"the default source\" has exactly one spelling.\n *\n * The default source can be asked for three ways — the parameter omitted, sent\n * empty, or sent as the literal `(default)` — and all three resolve to the same\n * controller. Without a single spelling, the value derived when a download\n * token is minted and the value derived when it is presented can differ for the\n * same object, which is either a spurious 403 or, if the comparison is dropped\n * to stop those, no scoping at all.\n *\n * Deliberately *not* validated against the registry: this is a naming rule, not\n * an existence check, and it must give the same answer in `auth/` (which has no\n * registry) as in the storage routes. An id that names no source still\n * canonicalizes to itself and simply matches only itself.\n */\nexport function canonicalStorageId(rawStorageId: string | undefined | null): string {\n if (rawStorageId === undefined || rawStorageId === null) return DEFAULT_STORAGE_SOURCE_KEY;\n const trimmed = rawStorageId.trim();\n return trimmed === \"\" ? DEFAULT_STORAGE_SOURCE_KEY : trimmed;\n}\n\n/**\n * Longest bucket name accepted. 63 is the S3/GCS limit, and a local bucket is\n * one directory name, so the tighter of the two bounds everything.\n */\nexport const MAX_STORAGE_BUCKET_LENGTH = 63;\n\n/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */\nexport class InvalidStorageBucketError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidStorageBucketError\";\n }\n}\n\n/**\n * One path segment: letters, digits, `.`, `_`, `-`, first character\n * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS\n * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,\n * absolute paths and anything containing a separator unrepresentable.\n */\nconst STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;\n\n/**\n * Canonicalize a caller-supplied bucket name, or throw\n * {@link InvalidStorageBucketError}.\n *\n * The bucket is the *other* caller-controlled routing value in an upload\n * request, and it was the one nobody validated. `LocalStorageController`\n * builds `join(basePath, bucket)` and then checks containment against that\n * result, so a bucket of `../../etc` moved the boundary rather than crossing\n * it — the guard passed because the guard's reference point was the attacker's.\n * The containment check now resolves against the storage root, and this is the\n * check at the route boundary that stops the value before it gets there.\n *\n * A bucket is configuration, not user data: there is no legitimate caller that\n * needs a separator, a leading dot, or a `..` in one. So this refuses rather\n * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten\n * bucket would silently store the object somewhere the caller did not ask for.\n *\n * Returns `undefined` when the caller named no bucket (absent, or an empty\n * form field), which is how every controller spells \"use my default\". An empty\n * string used to reach `getFullPath` and resolve to the storage *root* rather\n * than the `default` bucket, which is a third place a bare key did not\n * round-trip.\n */\nexport function canonicalStorageBucket(rawBucket: string | undefined | null): string | undefined {\n if (rawBucket === undefined || rawBucket === null || rawBucket === \"\") return undefined;\n if (rawBucket.length > MAX_STORAGE_BUCKET_LENGTH) {\n throw new InvalidStorageBucketError(\n `Storage bucket exceeds the maximum length of ${MAX_STORAGE_BUCKET_LENGTH} characters.`\n );\n }\n if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) {\n throw new InvalidStorageBucketError(\n \"Storage bucket must be a single name of letters, digits, '.', '_' or '-', \" +\n \"starting with a letter or digit.\"\n );\n }\n return rawBucket;\n}\n\n/**\n * The prefix an object store's listing should be given for a caller's key, and\n * the key a listing should hand back for a folder.\n *\n * The three controllers answer the same question in two different ways, and\n * they used to disagree about the answer. `LocalStorageController` resolves a\n * *directory* on disk, so `listObjects(\"products/images\")` and\n * `listObjects(\"products/images/\")` both list what is inside it. An object\n * store under `Delimiter: \"/\"` does not: `Prefix: \"products/images\"` matches\n * `products/images.txt` and `productsomething/`, and returns none of the files\n * inside `products/images/`. So the same call — the SDK's own documented one —\n * answered one thing against local dev and another against the S3 bucket in\n * production, which is the worst place for a divergence to live.\n *\n * `listingPrefix` is what to *send*: a trailing slash, always, because that is\n * the only spelling that means \"inside this folder\" to a delimiter listing.\n * `folderKey` is what to *return* for a common prefix: no trailing slash, the\n * form the local controller has always used, so a `fullPath` from a listing is\n * a key you can pass straight back to any of them.\n */\nexport function listingPrefix(rawPrefix: string): string | undefined {\n const key = rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n return key === \"\" ? undefined : `${key}/`;\n}\n\n/** @see listingPrefix */\nexport function folderKey(rawPrefix: string): string {\n return rawPrefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\");\n}\n\n/**\n * A listing asked for paging a controller cannot honour: a page size below one,\n * or a page token it never issued.\n *\n * Refused rather than coerced. A page size of 0 answered an empty page whose\n * next token was the one it had been handed, so `while (pageToken)` never\n * ended; a token of `-1` read before the first entry and crashed. The route\n * turns this into a 400.\n */\nexport class InvalidListOptionsError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"InvalidListOptionsError\";\n }\n}\n"],"mappings":"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"}
|
|
@@ -28,7 +28,14 @@
|
|
|
28
28
|
* 5. **Never post the password anywhere but the existing login endpoint.**
|
|
29
29
|
* The form below sends credentials to `${basePath}/auth/login` and nowhere
|
|
30
30
|
* else; this file's own endpoint receives only the resulting session token.
|
|
31
|
-
* 6. **
|
|
31
|
+
* 6. **Say where the code goes.** Registration is open by default and the
|
|
32
|
+
* client name is the registrant's to choose, so "“Claude” wants access"
|
|
33
|
+
* can be anyone. The redirect host cannot, and the page names it.
|
|
34
|
+
* 7. **Let every account that can sign in consent.** An account with a second
|
|
35
|
+
* factor finishes here through the existing MFA routes; an account that
|
|
36
|
+
* has only ever used Google, GitHub or an email link is told how to get a
|
|
37
|
+
* password, rather than shown an error it cannot act on.
|
|
38
|
+
* 8. **Refuse to be framed.** See {@link consentPageHeaders} — a consent screen
|
|
32
39
|
* that can be iframed is a consent screen an attacker can have clicked.
|
|
33
40
|
*/
|
|
34
41
|
export interface ConsentPageParams {
|
|
@@ -42,6 +49,24 @@ export interface ConsentPageParams {
|
|
|
42
49
|
requestToken: string;
|
|
43
50
|
/** The existing auth endpoint the sign-in form posts to. */
|
|
44
51
|
loginUrl: string;
|
|
52
|
+
/**
|
|
53
|
+
* The existing MFA routes (`POST /auth/mfa/challenge` and
|
|
54
|
+
* `/auth/mfa/challenge/verify`). An account with a verified factor answers
|
|
55
|
+
* the login with `MFA_REQUIRED` and a pending token; the page opens a
|
|
56
|
+
* challenge with it and posts the code, which is where that account's
|
|
57
|
+
* session is minted. Without this step such an account could never consent.
|
|
58
|
+
*/
|
|
59
|
+
mfaChallengeUrl: string;
|
|
60
|
+
mfaVerifyUrl: string;
|
|
61
|
+
/**
|
|
62
|
+
* Where the authorization code goes once the person allows.
|
|
63
|
+
*
|
|
64
|
+
* The client's name is free text anyone may register, so it proves nothing;
|
|
65
|
+
* the redirect URI is the one fact on this screen an attacker cannot make
|
|
66
|
+
* look like someone else's without owning that host. It is shown, as a
|
|
67
|
+
* host, with a plain statement that this server has not verified it.
|
|
68
|
+
*/
|
|
69
|
+
redirectUri: string;
|
|
45
70
|
/** Where the decision is posted. */
|
|
46
71
|
decisionUrl: string;
|
|
47
72
|
/** The canonical resource, shown so the user can see which person this is. */
|
|
@@ -73,3 +98,15 @@ export interface ConsentPageParams {
|
|
|
73
98
|
*/
|
|
74
99
|
export declare function consentPageHeaders(nonce: string): Record<string, string>;
|
|
75
100
|
export declare function renderConsentPage(params: ConsentPageParams): string;
|
|
101
|
+
/**
|
|
102
|
+
* What to call the redirect target on the consent screen.
|
|
103
|
+
*
|
|
104
|
+
* The host, not the whole URI: a path is the registrant's to dress up
|
|
105
|
+
* ("/claude/official/callback"), the host is what they must own. A loopback
|
|
106
|
+
* redirect is a native client on this person's own machine; a private-use
|
|
107
|
+
* scheme (`com.example.app:/cb`) has no host, so it is shown whole.
|
|
108
|
+
*/
|
|
109
|
+
export declare function describeRedirect(redirectUri: string): {
|
|
110
|
+
label: string;
|
|
111
|
+
note: string;
|
|
112
|
+
};
|
package/dist/mcp/mcp-routes.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { HonoEnv } from "../api/types.js";
|
|
|
26
26
|
import { type JwtService } from "../auth/jwt.js";
|
|
27
27
|
import { type DataRateLimitConfig } from "../auth/rate-limiter.js";
|
|
28
28
|
import type { ApiKeyIdentity, ApiKeyRefusal } from "../auth/api-keys/api-key-middleware.js";
|
|
29
|
+
import type { McpGrantIdentity } from "./oauth-routes.js";
|
|
29
30
|
/**
|
|
30
31
|
* The most messages one JSON-RPC batch may carry.
|
|
31
32
|
*
|
|
@@ -79,6 +80,14 @@ export interface McpRoutesConfig {
|
|
|
79
80
|
* rate limiting off. See {@link createMcpRoutes}.
|
|
80
81
|
*/
|
|
81
82
|
rateLimit?: DataRateLimitConfig;
|
|
83
|
+
/**
|
|
84
|
+
* Who an access token's account is now — the identity the OAuth routes
|
|
85
|
+
* re-read at consent and refresh. Asked on every request, so a deleted,
|
|
86
|
+
* disabled or signed-out-everywhere account stops at once and a demotion
|
|
87
|
+
* takes effect on the next call. Absent when the auth adapter exposes no
|
|
88
|
+
* user repository, and then a token carries the roles it was minted with.
|
|
89
|
+
*/
|
|
90
|
+
identity?: McpGrantIdentity;
|
|
82
91
|
}
|
|
83
92
|
/**
|
|
84
93
|
* The `.well-known` documents.
|
package/dist/mcp/mcp-tools.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* is a second lock, not the main one: a `data:write` token still cannot write
|
|
22
22
|
* a row the user could not write themselves.
|
|
23
23
|
*/
|
|
24
|
-
import type { AuthAdapter, CollectionConfig, DataDriver } from "@rebasepro/types";
|
|
24
|
+
import type { AuthAdapter, CallerCredential, CollectionConfig, DataDriver } from "@rebasepro/types";
|
|
25
25
|
import type { McpScope } from "./oauth-metadata.js";
|
|
26
26
|
/** The identity a tool call runs as. Comes from the verified access token. */
|
|
27
27
|
export interface McpCaller {
|
|
@@ -30,6 +30,12 @@ export interface McpCaller {
|
|
|
30
30
|
/** The scopes granted to this connection — `data:read`, `data:write:posts`, … */
|
|
31
31
|
scopes: string[];
|
|
32
32
|
clientId: string;
|
|
33
|
+
/**
|
|
34
|
+
* The API key the connection authenticated with, when it was one rather
|
|
35
|
+
* than an OAuth grant. Handed to collection callbacks as
|
|
36
|
+
* `context.credential`.
|
|
37
|
+
*/
|
|
38
|
+
credential?: CallerCredential;
|
|
33
39
|
}
|
|
34
40
|
export interface McpToolContext {
|
|
35
41
|
driver: DataDriver;
|
|
@@ -146,10 +146,10 @@ export interface OAuthStore {
|
|
|
146
146
|
* there was nothing to revoke, which lets the route answer 404 rather than
|
|
147
147
|
* reporting success for a client the user never connected.
|
|
148
148
|
*
|
|
149
|
-
* Access tokens already issued are NOT invalidated —
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
149
|
+
* Access tokens already issued are NOT invalidated — `/mcp` re-checks the
|
|
150
|
+
* account on each request, not the grant. The window is therefore one
|
|
151
|
+
* access-token lifetime (an hour), and the route's response says so rather
|
|
152
|
+
* than implying an instant cut-off.
|
|
153
153
|
*/
|
|
154
154
|
revokeGrant(uid: string, clientId: string): Promise<boolean>;
|
|
155
155
|
}
|
|
@@ -170,4 +170,11 @@ export interface GrantSummary {
|
|
|
170
170
|
* server that cannot persist a code is not a degraded authorization server, it
|
|
171
171
|
* is one that hands out credentials it cannot check.
|
|
172
172
|
*/
|
|
173
|
-
export declare function createOAuthStore(driver: DataDriver
|
|
173
|
+
export declare function createOAuthStore(driver: DataDriver, options?: {
|
|
174
|
+
/**
|
|
175
|
+
* Default `true`. `false` is a backend that changes nothing in its
|
|
176
|
+
* database: `ensureTables` creates and revokes nothing, and throws
|
|
177
|
+
* when the tables are not already there.
|
|
178
|
+
*/
|
|
179
|
+
provision?: boolean;
|
|
180
|
+
}): OAuthStore | null;
|
|
@@ -15,4 +15,7 @@ export interface MetricsHistory {
|
|
|
15
15
|
* chart that renders empty is indistinguishable from a quiet period and this is
|
|
16
16
|
* exactly the class of silence the panel it feeds exists to remove.
|
|
17
17
|
*/
|
|
18
|
-
export declare function createMetricsHistory(driver: DataDriver
|
|
18
|
+
export declare function createMetricsHistory(driver: DataDriver, options?: {
|
|
19
|
+
/** Default `true`. `false` uses the table if it is there and never creates it. */
|
|
20
|
+
provision?: boolean;
|
|
21
|
+
}): MetricsHistory | undefined;
|
|
@@ -59,6 +59,14 @@ export interface MetricSample {
|
|
|
59
59
|
* nobody is mid-request.
|
|
60
60
|
*/
|
|
61
61
|
export declare function ensureMetricsHistory(exec: Exec): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* {@link ensureMetricsHistory} for a backend that changes nothing in its
|
|
64
|
+
* database: the table is read, never made, and its absence is an error — which
|
|
65
|
+
* the caller turns into "no history", the same outcome as a create that failed.
|
|
66
|
+
* The sweep still runs: it deletes rows, and keeping the table bounded is not a
|
|
67
|
+
* schema change.
|
|
68
|
+
*/
|
|
69
|
+
export declare function adoptExistingMetricsHistory(exec: Exec): Promise<void>;
|
|
62
70
|
/**
|
|
63
71
|
* What this process is using right now.
|
|
64
72
|
*
|
|
@@ -2,11 +2,12 @@ import { createRequire as __rebaseCreateRequire } from "module";
|
|
|
2
2
|
import __rebaseProcess from "process";
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
import {
|
|
6
|
-
import { D as Vector, E as GeoPoint, S as hasFieldOperation, T as EntityRelation, _ as normalizeOrderBy, c as collectAllPages, l as paginateFind, u as resolveFindWindow, w as EntityReference } from "./filter-dialect-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
5
|
+
import { a as RebaseClientError, i as RebaseApiError, o as isUnsupported } from "./data_driver-D0mg1Y_x.js";
|
|
6
|
+
import { D as Vector, E as GeoPoint, S as hasFieldOperation, T as EntityRelation, _ as normalizeOrderBy, c as collectAllPages, l as paginateFind, u as resolveFindWindow, w as EntityReference } from "./filter-dialect-TrKFWLqT.js";
|
|
7
|
+
import { et as parseRelationAggregateSort, nt as toCanonicalOp } from "./src-Bh9LRPb9.js";
|
|
8
|
+
import { t as splitSearchTerms } from "./strings.es-B7QLnHh2.js";
|
|
9
|
+
import { a as byIdRead, f as rebaseReviver, h as unref, i as buildQueryString, m as withETag, n as MemoryOfflineStore, p as etagOf, r as SDKQueryBuilder, s as createMutationId, t as IndexedDBOfflineStore, u as invalidBulkBody } from "./offline-store-BhYXViha-YTmjBpo2.js";
|
|
10
|
+
//#region ../client/dist/offline-AvuEcg7u.js
|
|
10
11
|
/**
|
|
11
12
|
* Lossless round-tripping of rows through the offline store.
|
|
12
13
|
*
|
|
@@ -901,6 +902,13 @@ function isPlainObject(value) {
|
|
|
901
902
|
const proto = Object.getPrototypeOf(value);
|
|
902
903
|
return proto === null || Object.getPrototypeOf(proto) === null;
|
|
903
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* The key a folded run is sent under: its first and last member and its
|
|
907
|
+
* length, which fixes its composition.
|
|
908
|
+
*/
|
|
909
|
+
function runKey(run) {
|
|
910
|
+
return `${run[0].mutationId}..${run[run.length - 1].mutationId}:${run.length}`;
|
|
911
|
+
}
|
|
904
912
|
/** Whether a queued write's payload mentions `id` anywhere — see {@link replaceMintedId}. */
|
|
905
913
|
function mentionsMintedId(op, id) {
|
|
906
914
|
return [op.data, ...(op.updates ?? []).map((u) => u.data)].some((payload) => replaceMintedId(payload, id, "").changed);
|
|
@@ -1324,19 +1332,45 @@ var OfflineManager = class {
|
|
|
1324
1332
|
*/
|
|
1325
1333
|
ticket() {
|
|
1326
1334
|
if (this.scopeHold) return void 0;
|
|
1327
|
-
return
|
|
1328
|
-
scope: this.scope,
|
|
1329
|
-
epoch: this.scopeEpoch
|
|
1330
|
-
};
|
|
1335
|
+
return this.currentTicket();
|
|
1331
1336
|
}
|
|
1332
1337
|
/** The ticket of whoever the held scope turns out to be. */
|
|
1333
1338
|
async nextTicket() {
|
|
1334
1339
|
await this.scopeKnown();
|
|
1335
|
-
return
|
|
1340
|
+
return this.currentTicket();
|
|
1341
|
+
}
|
|
1342
|
+
currentTicket() {
|
|
1343
|
+
return this.unverified ? {
|
|
1344
|
+
scope: this.scope,
|
|
1345
|
+
epoch: this.scopeEpoch,
|
|
1346
|
+
unverified: true
|
|
1347
|
+
} : {
|
|
1336
1348
|
scope: this.scope,
|
|
1337
1349
|
epoch: this.scopeEpoch
|
|
1338
1350
|
};
|
|
1339
1351
|
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Whether a write may go to the server now. Not while the scope is the last
|
|
1354
|
+
* user's, unconfirmed: it would go out with no credentials, as nobody. It
|
|
1355
|
+
* queues for its author instead, as it does offline.
|
|
1356
|
+
*/
|
|
1357
|
+
sendsWrites() {
|
|
1358
|
+
return this.connectivity.shouldAttempt() && !this.unverified;
|
|
1359
|
+
}
|
|
1360
|
+
reachedUnconfirmedListeners = /* @__PURE__ */ new Set();
|
|
1361
|
+
/**
|
|
1362
|
+
* `listener` runs when a request made while the scope is unconfirmed
|
|
1363
|
+
* reached the server: the connection is back, so the session can be asked
|
|
1364
|
+
* about. The client wires it to the same confirmation the status listener
|
|
1365
|
+
* runs — the status may never have said the connection was gone.
|
|
1366
|
+
*/
|
|
1367
|
+
onReachedUnconfirmed(listener) {
|
|
1368
|
+
this.reachedUnconfirmedListeners.add(listener);
|
|
1369
|
+
return () => this.reachedUnconfirmedListeners.delete(listener);
|
|
1370
|
+
}
|
|
1371
|
+
reachedUnconfirmed() {
|
|
1372
|
+
for (const listener of this.reachedUnconfirmedListeners) callSafely(listener);
|
|
1373
|
+
}
|
|
1340
1374
|
/** Whether an operation's user is still the signed-in one. */
|
|
1341
1375
|
isCurrent(ticket) {
|
|
1342
1376
|
return ticket.epoch === this.scopeEpoch;
|
|
@@ -1393,15 +1427,18 @@ var OfflineManager = class {
|
|
|
1393
1427
|
const res = await inner.find(params);
|
|
1394
1428
|
this.connectivity.markSuccess();
|
|
1395
1429
|
if (!this.isCurrent(ticket)) return res;
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1430
|
+
if (ticket.unverified) this.reachedUnconfirmed();
|
|
1431
|
+
else {
|
|
1432
|
+
await this.ingest(slug, res.data ?? [], ticket, isProjection(params));
|
|
1433
|
+
if (!this.isCurrent(ticket)) return res;
|
|
1434
|
+
const snapshot = this.recordSnapshot(slug, params, res, ticket);
|
|
1435
|
+
const answer = this.answer(slug, params, snapshot);
|
|
1436
|
+
this.notifyCollection(slug, false);
|
|
1437
|
+
return {
|
|
1438
|
+
data: answer.data,
|
|
1439
|
+
meta: answer.meta
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1405
1442
|
} catch (error) {
|
|
1406
1443
|
if (!isNetworkError(error)) {
|
|
1407
1444
|
if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params) && !(walk && !this.snapshotFor(slug, params))) {
|
|
@@ -1435,10 +1472,16 @@ var OfflineManager = class {
|
|
|
1435
1472
|
const row = await inner.findById(id, options);
|
|
1436
1473
|
this.connectivity.markSuccess();
|
|
1437
1474
|
if (!this.isCurrent(ticket)) return row;
|
|
1475
|
+
if (ticket.unverified) {
|
|
1476
|
+
this.reachedUnconfirmed();
|
|
1477
|
+
return this.localRow(slug, id);
|
|
1478
|
+
}
|
|
1438
1479
|
if (row !== void 0) await this.ingest(slug, [row], ticket);
|
|
1439
1480
|
else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
1440
1481
|
this.notifyCollection(slug, false);
|
|
1441
|
-
|
|
1482
|
+
const local = this.localRow(slug, id);
|
|
1483
|
+
const etag = etagOf(row);
|
|
1484
|
+
return etag && local !== void 0 && !this.hasPending(slug, id) ? withETag({ ...local }, etag) : local;
|
|
1442
1485
|
} catch (error) {
|
|
1443
1486
|
if (!isNetworkError(error)) throw error;
|
|
1444
1487
|
this.connectivity.markFailure();
|
|
@@ -1461,7 +1504,7 @@ var OfflineManager = class {
|
|
|
1461
1504
|
await this.ensureCollection(slug);
|
|
1462
1505
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
1463
1506
|
let sent = false;
|
|
1464
|
-
if (this.
|
|
1507
|
+
if (this.sendsWrites()) try {
|
|
1465
1508
|
const row = await inner.create(data, id, {
|
|
1466
1509
|
...options,
|
|
1467
1510
|
idempotencyKey
|
|
@@ -1503,7 +1546,7 @@ var OfflineManager = class {
|
|
|
1503
1546
|
if (data.length === 0) return [];
|
|
1504
1547
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
1505
1548
|
let sent = false;
|
|
1506
|
-
if (this.
|
|
1549
|
+
if (this.sendsWrites()) try {
|
|
1507
1550
|
const rows = await inner.createMany(data, {
|
|
1508
1551
|
...options,
|
|
1509
1552
|
idempotencyKey
|
|
@@ -1555,7 +1598,7 @@ var OfflineManager = class {
|
|
|
1555
1598
|
upsert: async (data, options) => {
|
|
1556
1599
|
const ticket = this.ticket() ?? await this.nextTicket();
|
|
1557
1600
|
await this.ensureCollection(slug);
|
|
1558
|
-
if (this.
|
|
1601
|
+
if (this.sendsWrites()) try {
|
|
1559
1602
|
const row = await inner.upsert(data, options);
|
|
1560
1603
|
this.connectivity.markSuccess();
|
|
1561
1604
|
await this.ingest(slug, [row], ticket);
|
|
@@ -1586,7 +1629,7 @@ var OfflineManager = class {
|
|
|
1586
1629
|
if (!Array.isArray(updates)) throw invalidBulkBody("updateMany expects an array of { id, data } entries.");
|
|
1587
1630
|
if (updates.length === 0) return [];
|
|
1588
1631
|
const anyPending = updates.some((u) => this.hasPending(slug, u.id));
|
|
1589
|
-
if (this.
|
|
1632
|
+
if (this.sendsWrites() && !anyPending) try {
|
|
1590
1633
|
const rows = await inner.updateMany(updates, options);
|
|
1591
1634
|
this.connectivity.markSuccess();
|
|
1592
1635
|
await this.ingest(slug, rows, ticket);
|
|
@@ -1625,7 +1668,7 @@ var OfflineManager = class {
|
|
|
1625
1668
|
if (!Array.isArray(ids)) throw invalidBulkBody("deleteMany expects an array of ids.");
|
|
1626
1669
|
if (ids.length === 0) return;
|
|
1627
1670
|
const anyPending = ids.some((id) => this.hasPending(slug, id));
|
|
1628
|
-
if (this.
|
|
1671
|
+
if (this.sendsWrites() && !anyPending) try {
|
|
1629
1672
|
await inner.deleteMany(ids, options);
|
|
1630
1673
|
this.connectivity.markSuccess();
|
|
1631
1674
|
for (const id of ids) this.removeLocalRow(slug, id, ticket, true);
|
|
@@ -1653,7 +1696,7 @@ var OfflineManager = class {
|
|
|
1653
1696
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
1654
1697
|
let sent = false;
|
|
1655
1698
|
let sendFailure;
|
|
1656
|
-
if (this.
|
|
1699
|
+
if (this.sendsWrites() && !this.hasPending(slug, id)) try {
|
|
1657
1700
|
const row = await inner.update(id, data, {
|
|
1658
1701
|
...options,
|
|
1659
1702
|
idempotencyKey
|
|
@@ -1678,7 +1721,7 @@ var OfflineManager = class {
|
|
|
1678
1721
|
throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
|
|
1679
1722
|
}
|
|
1680
1723
|
const base = this.rawLocalRow(slug, id);
|
|
1681
|
-
const ifMatch =
|
|
1724
|
+
const ifMatch = options?.ifMatch ?? (this.onConflict && !this.hasPending(slug, id) ? this.etagFor(slug, id) : void 0);
|
|
1682
1725
|
await this.enqueue({
|
|
1683
1726
|
collection: slug,
|
|
1684
1727
|
type: "update",
|
|
@@ -1701,7 +1744,7 @@ var OfflineManager = class {
|
|
|
1701
1744
|
await this.ensureCollection(slug);
|
|
1702
1745
|
const idempotencyKey = options?.idempotencyKey ?? createMutationId();
|
|
1703
1746
|
let sent = false;
|
|
1704
|
-
if (this.
|
|
1747
|
+
if (this.sendsWrites() && !this.hasPending(slug, id)) try {
|
|
1705
1748
|
await inner.delete(id, {
|
|
1706
1749
|
...options,
|
|
1707
1750
|
idempotencyKey
|
|
@@ -1721,6 +1764,7 @@ var OfflineManager = class {
|
|
|
1721
1764
|
type: "delete",
|
|
1722
1765
|
id,
|
|
1723
1766
|
...sent ? { sent: { idempotencyKey } } : {},
|
|
1767
|
+
...options?.ifMatch ? { ifMatch: options.ifMatch } : {},
|
|
1724
1768
|
rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
|
|
1725
1769
|
}, ticket);
|
|
1726
1770
|
this.removeLocalRow(slug, id, ticket);
|
|
@@ -1733,6 +1777,12 @@ var OfflineManager = class {
|
|
|
1733
1777
|
const n = await inner.count(params);
|
|
1734
1778
|
this.connectivity.markSuccess();
|
|
1735
1779
|
if (!this.isCurrent(ticket)) return n;
|
|
1780
|
+
if (ticket.unverified) {
|
|
1781
|
+
this.reachedUnconfirmed();
|
|
1782
|
+
const cached = await this.readCache(this.countKey(slug, params));
|
|
1783
|
+
if (cached !== void 0) return Math.max(0, cached + this.pendingDelta(slug, params));
|
|
1784
|
+
throw offlineError(`No count of "${slug}" for the last user is cached, and their session is not confirmed yet.`);
|
|
1785
|
+
}
|
|
1736
1786
|
this.writeCache(this.countKey(slug, params), n);
|
|
1737
1787
|
return Math.max(0, n + this.pendingDelta(slug, params));
|
|
1738
1788
|
} catch (error) {
|
|
@@ -1771,6 +1821,15 @@ var OfflineManager = class {
|
|
|
1771
1821
|
onUpdate(response);
|
|
1772
1822
|
return;
|
|
1773
1823
|
}
|
|
1824
|
+
if (ticket.unverified) {
|
|
1825
|
+
const answer = this.localFind(slug, params);
|
|
1826
|
+
onUpdate({
|
|
1827
|
+
...response,
|
|
1828
|
+
data: answer.data,
|
|
1829
|
+
meta: answer.meta
|
|
1830
|
+
});
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1774
1833
|
this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
|
|
1775
1834
|
if (!this.isCurrent(ticket)) return;
|
|
1776
1835
|
const snapshot = this.recordSnapshot(slug, params, response, ticket);
|
|
@@ -1789,6 +1848,10 @@ var OfflineManager = class {
|
|
|
1789
1848
|
onUpdate(row);
|
|
1790
1849
|
return;
|
|
1791
1850
|
}
|
|
1851
|
+
if (ticket.unverified) {
|
|
1852
|
+
onUpdate(this.localRow(slug, id));
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1792
1855
|
if (!row) {
|
|
1793
1856
|
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
1794
1857
|
this.notifyCollection(slug, false);
|
|
@@ -1820,7 +1883,7 @@ var OfflineManager = class {
|
|
|
1820
1883
|
wrapTrash(slug, inner) {
|
|
1821
1884
|
const online = async (what, call) => {
|
|
1822
1885
|
const message = `Offline: ${what} on the trash of "${slug}" needs the server; the trash is never cached on this device.`;
|
|
1823
|
-
if (!this.
|
|
1886
|
+
if (!this.sendsWrites()) throw offlineError(message);
|
|
1824
1887
|
try {
|
|
1825
1888
|
const result = await call();
|
|
1826
1889
|
this.connectivity.markSuccess();
|
|
@@ -1934,7 +1997,7 @@ var OfflineManager = class {
|
|
|
1934
1997
|
})();
|
|
1935
1998
|
if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
|
|
1936
1999
|
const ticket = this.ticket();
|
|
1937
|
-
if (!ticket) return;
|
|
2000
|
+
if (!ticket || ticket.unverified) return;
|
|
1938
2001
|
this.ingest(slug, response.data ?? [], ticket, isProjection(params)).then(() => {
|
|
1939
2002
|
this.recordSnapshot(slug, params, response, ticket);
|
|
1940
2003
|
this.notifyCollection(slug, false);
|
|
@@ -1989,7 +2052,7 @@ var OfflineManager = class {
|
|
|
1989
2052
|
})();
|
|
1990
2053
|
if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
|
|
1991
2054
|
const ticket = this.ticket();
|
|
1992
|
-
if (!ticket) return;
|
|
2055
|
+
if (!ticket || ticket.unverified) return;
|
|
1993
2056
|
if (!row) {
|
|
1994
2057
|
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, ticket, true);
|
|
1995
2058
|
this.notifyCollection(slug, false);
|
|
@@ -2314,6 +2377,7 @@ var OfflineManager = class {
|
|
|
2314
2377
|
*/
|
|
2315
2378
|
removeLocalRow(slug, id, ticket, known = false) {
|
|
2316
2379
|
if (!this.isCurrent(ticket)) return;
|
|
2380
|
+
if (known && ticket.unverified) return;
|
|
2317
2381
|
const state = this.collectionState(slug);
|
|
2318
2382
|
const key = String(id);
|
|
2319
2383
|
const existed = state.rows.delete(key);
|
|
@@ -2346,7 +2410,7 @@ var OfflineManager = class {
|
|
|
2346
2410
|
* dropdown elsewhere asked for titles.
|
|
2347
2411
|
*/
|
|
2348
2412
|
async ingest(slug, rows, ticket, projection = false) {
|
|
2349
|
-
if (rows.length === 0 || !this.isCurrent(ticket)) return;
|
|
2413
|
+
if (rows.length === 0 || !this.isCurrent(ticket) || ticket.unverified) return;
|
|
2350
2414
|
const state = await this.ensureCollection(slug);
|
|
2351
2415
|
if (!this.isCurrent(ticket)) return;
|
|
2352
2416
|
const cachedAt = Date.now();
|
|
@@ -2450,7 +2514,7 @@ var OfflineManager = class {
|
|
|
2450
2514
|
hasMore: meta.hasMore ?? false,
|
|
2451
2515
|
...isProjection(params) ? { rows: (result.data ?? []).map((row) => ({ ...row })) } : {}
|
|
2452
2516
|
};
|
|
2453
|
-
if (!this.isCurrent(ticket)) return snapshot;
|
|
2517
|
+
if (!this.isCurrent(ticket) || ticket.unverified) return snapshot;
|
|
2454
2518
|
const state = this.collectionState(slug);
|
|
2455
2519
|
const key = buildQueryString(params);
|
|
2456
2520
|
state.snapshots.set(key, snapshot);
|
|
@@ -2548,7 +2612,7 @@ var OfflineManager = class {
|
|
|
2548
2612
|
}
|
|
2549
2613
|
if (mutation.type === "update") {
|
|
2550
2614
|
const tail = this.queue[this.queue.length - 1];
|
|
2551
|
-
if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
|
|
2615
|
+
if (tail && !this.inFlight.has(tail.mutationId) && tail.sent === void 0 && (tail.schemaVersion ?? 1) <= 1 && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id && (mutation.ifMatch === void 0 || mutation.ifMatch === tail.ifMatch)) {
|
|
2552
2616
|
if (tail.type === "update") tail.parts = [...tail.parts ?? [{ ...tail.data }], { ...mutation.data }];
|
|
2553
2617
|
tail.data = {
|
|
2554
2618
|
...tail.data,
|
|
@@ -2729,7 +2793,7 @@ var OfflineManager = class {
|
|
|
2729
2793
|
this.connectivity.markFailure();
|
|
2730
2794
|
break;
|
|
2731
2795
|
}
|
|
2732
|
-
if (op.type === "update" && op.ifMatch && isPreconditionFailed(error)) {
|
|
2796
|
+
if ((op.type === "update" || op.type === "delete") && op.ifMatch && this.onConflict && isPreconditionFailed(error)) {
|
|
2733
2797
|
this.connectivity.markSuccess();
|
|
2734
2798
|
await this.resolveConflict(op, error, ticket);
|
|
2735
2799
|
continue;
|
|
@@ -2800,16 +2864,18 @@ var OfflineManager = class {
|
|
|
2800
2864
|
*/
|
|
2801
2865
|
foldableRun(unbatched) {
|
|
2802
2866
|
if (this.replayBatchSize < 2) return [];
|
|
2803
|
-
const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && op.sent === void 0 && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
|
|
2804
2867
|
const head = this.queue[0];
|
|
2868
|
+
const resent = head?.sent?.run;
|
|
2869
|
+
const foldable = (op, collection) => op !== void 0 && op.type === "create" && op.generatedId === true && typeof op.id === "string" && (resent === void 0 ? op.sent === void 0 : op.sent?.run === resent) && !op.upsert && (op.schemaVersion ?? 1) <= 1 && !unbatched.has(op.mutationId) && (collection === void 0 || op.collection === collection);
|
|
2805
2870
|
if (!foldable(head)) return [];
|
|
2806
2871
|
const run = [head];
|
|
2807
|
-
for (let i = 1; i < this.queue.length && run.length < this.replayBatchSize; i++) {
|
|
2872
|
+
for (let i = 1; i < this.queue.length && (resent !== void 0 || run.length < this.replayBatchSize); i++) {
|
|
2808
2873
|
const next = this.queue[i];
|
|
2809
2874
|
if (!foldable(next, head.collection)) break;
|
|
2810
2875
|
if (run.some((member) => mentionsMintedId(next, member.id))) break;
|
|
2811
2876
|
run.push(next);
|
|
2812
2877
|
}
|
|
2878
|
+
if (resent !== void 0 && runKey(run) !== resent) return [];
|
|
2813
2879
|
return run;
|
|
2814
2880
|
}
|
|
2815
2881
|
/**
|
|
@@ -2830,9 +2896,10 @@ var OfflineManager = class {
|
|
|
2830
2896
|
*/
|
|
2831
2897
|
async replayRun(run, ticket) {
|
|
2832
2898
|
const inner = this.innerFor(run[0].collection);
|
|
2833
|
-
const idempotencyKey =
|
|
2899
|
+
const idempotencyKey = runKey(run);
|
|
2834
2900
|
for (const member of run) this.inFlight.add(member.mutationId);
|
|
2835
2901
|
try {
|
|
2902
|
+
for (const member of run) await this.markCreateSent(member, ticket, idempotencyKey);
|
|
2836
2903
|
let rows;
|
|
2837
2904
|
try {
|
|
2838
2905
|
rows = await inner.createMany(run.map((member) => member.data), { idempotencyKey });
|
|
@@ -2854,6 +2921,7 @@ var OfflineManager = class {
|
|
|
2854
2921
|
async replay(op, ticket) {
|
|
2855
2922
|
const inner = this.innerFor(op.collection);
|
|
2856
2923
|
if (op.type === "create") {
|
|
2924
|
+
await this.markCreateSent(op, ticket);
|
|
2857
2925
|
let row;
|
|
2858
2926
|
try {
|
|
2859
2927
|
row = await this.sendQueuedWrite(op, (idempotencyKey) => inner.create(op.data, void 0, { idempotencyKey }), (sent) => inner.create(sent.data, sent.id, { idempotencyKey: sent.idempotencyKey }));
|
|
@@ -2906,11 +2974,30 @@ var OfflineManager = class {
|
|
|
2906
2974
|
}
|
|
2907
2975
|
for (const id of ids) this.removeLocalRow(op.collection, id, ticket, true);
|
|
2908
2976
|
} else if (op.type === "delete") {
|
|
2909
|
-
await inner.delete(op.id, {
|
|
2977
|
+
await inner.delete(op.id, {
|
|
2978
|
+
idempotencyKey: op.sent?.idempotencyKey ?? op.mutationId,
|
|
2979
|
+
...op.ifMatch ? { ifMatch: op.ifMatch } : {}
|
|
2980
|
+
});
|
|
2910
2981
|
this.removeLocalRow(op.collection, op.id, ticket, true);
|
|
2911
2982
|
}
|
|
2912
2983
|
}
|
|
2913
2984
|
/**
|
|
2985
|
+
* Record a queued create as sent, with the request about to go out, and
|
|
2986
|
+
* persist it — before the request, so a lost answer cannot leave it
|
|
2987
|
+
* unmarked. One already marked (sent online before it was queued, or by
|
|
2988
|
+
* an earlier replay) keeps what it holds: that is the request the server
|
|
2989
|
+
* may have stored.
|
|
2990
|
+
*/
|
|
2991
|
+
async markCreateSent(op, ticket, run) {
|
|
2992
|
+
if (op.sent) return;
|
|
2993
|
+
op.sent = {
|
|
2994
|
+
idempotencyKey: op.mutationId,
|
|
2995
|
+
data: { ...op.data },
|
|
2996
|
+
...run ? { run } : {}
|
|
2997
|
+
};
|
|
2998
|
+
await this.persistMutation(`${ticket.scope}|${op.mutationId}`, op).catch(() => void 0);
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
2914
3001
|
* Send a queued write under its key.
|
|
2915
3002
|
*
|
|
2916
3003
|
* A write the queue sent once already, before it was queued, carries that
|
|
@@ -3302,4 +3389,4 @@ var OfflineManager = class {
|
|
|
3302
3389
|
//#endregion
|
|
3303
3390
|
export { OfflineManager };
|
|
3304
3391
|
|
|
3305
|
-
//# sourceMappingURL=offline-
|
|
3392
|
+
//# sourceMappingURL=offline-AvuEcg7u-DFHL98OT.js.map
|