@burdenoff/fe-libs 2026.813.2 → 2026.813.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"SaveToFilesDialog.d.ts","sourceRoot":"","sources":["../../src/storage/SaveToFilesDialog.tsx"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElE,UAAU,KAAK;IACb,IAAI,EAAE,OAAO,CAAC;IACd,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACtC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;CACrC;AAED,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,iBAAiB,EACjB,QAAQ,EACR,OAAO,GACR,EAAE,KAAK,2CAwFP"}
1
+ {"version":3,"file":"SaveToFilesDialog.d.ts","sourceRoot":"","sources":["../../src/storage/SaveToFilesDialog.tsx"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElE,UAAU,KAAK;IACb,IAAI,EAAE,OAAO,CAAC;IACd,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACtC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;CACrC;AAED,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,iBAAiB,EACjB,QAAQ,EACR,OAAO,GACR,EAAE,KAAK,2CA6GP"}
@@ -8,7 +8,7 @@ import { useEffect as d, useState as f } from "react";
8
8
  import { Fragment as p, jsx as m, jsxs as h } from "react/jsx-runtime";
9
9
  //#region src/storage/SaveToFilesDialog.tsx
10
10
  function g({ open: g, onOpenChange: _, source: v, defaultFolderPath: y, filename: b, onSaved: x }) {
11
- let [S, C] = f(y ?? "/Saved"), { save: w, isPending: T, error: E, progress: D } = u();
11
+ let [S, C] = f(y ?? "/Saved"), { save: w, isPending: T, error: E, progress: D, retry: O } = u();
12
12
  return d(() => {
13
13
  g && C(y ?? "/Saved");
14
14
  }, [g, y]), /* @__PURE__ */ m(e, {
@@ -55,6 +55,21 @@ function g({ open: g, onOpenChange: _, source: v, defaultFolderPath: y, filename
55
55
  style: { width: `${D}%` }
56
56
  })
57
57
  }),
58
+ T && O && /* @__PURE__ */ h("p", {
59
+ className: "text-xs text-muted-foreground",
60
+ role: "status",
61
+ "aria-live": "polite",
62
+ "data-testid": "save-to-files-retry",
63
+ children: [
64
+ O.kind === "cold-start" ? "The file service is starting up — retrying" : "Hit a hiccup — retrying",
65
+ " ",
66
+ "(attempt ",
67
+ O.attempt,
68
+ " of ",
69
+ O.maxAttempts,
70
+ ")…"
71
+ ]
72
+ }),
58
73
  E && /* @__PURE__ */ m("p", {
59
74
  className: "text-xs text-destructive",
60
75
  "data-testid": "save-to-files-error",
@@ -6,5 +6,8 @@ export { SaveToFilesDialog } from './SaveToFilesDialog';
6
6
  export { useSaveToFiles } from './useSaveToFiles';
7
7
  export type { SavedFile, UseSaveToFilesResult } from './useSaveToFiles';
8
8
  export { uploadBlobToFiles, FILES_AFTER_SAVE_REFETCH } from './uploadBlob';
9
- export type { UploadBlobParams, UploadedFile } from './uploadBlob';
9
+ export type { UploadBlobParams, UploadedFile, SaveToFilesStep, SaveToFilesRetryNotice, } from './uploadBlob';
10
+ export { classifyFilesError, decideFilesRetry, runWithFilesRetry, COLD_START_MAX_ATTEMPTS, COLD_START_RETRY_DELAYS_MS, COLD_START_RETRY_BUDGET_MS, } from './retryPolicy';
11
+ export type { FilesRetryKind, FilesRetryDecision, FilesRetryNotice, FilesRetryContext, } from './retryPolicy';
12
+ export { warmFilesService, useFilesServiceWarmup, FILES_WARMUP_COOLDOWN_MS, } from './warmFilesService';
10
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAC3E,YAAY,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAC3E,YAAY,EACV,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,sBAAsB,GACvB,MAAM,cAAc,CAAC;AAItB,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,uBAAuB,EACvB,0BAA0B,EAC1B,0BAA0B,GAC3B,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,cAAc,EACd,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,GACzB,MAAM,oBAAoB,CAAC"}
@@ -1,6 +1,8 @@
1
1
  import { StorageFolderPicker as e } from "./StorageFolderPicker.js";
2
- import { FILES_AFTER_SAVE_REFETCH as t, uploadBlobToFiles as n } from "./uploadBlob.js";
3
- import { useSaveToFiles as r } from "./useSaveToFiles.js";
4
- import { SaveToFilesDialog as i } from "./SaveToFilesDialog.js";
5
- import { SaveToFilesButton as a } from "./SaveToFilesButton.js";
6
- export { t as FILES_AFTER_SAVE_REFETCH, a as SaveToFilesButton, i as SaveToFilesDialog, e as StorageFolderPicker, n as uploadBlobToFiles, r as useSaveToFiles };
2
+ import { COLD_START_MAX_ATTEMPTS as t, COLD_START_RETRY_BUDGET_MS as n, COLD_START_RETRY_DELAYS_MS as r, classifyFilesError as i, decideFilesRetry as a, runWithFilesRetry as o } from "./retryPolicy.js";
3
+ import { FILES_AFTER_SAVE_REFETCH as s, uploadBlobToFiles as c } from "./uploadBlob.js";
4
+ import { useSaveToFiles as l } from "./useSaveToFiles.js";
5
+ import { SaveToFilesDialog as u } from "./SaveToFilesDialog.js";
6
+ import { SaveToFilesButton as d } from "./SaveToFilesButton.js";
7
+ import { FILES_WARMUP_COOLDOWN_MS as f, useFilesServiceWarmup as p, warmFilesService as m } from "./warmFilesService.js";
8
+ export { t as COLD_START_MAX_ATTEMPTS, n as COLD_START_RETRY_BUDGET_MS, r as COLD_START_RETRY_DELAYS_MS, s as FILES_AFTER_SAVE_REFETCH, f as FILES_WARMUP_COOLDOWN_MS, d as SaveToFilesButton, u as SaveToFilesDialog, e as StorageFolderPicker, i as classifyFilesError, a as decideFilesRetry, o as runWithFilesRetry, c as uploadBlobToFiles, p as useFilesServiceWarmup, l as useSaveToFiles, m as warmFilesService };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * retryPolicy — when a wspace-files-svc call through the workspace gateway is
3
+ * worth trying again, and how long to wait before doing so.
4
+ *
5
+ * ## Why this exists: a scale-to-zero cold start, measured in production
6
+ *
7
+ * `wspace-files-svc` runs at `minReplicas: 0`, 0.25 vCPU / 0.5 GiB, so after an
8
+ * idle period its revision sits at `replicas: 0, state: "ScaledToZero"`. Timed on
9
+ * the deployed image at that exact resource envelope:
10
+ *
11
+ * bunx prisma migrate deploy 26.0s
12
+ * app boot ~25.5s
13
+ * ------------------------------------
14
+ * >= 51s before the port binds
15
+ *
16
+ * The gateway's per-operation budget is `OPERATION_TIMEOUT_MS = 15_000`. The first
17
+ * request after a quiet period therefore CANNOT succeed: it wakes the container and
18
+ * is then abandoned ~36s before that container can answer.
19
+ *
20
+ * Seven-attempt production reproduction, same 1,305-byte file, idle gaps
21
+ * 0/5/40/5/40/90/5s:
22
+ *
23
+ * #1 16.2s FAIL "Operation timeout" {"code":"OPERATION_TIMEOUT","timeoutMs":15000}
24
+ * #2 16.2s OK #3 6.1s OK #4 6.1s OK #5 6.1s OK #6 2.1s OK #7 2.1s OK
25
+ *
26
+ * Only the first attempt fails; everything after it is warm and gets faster. With a
27
+ * 300s Azure Container Apps cooldown, a real user's first attachment after any quiet
28
+ * period lands on exactly this. The proper fix is `minReplicas: 1` — that needs Azure
29
+ * access, so this module is the client-side half that makes the cold start survivable
30
+ * meanwhile. Several services in this fleet are scale-to-zero, so it is not a
31
+ * one-off workaround.
32
+ *
33
+ * ## Two retry classes, deliberately different
34
+ *
35
+ * - `cold-start` — the gateway gave up waiting (`OPERATION_TIMEOUT` and friends).
36
+ * The container is probably still booting, so the delays are sized
37
+ * in TENS of seconds. A single immediate retry is useless here.
38
+ * - `transient-blip` — a masked `INTERNAL_SERVER_ERROR` or a bare network error, the
39
+ * pre-existing `completeUpload` case (a brief Azure Managed Redis
40
+ * slowdown). Sub-second delays, unchanged from before this module.
41
+ *
42
+ * ## What is NEVER retried
43
+ *
44
+ * Classification is an ALLOWLIST: an unrecognised code is not retried. On top of
45
+ * that, `NEVER_RETRY_CODES` is a hard stop — if a quota rejection, an auth failure
46
+ * or any other 4xx-class business error appears anywhere in the error, the whole
47
+ * error is unretryable even if a timeout also appears. Retrying those is pure harm:
48
+ * the answer will not change and the user waits a minute to be told the same thing.
49
+ */
50
+ /** How an error should be retried, or `null` for "do not retry this". */
51
+ export type FilesRetryKind = 'cold-start' | 'transient-blip';
52
+ /**
53
+ * Attempt ceiling for a cold start. Three attempts, and the third one starts at
54
+ * roughly t+61s:
55
+ *
56
+ * attempt 1 t=0 -> gateway gives up at ~16s
57
+ * wait 10s
58
+ * attempt 2 t=26 -> gateway gives up at ~41s
59
+ * wait 20s
60
+ * attempt 3 t=61 -> container bound its port at ~51s, so this is the one that lands
61
+ *
62
+ * The container's boot clock starts when attempt 1 reaches the ACA ingress, which is
63
+ * why the ladder is measured from the FIRST attempt and not from each failure. The
64
+ * numbers above come from the measurement at the top of this file: >=51s to port
65
+ * bind, 15s gateway budget. Change them if that measurement changes, not before.
66
+ */
67
+ export declare const COLD_START_MAX_ATTEMPTS = 3;
68
+ /** Delay before cold-start attempt 2, then before attempt 3. See the ladder above. */
69
+ export declare const COLD_START_RETRY_DELAYS_MS: readonly number[];
70
+ /**
71
+ * Hard wall-clock ceiling for one operation's cold-start retries, measured from the
72
+ * first attempt. The ladder above tops out at ~76s of real time; this stops a
73
+ * pathologically slow gateway from stretching that indefinitely. Past this, we give
74
+ * up honestly and show the user the real error.
75
+ */
76
+ export declare const COLD_START_RETRY_BUDGET_MS = 75000;
77
+ /** Attempt ceiling for a transient blip — unchanged from the pre-existing behaviour. */
78
+ export declare const BLIP_MAX_ATTEMPTS = 3;
79
+ /** Delays before blip attempts 2 and 3 — unchanged from the pre-existing behaviour. */
80
+ export declare const BLIP_RETRY_DELAYS_MS: readonly number[];
81
+ /**
82
+ * Best-effort message extraction, preferring the first GraphQL error's message
83
+ * (Apollo v4 `CombinedGraphQLErrors.errors`, or v3 `.graphQLErrors`) so callers can
84
+ * surface the real reason instead of a generic envelope.
85
+ *
86
+ * Lives here rather than in `uploadBlob` because both the upload path and the folder
87
+ * path need it and both now sit on top of this module. `uploadBlob` re-exports it, so
88
+ * its public name is unchanged.
89
+ */
90
+ export declare function extractErrorMessage(err: unknown): string | undefined;
91
+ /**
92
+ * Decide what KIND of failure this is, or `null` if it must not be retried.
93
+ *
94
+ * Pure. This is the whole safety argument in one function, so it is unit-tested
95
+ * directly rather than only through the upload path.
96
+ */
97
+ export declare function classifyFilesError(err: unknown): FilesRetryKind | null;
98
+ export interface FilesRetryContext {
99
+ /** 1-based index of the attempt that just failed. */
100
+ attempt: number;
101
+ /** Milliseconds since the FIRST attempt started (the container's boot clock). */
102
+ elapsedMs: number;
103
+ /** Which classes this call site permits. Defaults to cold-start only. */
104
+ allow?: readonly FilesRetryKind[];
105
+ }
106
+ export type FilesRetryDecision = {
107
+ retry: true;
108
+ kind: FilesRetryKind;
109
+ /** How long to wait before the next attempt. */
110
+ delayMs: number;
111
+ /** 1-based number of the attempt that is about to run. */
112
+ nextAttempt: number;
113
+ maxAttempts: number;
114
+ } | {
115
+ retry: false;
116
+ reason: 'not-retryable' | 'kind-not-allowed' | 'attempts-exhausted' | 'budget-exhausted';
117
+ };
118
+ /**
119
+ * The retry policy as a pure function: given the error and where we are, should we go
120
+ * again and after how long?
121
+ *
122
+ * Separated from any I/O so the safety properties can be asserted directly — retries
123
+ * on a gateway timeout, does NOT retry a quota/auth/4xx rejection, respects the
124
+ * attempt ceiling and the wall-clock budget, and reports honestly why it stopped.
125
+ */
126
+ export declare function decideFilesRetry(err: unknown, ctx: FilesRetryContext): FilesRetryDecision;
127
+ /** What one attempt reports back to the runner. */
128
+ export type FilesAttemptResult<T> = {
129
+ ok: true;
130
+ value: T;
131
+ } | {
132
+ ok: false;
133
+ error: unknown;
134
+ };
135
+ /** Handed to `onRetry` just before the runner sleeps. Enough to render honest UI. */
136
+ export interface FilesRetryNotice {
137
+ kind: FilesRetryKind;
138
+ /** 1-based number of the attempt about to run (2 means "second try"). */
139
+ attempt: number;
140
+ maxAttempts: number;
141
+ /** How long the runner is about to wait. */
142
+ delayMs: number;
143
+ /** The failure that triggered the retry, for logging — never for the user. */
144
+ error: unknown;
145
+ }
146
+ export interface RunWithFilesRetryOptions {
147
+ /** Which classes this call site permits. Defaults to cold-start only. */
148
+ allow?: readonly FilesRetryKind[];
149
+ /** Fired before each wait, so the UI can say what it is waiting for. */
150
+ onRetry?: (notice: FilesRetryNotice) => void;
151
+ /** Injected by tests. */
152
+ now?: () => number;
153
+ /** Injected by tests. */
154
+ sleep?: (ms: number) => Promise<void>;
155
+ }
156
+ export interface FilesRetryOutcome<T> {
157
+ /** Present only on success. */
158
+ value?: T;
159
+ /** The last in-band error, when `value` is absent. */
160
+ error?: unknown;
161
+ /** How many attempts were actually made. */
162
+ attempts: number;
163
+ /** Why we stopped, when `value` is absent. */
164
+ gaveUpBecause?: 'not-retryable' | 'kind-not-allowed' | 'attempts-exhausted' | 'budget-exhausted';
165
+ }
166
+ /**
167
+ * Run `attempt` until it succeeds, the policy says stop, or the budget runs out.
168
+ *
169
+ * Two failure channels, on purpose:
170
+ *
171
+ * - A THROWN error is classified and, if unretryable, rethrown **as-is**. Identity
172
+ * and stack are preserved, so callers that let network errors through untouched
173
+ * keep doing exactly that.
174
+ * - An IN-BAND error (`{ ok: false, error }` — what `errorPolicy: 'all'` produces on
175
+ * `result.error`) is returned in the outcome. The runner never invents a message
176
+ * for it; the caller owns the wording, which is what fe-libs #132 established.
177
+ */
178
+ export declare function runWithFilesRetry<T>(attempt: (attemptNumber: number) => Promise<FilesAttemptResult<T>>, options?: RunWithFilesRetryOptions): Promise<FilesRetryOutcome<T>>;
179
+ //# sourceMappingURL=retryPolicy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retryPolicy.d.ts","sourceRoot":"","sources":["../../src/storage/retryPolicy.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,150 @@
1
+ //#region src/storage/retryPolicy.ts
2
+ var e = 3, t = [1e4, 2e4], n = 75e3, r = [400, 800], i = new Set([
3
+ "OPERATION_TIMEOUT",
4
+ "GATEWAY_TIMEOUT",
5
+ "REQUEST_TIMEOUT",
6
+ "TIMEOUT"
7
+ ]), a = new Set([
8
+ "QUOTA_NOT_PROVISIONED",
9
+ "QUOTA_EXHAUSTED",
10
+ "QUOTA_EXCEEDED",
11
+ "FORBIDDEN",
12
+ "UNAUTHENTICATED",
13
+ "UNAUTHORIZED",
14
+ "BAD_USER_INPUT",
15
+ "BAD_REQUEST",
16
+ "GRAPHQL_VALIDATION_FAILED",
17
+ "GRAPHQL_PARSE_FAILED",
18
+ "PERSISTED_QUERY_NOT_FOUND",
19
+ "NOT_FOUND",
20
+ "CONFLICT",
21
+ "PAYLOAD_TOO_LARGE"
22
+ ]), o = new Set([
23
+ 408,
24
+ 504,
25
+ 524
26
+ ]), s = /\boperation\s+timed?\s?out\b/i;
27
+ function c(e) {
28
+ return !e || typeof e != "object" ? null : e;
29
+ }
30
+ function l(e) {
31
+ let t = c(e);
32
+ if (!t) return [];
33
+ let n = [], r = (e) => {
34
+ typeof e == "string" && e.length > 0 && n.push(e);
35
+ };
36
+ r(t.extensions?.code);
37
+ for (let e of t.errors ?? t.graphQLErrors ?? []) r(e?.extensions?.code);
38
+ let i = c(t.networkError);
39
+ if (i) {
40
+ r(i.extensions?.code);
41
+ for (let e of i.errors ?? i.graphQLErrors ?? []) r(e?.extensions?.code);
42
+ }
43
+ return n;
44
+ }
45
+ function u(e) {
46
+ let t = [], n = (e) => {
47
+ typeof e == "number" && Number.isFinite(e) && t.push(e);
48
+ }, r = c(e);
49
+ if (!r) return t;
50
+ n(r.statusCode), n(r.status);
51
+ let i = c(r.networkError);
52
+ return i && (n(i.statusCode), n(i.status)), t;
53
+ }
54
+ function d(e) {
55
+ let t = c(e);
56
+ if (!t) return [];
57
+ let n = [], r = (e) => {
58
+ typeof e == "string" && e.length > 0 && n.push(e);
59
+ };
60
+ r(t.message);
61
+ for (let e of t.errors ?? t.graphQLErrors ?? []) r(e?.message);
62
+ return n;
63
+ }
64
+ function f(e) {
65
+ if (!e || typeof e != "object") return;
66
+ let t = e, n = (t.errors ?? t.graphQLErrors)?.[0]?.message;
67
+ if (typeof n == "string" && n.length > 0) return n;
68
+ if (typeof t.message == "string" && t.message.length > 0) return t.message;
69
+ }
70
+ function p(e) {
71
+ if (!e || typeof e != "object") return null;
72
+ let t = l(e), n = u(e);
73
+ return t.some((e) => a.has(e)) || n.some((e) => e >= 400 && e < 500 && e !== 408) ? null : t.some((e) => i.has(e)) || n.some((e) => o.has(e)) || d(e).some((e) => s.test(e)) ? "cold-start" : c(e)?.networkError || t.includes("INTERNAL_SERVER_ERROR") ? "transient-blip" : null;
74
+ }
75
+ var m = {
76
+ "cold-start": 3,
77
+ "transient-blip": 3
78
+ }, h = {
79
+ "cold-start": t,
80
+ "transient-blip": r
81
+ }, g = ["cold-start"];
82
+ function _(e, t) {
83
+ let n = p(e);
84
+ if (!n) return {
85
+ retry: !1,
86
+ reason: "not-retryable"
87
+ };
88
+ if (!(t.allow ?? g).includes(n)) return {
89
+ retry: !1,
90
+ reason: "kind-not-allowed"
91
+ };
92
+ let r = m[n];
93
+ if (t.attempt >= r) return {
94
+ retry: !1,
95
+ reason: "attempts-exhausted"
96
+ };
97
+ let i = h[n], a = i[Math.min(t.attempt - 1, i.length - 1)] ?? 0;
98
+ return n === "cold-start" && t.elapsedMs + a >= 75e3 ? {
99
+ retry: !1,
100
+ reason: "budget-exhausted"
101
+ } : {
102
+ retry: !0,
103
+ kind: n,
104
+ delayMs: a,
105
+ nextAttempt: t.attempt + 1,
106
+ maxAttempts: r
107
+ };
108
+ }
109
+ var v = (e) => new Promise((t) => setTimeout(t, e));
110
+ async function y(e, t = {}) {
111
+ let n = t.now ?? (() => Date.now()), r = t.sleep ?? v, i = n(), a = 0;
112
+ for (;;) {
113
+ a += 1;
114
+ let o, s, c = !1;
115
+ try {
116
+ o = await e(a);
117
+ } catch (e) {
118
+ c = !0, s = e, o = {
119
+ ok: !1,
120
+ error: e
121
+ };
122
+ }
123
+ if (o.ok) return {
124
+ value: o.value,
125
+ attempts: a
126
+ };
127
+ let l = _(o.error, {
128
+ attempt: a,
129
+ elapsedMs: n() - i,
130
+ ...t.allow ? { allow: t.allow } : {}
131
+ });
132
+ if (!l.retry) {
133
+ if (c) throw s;
134
+ return {
135
+ error: o.error,
136
+ attempts: a,
137
+ gaveUpBecause: l.reason
138
+ };
139
+ }
140
+ t.onRetry?.({
141
+ kind: l.kind,
142
+ attempt: l.nextAttempt,
143
+ maxAttempts: l.maxAttempts,
144
+ delayMs: l.delayMs,
145
+ error: o.error
146
+ }), await r(l.delayMs);
147
+ }
148
+ }
149
+ //#endregion
150
+ export { e as COLD_START_MAX_ATTEMPTS, n as COLD_START_RETRY_BUDGET_MS, t as COLD_START_RETRY_DELAYS_MS, p as classifyFilesError, _ as decideFilesRetry, f as extractErrorMessage, y as runWithFilesRetry };
@@ -1,9 +1,17 @@
1
1
  import { ApolloClient } from '@apollo/client';
2
+ import { extractErrorMessage, FilesRetryNotice } from './retryPolicy';
3
+ export { extractErrorMessage };
2
4
  /**
3
5
  * Apollo invalidations every "Save to Files" caller benefits from — pulled
4
6
  * from microfe-files' useUploadFile to keep the browse views in sync.
5
7
  */
6
8
  export declare const FILES_AFTER_SAVE_REFETCH: string[];
9
+ /** Which gateway hop a retry notice is about, so the UI can name it. */
10
+ export type SaveToFilesStep = 'folder' | 'initiate' | 'complete';
11
+ /** A retry notice with the step attached. */
12
+ export interface SaveToFilesRetryNotice extends FilesRetryNotice {
13
+ step: SaveToFilesStep;
14
+ }
7
15
  export interface UploadBlobParams {
8
16
  client: ApolloClient;
9
17
  blob: Blob;
@@ -11,22 +19,23 @@ export interface UploadBlobParams {
11
19
  mimeType: string;
12
20
  folderId: string;
13
21
  onProgress?: (pct: number) => void;
22
+ /**
23
+ * Fired before each retry wait. The upload is still running — this exists so the UI
24
+ * can explain a 60-second pause instead of looking frozen. It is never a failure
25
+ * signal; a genuine failure still arrives as a rejection.
26
+ */
27
+ onRetry?: (notice: SaveToFilesRetryNotice) => void;
28
+ /**
29
+ * Test seam for the retry ladder's clock and waits. Production callers never set
30
+ * these — without them the real ladder (tens of seconds) runs, which is the point.
31
+ */
32
+ now?: () => number;
33
+ sleep?: (ms: number) => Promise<void>;
14
34
  }
15
35
  export interface UploadedFile {
16
36
  id: string;
17
37
  name: string;
18
38
  folderId: string | null;
19
39
  }
20
- /**
21
- * Best-effort message extraction, preferring the first GraphQL error's message
22
- * (Apollo v4 CombinedGraphQLErrors `.errors`, or v3 `.graphQLErrors`) so we surface
23
- * the real reason a completeUpload failed instead of a generic error envelope.
24
- *
25
- * Exported because every `client.mutate` call in this module faces the same
26
- * problem: under `errorPolicy: 'all'` the failure arrives in-band on
27
- * `result.error` and the caller's own "that didn't work" string is the LEAST
28
- * informative thing it could report. See `useSaveToFiles`.
29
- */
30
- export declare function extractErrorMessage(err: unknown): string | undefined;
31
40
  export declare function uploadBlobToFiles(params: UploadBlobParams): Promise<UploadedFile>;
32
41
  //# sourceMappingURL=uploadBlob.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"uploadBlob.d.ts","sourceRoot":"","sources":["../../src/storage/uploadBlob.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAyBxD;;;GAGG;AACH,eAAO,MAAM,wBAAwB,UAKpC,CAAC;AAEF,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AA2BD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAWpE;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CA8HvF"}
1
+ {"version":3,"file":"uploadBlob.d.ts","sourceRoot":"","sources":["../../src/storage/uploadBlob.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAExD,OAAO,EAAE,mBAAmB,EAAqB,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAE9F,OAAO,EAAE,mBAAmB,EAAE,CAAC;AAyB/B;;;GAGG;AACH,eAAO,MAAM,wBAAwB,UAKpC,CAAC;AAEF,wEAAwE;AACxE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,CAAC;AAEjE,6CAA6C;AAC7C,MAAM,WAAW,sBAAuB,SAAQ,gBAAgB;IAC9D,IAAI,EAAE,eAAe,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACnD;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAuIvF"}
@@ -1,6 +1,7 @@
1
- import { gql as e } from "@apollo/client";
1
+ import { extractErrorMessage as e, runWithFilesRetry as t } from "./retryPolicy.js";
2
+ import { gql as n } from "@apollo/client";
2
3
  //#region src/storage/uploadBlob.ts
3
- var t = e`
4
+ var r = n`
4
5
  mutation SaveToFiles_InitiateUpload($input: InitiateUploadInput!) {
5
6
  initiateUpload(input: $input) {
6
7
  session {
@@ -9,7 +10,7 @@ var t = e`
9
10
  uploadUrl
10
11
  }
11
12
  }
12
- `, n = e`
13
+ `, i = n`
13
14
  mutation SaveToFiles_CompleteUpload($input: CompleteUploadInput!) {
14
15
  completeUpload(input: $input) {
15
16
  file {
@@ -19,37 +20,48 @@ var t = e`
19
20
  }
20
21
  }
21
22
  }
22
- `, r = [
23
+ `, a = [
23
24
  "GetFilesRootBrowsePage",
24
25
  "GetFilesFolderBrowsePage",
25
26
  "GetFilesRecentPage",
26
27
  "GetFiles"
27
28
  ];
28
- function i(e) {
29
- if (!e || typeof e != "object") return !1;
30
- let t = e;
31
- return t.networkError ? !0 : (t.errors ?? t.graphQLErrors ?? []).some((e) => e?.extensions?.code === "INTERNAL_SERVER_ERROR");
32
- }
33
- function a(e) {
34
- if (!e || typeof e != "object") return;
35
- let t = e, n = (t.errors ?? t.graphQLErrors)?.[0]?.message;
36
- if (typeof n == "string" && n.length > 0) return n;
37
- if (typeof t.message == "string" && t.message.length > 0) return t.message;
38
- }
39
- async function o(e) {
40
- let { client: o, blob: s, filename: c, mimeType: l, folderId: u, onProgress: d } = e, f = c.includes(".") ? c.substring(c.lastIndexOf(".") + 1) : "";
29
+ async function o(n) {
30
+ let { client: o, blob: s, filename: c, mimeType: l, folderId: u, onProgress: d, onRetry: f } = n, p = c.includes(".") ? c.substring(c.lastIndexOf(".") + 1) : "", m = {
31
+ ...n.now ? { now: n.now } : {},
32
+ ...n.sleep ? { sleep: n.sleep } : {}
33
+ };
41
34
  d?.(0);
42
- let p = await o.mutate({
43
- mutation: t,
44
- variables: { input: {
45
- fileName: c,
46
- fileSize: String(s.size),
47
- mimeType: l,
48
- extension: f,
49
- folderId: u
50
- } }
51
- }), m = p.data?.initiateUpload?.session, h = p.data?.initiateUpload?.uploadUrl;
52
- if (!m || !h) throw Error(a(p.error) ?? "initiateUpload did not return a presigned URL");
35
+ let h = await t(async () => {
36
+ let e = await o.mutate({
37
+ mutation: r,
38
+ variables: { input: {
39
+ fileName: c,
40
+ fileSize: String(s.size),
41
+ mimeType: l,
42
+ extension: p,
43
+ folderId: u
44
+ } }
45
+ }), t = e.data?.initiateUpload?.session, n = e.data?.initiateUpload?.uploadUrl;
46
+ return t && n ? {
47
+ ok: !0,
48
+ value: {
49
+ session: t,
50
+ uploadUrl: n
51
+ }
52
+ } : {
53
+ ok: !1,
54
+ error: e.error
55
+ };
56
+ }, {
57
+ ...m,
58
+ ...f ? { onRetry: (e) => f({
59
+ ...e,
60
+ step: "initiate"
61
+ }) } : {}
62
+ });
63
+ if (!h.value) throw Error(e(h.error) ?? "initiateUpload did not return a presigned URL");
64
+ let { session: g, uploadUrl: _ } = h.value;
53
65
  d?.(10), await new Promise((e, t) => {
54
66
  let n = new XMLHttpRequest();
55
67
  n.upload.addEventListener("progress", (e) => {
@@ -59,44 +71,35 @@ async function o(e) {
59
71
  }
60
72
  }), n.addEventListener("load", () => {
61
73
  n.status >= 200 && n.status < 300 ? (d?.(90), e()) : t(/* @__PURE__ */ Error(`Upload PUT failed with status ${n.status}`));
62
- }), n.addEventListener("error", () => t(/* @__PURE__ */ Error("Upload PUT network error"))), n.addEventListener("abort", () => t(/* @__PURE__ */ Error("Upload PUT aborted"))), n.open("PUT", h), n.setRequestHeader("Content-Type", l), n.send(s);
74
+ }), n.addEventListener("error", () => t(/* @__PURE__ */ Error("Upload PUT network error"))), n.addEventListener("abort", () => t(/* @__PURE__ */ Error("Upload PUT aborted"))), n.open("PUT", _), n.setRequestHeader("Content-Type", l), n.send(s);
75
+ });
76
+ let v = await t(async () => {
77
+ let e = await o.mutate({
78
+ mutation: i,
79
+ variables: { input: {
80
+ sessionId: g.id,
81
+ fileSize: String(s.size),
82
+ folderId: u
83
+ } },
84
+ refetchQueries: a
85
+ }), t = e.data?.completeUpload?.file;
86
+ return t ? {
87
+ ok: !0,
88
+ value: t
89
+ } : {
90
+ ok: !1,
91
+ error: e.error
92
+ };
93
+ }, {
94
+ allow: ["cold-start", "transient-blip"],
95
+ ...m,
96
+ ...f ? { onRetry: (e) => f({
97
+ ...e,
98
+ step: "complete"
99
+ }) } : {}
63
100
  });
64
- let g = async () => {
65
- try {
66
- let e = await o.mutate({
67
- mutation: n,
68
- variables: { input: {
69
- sessionId: m.id,
70
- fileSize: String(s.size),
71
- folderId: u
72
- } },
73
- refetchQueries: r
74
- }), t = e.data?.completeUpload?.file ?? void 0;
75
- if (t) return { file: t };
76
- let c = e.error;
77
- return {
78
- retryable: i(c),
79
- message: a(c)
80
- };
81
- } catch (e) {
82
- if (!i(e)) throw e;
83
- return {
84
- retryable: !0,
85
- message: a(e)
86
- };
87
- }
88
- }, _, v;
89
- for (let e = 0; e <= 2; e++) {
90
- e > 0 && await new Promise((t) => setTimeout(t, 400 * e));
91
- let t = await g();
92
- if (t.file) {
93
- _ = t.file;
94
- break;
95
- }
96
- if (v = t.message ?? v, !t.retryable) break;
97
- }
98
- if (!_) throw Error(v ?? "completeUpload did not return a file");
99
- return d?.(100), _;
101
+ if (!v.value) throw Error(e(v.error) ?? "completeUpload did not return a file");
102
+ return d?.(100), v.value;
100
103
  }
101
104
  //#endregion
102
- export { r as FILES_AFTER_SAVE_REFETCH, a as extractErrorMessage, o as uploadBlobToFiles };
105
+ export { a as FILES_AFTER_SAVE_REFETCH, o as uploadBlobToFiles };
@@ -1,5 +1,6 @@
1
1
  import { ApolloClient } from '@apollo/client';
2
2
  import { SaveToFilesSource } from './SaveToFilesButton';
3
+ import { SaveToFilesRetryNotice } from './uploadBlob';
3
4
  export interface SavedFile {
4
5
  id: string;
5
6
  name: string;
@@ -9,13 +10,35 @@ export interface UseSaveToFilesResult {
9
10
  save: (params: {
10
11
  source: SaveToFilesSource;
11
12
  folderPath: string;
13
+ /**
14
+ * Fired before each retry wait. Purely informational — the save is still in
15
+ * flight. Exists so a caller rendering its own chip can say why a cold start is
16
+ * taking a minute instead of showing a spinner that looks stuck.
17
+ */
18
+ onRetry?: (notice: SaveToFilesRetryNotice) => void;
12
19
  }) => Promise<SavedFile>;
13
20
  isPending: boolean;
14
21
  error: Error | null;
15
22
  progress: number;
23
+ /**
24
+ * The retry currently being waited for or run — `{ step, attempt, maxAttempts,
25
+ * delayMs }`. Null on the first attempt of every step, and always cleared when the
26
+ * save settles either way, so it can never be mistaken for a result. Render it to
27
+ * explain a long pause; it is not an error and must not be shown as one.
28
+ */
29
+ retry: SaveToFilesRetryNotice | null;
16
30
  }
17
31
  /** The Apollo surface `resolveFolderIdByPath` needs — kept narrow so it is trivial to fake. */
18
32
  type FolderMutatingClient = Pick<ApolloClient, 'mutate'>;
33
+ export interface ResolveFolderOptions {
34
+ onRetry?: (notice: SaveToFilesRetryNotice) => void;
35
+ /**
36
+ * Test seam for the retry ladder's clock and waits. Production callers never set
37
+ * these — without them the real ladder (tens of seconds) runs, which is the point.
38
+ */
39
+ now?: () => number;
40
+ sleep?: (ms: number) => Promise<void>;
41
+ }
19
42
  /**
20
43
  * Resolve (creating if needed) the destination folder for a path, returning its id.
21
44
  *
@@ -38,8 +61,13 @@ type FolderMutatingClient = Pick<ApolloClient, 'mutate'>;
38
61
  * bad path or a missing folder and sent people looking at file-storage quota
39
62
  * they had plenty of. Surface the real reason; keep the path as a last resort
40
63
  * for the genuinely inexplicable case.
64
+ *
65
+ * A gateway timeout is now also *waited out* rather than only reported, because this
66
+ * is the hop that was measured failing on a cold container. Only a cold-start-class
67
+ * failure is retried — a quota rejection, an auth failure or any other 4xx answer
68
+ * still comes straight back, unchanged and immediately.
41
69
  */
42
- export declare function resolveFolderIdByPath(client: FolderMutatingClient, folderPath: string): Promise<string>;
70
+ export declare function resolveFolderIdByPath(client: FolderMutatingClient, folderPath: string, options?: ResolveFolderOptions): Promise<string>;
43
71
  export declare function useSaveToFiles(): UseSaveToFilesResult;
44
72
  export {};
45
73
  //# sourceMappingURL=useSaveToFiles.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useSaveToFiles.d.ts","sourceRoot":"","sources":["../../src/storage/useSaveToFiles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAIxD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAuB7D,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,iBAAiB,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACxF,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,+FAA+F;AAC/F,KAAK,oBAAoB,GAAG,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;AAEzD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,oBAAoB,EAC5B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAYjB;AAED,wBAAgB,cAAc,IAAI,oBAAoB,CAqErD"}
1
+ {"version":3,"file":"useSaveToFiles.d.ts","sourceRoot":"","sources":["../../src/storage/useSaveToFiles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAIxD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,cAAc,CAAC;AAsBtB,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,CAAC,MAAM,EAAE;QACb,MAAM,EAAE,iBAAiB,CAAC;QAC1B,UAAU,EAAE,MAAM,CAAC;QACnB;;;;WAIG;QACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,sBAAsB,KAAK,IAAI,CAAC;KACpD,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,KAAK,EAAE,sBAAsB,GAAG,IAAI,CAAC;CACtC;AAED,+FAA+F;AAC/F,KAAK,oBAAoB,GAAG,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;AAEzD,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACnD;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,oBAAoB,EAC5B,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,MAAM,CAAC,CA4BjB;AAED,wBAAgB,cAAc,IAAI,oBAAoB,CAsFrD"}
@@ -1,9 +1,10 @@
1
- import { FILES_AFTER_SAVE_REFETCH as e, extractErrorMessage as t, uploadBlobToFiles as n } from "./uploadBlob.js";
2
- import { useState as r } from "react";
3
- import { gql as i } from "@apollo/client";
4
- import { useApolloClient as a } from "@apollo/client/react";
1
+ import { extractErrorMessage as e, runWithFilesRetry as t } from "./retryPolicy.js";
2
+ import { FILES_AFTER_SAVE_REFETCH as n, uploadBlobToFiles as r } from "./uploadBlob.js";
3
+ import { useState as i } from "react";
4
+ import { gql as a } from "@apollo/client";
5
+ import { useApolloClient as o } from "@apollo/client/react";
5
6
  //#region src/storage/useSaveToFiles.ts
6
- var o = i`
7
+ var s = a`
7
8
  mutation SaveToFiles_EnsureFolderByPath($path: String!) {
8
9
  ensureFolderByPath(path: $path) {
9
10
  id
@@ -11,7 +12,7 @@ var o = i`
11
12
  path
12
13
  }
13
14
  }
14
- `, s = i`
15
+ `, c = a`
15
16
  mutation SaveToFiles_SaveSandboxArtifact($input: SaveSandboxArtifactInput!) {
16
17
  saveSandboxArtifactToFiles(input: $input) {
17
18
  id
@@ -20,58 +21,79 @@ var o = i`
20
21
  }
21
22
  }
22
23
  `;
23
- async function c(e, n) {
24
- let r = await e.mutate({
25
- mutation: o,
26
- variables: { path: n }
27
- }), i = r.data?.ensureFolderByPath?.id;
28
- if (i) return i;
29
- throw Error(t(r.error) ?? `Could not resolve folder for path: ${n}`);
24
+ async function l(n, r, i = {}) {
25
+ let a = await t(async () => {
26
+ let e = await n.mutate({
27
+ mutation: s,
28
+ variables: { path: r }
29
+ }), t = e.data?.ensureFolderByPath?.id;
30
+ return t ? {
31
+ ok: !0,
32
+ value: t
33
+ } : {
34
+ ok: !1,
35
+ error: e.error
36
+ };
37
+ }, {
38
+ ...i.onRetry ? { onRetry: (e) => i.onRetry?.({
39
+ ...e,
40
+ step: "folder"
41
+ }) } : {},
42
+ ...i.now ? { now: i.now } : {},
43
+ ...i.sleep ? { sleep: i.sleep } : {}
44
+ });
45
+ if (a.value !== void 0) return a.value;
46
+ throw Error(e(a.error) ?? `Could not resolve folder for path: ${r}`);
30
47
  }
31
- function l() {
32
- let i = a(), [o, l] = r(!1), [u, d] = r(null), [f, p] = r(0);
48
+ function u() {
49
+ let t = o(), [a, s] = i(!1), [u, d] = i(null), [f, p] = i(0), [m, h] = i(null);
33
50
  return {
34
- save: async (r) => {
35
- l(!0), d(null), p(0);
51
+ save: async (i) => {
52
+ s(!0), d(null), p(0), h(null);
53
+ let a = (e) => {
54
+ h(e), i.onRetry?.(e);
55
+ };
36
56
  try {
37
- let a = await c(i, r.folderPath);
38
- if (r.source.kind === "sandbox-artifact") {
39
- let n = await i.mutate({
40
- mutation: s,
57
+ let o = await l(t, i.folderPath, { onRetry: a });
58
+ if (h(null), i.source.kind === "sandbox-artifact") {
59
+ let r = await t.mutate({
60
+ mutation: c,
41
61
  variables: { input: {
42
- sandboxId: r.source.sandboxId,
43
- sessionId: r.source.sessionId,
44
- path: r.source.path,
45
- folderId: a,
46
- filename: r.source.filename ?? null
62
+ sandboxId: i.source.sandboxId,
63
+ sessionId: i.source.sessionId,
64
+ path: i.source.path,
65
+ folderId: o,
66
+ filename: i.source.filename ?? null
47
67
  } },
48
- refetchQueries: e
49
- }), o = n.data?.saveSandboxArtifactToFiles;
50
- if (!o) throw Error(t(n.error) ?? "saveSandboxArtifactToFiles returned no file");
51
- return p(100), o;
68
+ refetchQueries: n
69
+ }), a = r.data?.saveSandboxArtifactToFiles;
70
+ if (!a) throw Error(e(r.error) ?? "saveSandboxArtifactToFiles returned no file");
71
+ return p(100), a;
52
72
  }
53
- let o = await r.source.getBlob();
54
- if (!o) throw Error("Cancelled: no bytes to save");
55
- let l = o.blob;
56
- return await n({
57
- client: i,
58
- blob: l,
59
- filename: o.filename ?? r.source.filename,
60
- mimeType: o.mimeType ?? r.source.mimeType,
61
- folderId: a,
62
- onProgress: p
73
+ let s = await i.source.getBlob();
74
+ if (!s) throw Error("Cancelled: no bytes to save");
75
+ let u = s.blob;
76
+ return await r({
77
+ client: t,
78
+ blob: u,
79
+ filename: s.filename ?? i.source.filename,
80
+ mimeType: s.mimeType ?? i.source.mimeType,
81
+ folderId: o,
82
+ onProgress: p,
83
+ onRetry: a
63
84
  });
64
85
  } catch (e) {
65
86
  let t = e instanceof Error ? e : Error(String(e));
66
87
  throw d(t), t;
67
88
  } finally {
68
- l(!1);
89
+ s(!1), h(null);
69
90
  }
70
91
  },
71
- isPending: o,
92
+ isPending: a,
72
93
  error: u,
73
- progress: f
94
+ progress: f,
95
+ retry: m
74
96
  };
75
97
  }
76
98
  //#endregion
77
- export { l as useSaveToFiles };
99
+ export { u as useSaveToFiles };
@@ -0,0 +1,33 @@
1
+ import { ApolloClient } from '@apollo/client';
2
+ /**
3
+ * How long a successful warm-up is assumed to hold. Azure Container Apps scales a
4
+ * revision back to zero after ~300s idle, so anything under that is a wasted request
5
+ * — the container is still up. 240s leaves headroom without letting the window lapse.
6
+ */
7
+ export declare const FILES_WARMUP_COOLDOWN_MS = 240000;
8
+ /** Test seam — module state is a browser-lifetime singleton in real use. */
9
+ export declare function __resetFilesWarmupForTests(): void;
10
+ export interface WarmFilesServiceOptions {
11
+ /** Injected by tests. */
12
+ now?: () => number;
13
+ /** Skip the cooldown (for an explicit "the user is definitely about to upload"). */
14
+ force?: boolean;
15
+ }
16
+ /**
17
+ * Send one cheap files-svc read so a scaled-to-zero container starts booting.
18
+ *
19
+ * Never rejects and never throws — a failed warm-up is not a user-visible event.
20
+ * Concurrent callers share the in-flight promise; callers inside the cooldown window
21
+ * get an already-resolved one.
22
+ */
23
+ export declare function warmFilesService(client: Pick<ApolloClient, 'query'>, options?: WarmFilesServiceOptions): Promise<void>;
24
+ /**
25
+ * Fire a files-svc warm-up when `active` becomes true — e.g. when the assistant panel
26
+ * opens, so the container is booting while the user is still typing.
27
+ *
28
+ * The call site belongs to whoever owns the panel. In HealthyBowl that is
29
+ * `microfe-healthybowl`'s `AIAssistantPanel`, not fe-libs: fe-libs owns the mechanism
30
+ * (which query, single-flight, silence), the product owns the moment.
31
+ */
32
+ export declare function useFilesServiceWarmup(active?: boolean): void;
33
+ //# sourceMappingURL=warmFilesService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"warmFilesService.d.ts","sourceRoot":"","sources":["../../src/storage/warmFilesService.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAYxD;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,SAAU,CAAC;AAOhD,4EAA4E;AAC5E,wBAAgB,0BAA0B,IAAI,IAAI,CAGjD;AAED,MAAM,WAAW,uBAAuB;IACtC,yBAAyB;IACzB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,EACnC,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,IAAI,CAAC,CA0Cf;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,GAAE,OAAc,GAAG,IAAI,CAOlE"}
@@ -0,0 +1,42 @@
1
+ import { useEffect as e } from "react";
2
+ import { gql as t } from "@apollo/client";
3
+ import { useApolloClient as n } from "@apollo/client/react";
4
+ //#region src/storage/warmFilesService.ts
5
+ var r = t`
6
+ query FilesServiceWarmup($pagination: PaginationInput) {
7
+ getMyUploadSessions(pagination: $pagination) {
8
+ total
9
+ }
10
+ }
11
+ `, i = 24e4, a = null, o = null;
12
+ function s(e, t = {}) {
13
+ let n = t.now ?? (() => Date.now());
14
+ if (a) return a;
15
+ if (!t.force && o !== null && n() - o < 24e4) return Promise.resolve();
16
+ let i;
17
+ try {
18
+ i = Promise.resolve(e.query({
19
+ query: r,
20
+ variables: { pagination: {
21
+ limit: 1,
22
+ offset: 0
23
+ } },
24
+ fetchPolicy: "no-cache",
25
+ errorPolicy: "all"
26
+ }));
27
+ } catch {
28
+ i = Promise.resolve();
29
+ }
30
+ let s = i.then(() => void 0, () => void 0).then(() => {
31
+ o = n(), a = null;
32
+ });
33
+ return a = s, s;
34
+ }
35
+ function c(t = !0) {
36
+ let r = n();
37
+ e(() => {
38
+ t && s(r);
39
+ }, [t, r]);
40
+ }
41
+ //#endregion
42
+ export { i as FILES_WARMUP_COOLDOWN_MS, c as useFilesServiceWarmup, s as warmFilesService };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/fe-libs",
3
- "version": "2026.813.2",
3
+ "version": "2026.813.3",
4
4
  "description": "Burdenoff frontend primitives and domain libraries",
5
5
  "type": "module",
6
6
  "bin": {
@@ -284,7 +284,7 @@
284
284
  "lint:sanity": "if [ \"${CI:-}\" = \"true\" ]; then bun run lint; else bun run lint -- --cache --cache-location .eslintcache; fi",
285
285
  "format": "prettier --config shared/config/prettier.config.mjs --write \"src/**/*.{ts,tsx}\" \"!src/config/*.config.ts\"",
286
286
  "type:check": "bash scripts/build-limits.sh tsc --noEmit",
287
- "test": "bun test scripts/graphql-schema-drift/__tests__ src/credentials src/shared/providers/AppShellProvider.test.ts src/shared/events/sinks/RybbitSink.test.ts",
287
+ "test": "bun test scripts/graphql-schema-drift/__tests__ src/credentials src/storage src/shared/providers/AppShellProvider.test.ts src/shared/events/sinks/RybbitSink.test.ts",
288
288
  "sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run lint:sanity && bun run format && bun run type:check && bun run test && bun run build'",
289
289
  "switch:status": "bun scripts/deps-mode.ts status",
290
290
  "switch:remote": "bun scripts/deps-mode.ts remote",