@civitai/blocks-react 0.67.1 → 0.68.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.
@@ -1,4 +1,28 @@
1
1
  import type { SharedStorageValue } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * A shared-storage write the server REFUSED because a per-(viewer, app) budget
4
+ * is empty — thrown by `append`, `update`, `vote`, `unvote`, `withdraw` and
5
+ * `report` when the host's refusal carries retry metadata.
6
+ *
7
+ * It EXTENDS `Error`, the class every other shared-storage refusal is thrown
8
+ * as, so existing `catch` code keeps working unchanged: `.message` is still the
9
+ * host's refusal text (for example `Too many submissions — retry in 312s`).
10
+ * Branch on `instanceof SharedStorageRateLimitError` to render the wait from
11
+ * {@link SharedStorageRateLimitError.retryAt} instead of parsing the message.
12
+ *
13
+ * Not thrown when the refusal carries no metadata — a host that predates it, or
14
+ * a refusal that is not a rate limit. Those stay a plain `Error`.
15
+ */
16
+ export declare class SharedStorageRateLimitError extends Error {
17
+ /** Whole seconds until a retry can succeed, as of when the server refused. At least 1. */
18
+ readonly retryAfterSeconds: number;
19
+ /** ISO-8601 instant at which a retry can succeed. Prefer this for a countdown. */
20
+ readonly retryAt: string;
21
+ constructor(message: string, rateLimit: {
22
+ retryAfterSeconds: number;
23
+ retryAt: string;
24
+ });
25
+ }
2
26
  /**
3
27
  * The value a viewer contributes to the SHARED store: a required `title`, an
4
28
  * optional long-form `body`, and an optional opaque app-owned `data` blob.
@@ -2,6 +2,50 @@ import { useMemo } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply, throwOnReplyError } from '../internal/replyError.js';
4
4
  import { sendTypedRequest } from '../transport/transport.js';
5
+ import { parseSharedStorageRateLimit } from '../transport/validate.js';
6
+ /**
7
+ * A shared-storage write the server REFUSED because a per-(viewer, app) budget
8
+ * is empty — thrown by `append`, `update`, `vote`, `unvote`, `withdraw` and
9
+ * `report` when the host's refusal carries retry metadata.
10
+ *
11
+ * It EXTENDS `Error`, the class every other shared-storage refusal is thrown
12
+ * as, so existing `catch` code keeps working unchanged: `.message` is still the
13
+ * host's refusal text (for example `Too many submissions — retry in 312s`).
14
+ * Branch on `instanceof SharedStorageRateLimitError` to render the wait from
15
+ * {@link SharedStorageRateLimitError.retryAt} instead of parsing the message.
16
+ *
17
+ * Not thrown when the refusal carries no metadata — a host that predates it, or
18
+ * a refusal that is not a rate limit. Those stay a plain `Error`.
19
+ */
20
+ export class SharedStorageRateLimitError extends Error {
21
+ /** Whole seconds until a retry can succeed, as of when the server refused. At least 1. */
22
+ retryAfterSeconds;
23
+ /** ISO-8601 instant at which a retry can succeed. Prefer this for a countdown. */
24
+ retryAt;
25
+ constructor(message, rateLimit) {
26
+ super(message);
27
+ this.name = 'SharedStorageRateLimitError';
28
+ this.retryAfterSeconds = rateLimit.retryAfterSeconds;
29
+ this.retryAt = rateLimit.retryAt;
30
+ }
31
+ }
32
+ /**
33
+ * Throw {@link SharedStorageRateLimitError} when a write reply is a refusal (a
34
+ * PRESENT `error`, the same test as `throwOnReplyError`) that carries valid
35
+ * `rateLimit`. Otherwise returns, and the caller's usual reply check throws the
36
+ * plain `Error` as before.
37
+ *
38
+ * Parses `rateLimit` rather than trusting it: the transport delivers it as the
39
+ * host sent it, so a malformed value is ignored here and the caller's plain
40
+ * `Error` is thrown instead.
41
+ */
42
+ function throwOnSharedRateLimit(result, fallback) {
43
+ if (result.error === undefined)
44
+ return;
45
+ const rateLimit = parseSharedStorageRateLimit(result.rateLimit);
46
+ if (rateLimit)
47
+ throw new SharedStorageRateLimitError(result.error || fallback, rateLimit);
48
+ }
5
49
  /**
6
50
  * App-scoped, append-only, community-votable SHARED datastore. Sibling of
7
51
  * {@link useAppStorage} (the per-viewer KV store) — same postMessage bridge,
@@ -15,6 +59,11 @@ import { sendTypedRequest } from '../transport/transport.js';
15
59
  * / `getCount(s)`) and a hard reject on mutations (`append`/`vote`/`unvote`/
16
60
  * `withdraw`).
17
61
  *
62
+ * Writes are budgeted per (viewer, app) server-side. A write refused for that
63
+ * reason rejects with {@link SharedStorageRateLimitError} (a subclass of the
64
+ * plain `Error` every other refusal is) carrying `retryAfterSeconds` and
65
+ * `retryAt`, whenever the host sends the metadata.
66
+ *
18
67
  * The hook is stable across renders — it returns the same object identity once
19
68
  * the transport singleton is created, so it's safe in `useEffect`/`useMemo`
20
69
  * dependency arrays.
@@ -71,6 +120,7 @@ export function useSharedStorage() {
71
120
  },
72
121
  async report(key, reason) {
73
122
  const result = await sendTypedRequest(transport, { type: 'SHARED_REPORT', payload: { key, reason } }, 'SHARED_REPORT_RESULT');
123
+ throwOnSharedRateLimit(result, 'shared report failed');
74
124
  throwOnFailedReply(result, 'shared report failed');
75
125
  },
76
126
  async getCount(key) {
@@ -85,25 +135,30 @@ export function useSharedStorage() {
85
135
  },
86
136
  async append(value) {
87
137
  const result = await sendTypedRequest(transport, { type: 'SHARED_APPEND', payload: { value } }, 'SHARED_APPEND_RESULT');
138
+ throwOnSharedRateLimit(result, 'shared append failed');
88
139
  throwOnReplyError(result, 'shared append failed');
89
140
  return { key: result.key };
90
141
  },
91
142
  async update(key, value) {
92
143
  const result = await sendTypedRequest(transport, { type: 'SHARED_UPDATE', payload: { key, value } }, 'SHARED_UPDATE_RESULT');
144
+ throwOnSharedRateLimit(result, 'shared update failed');
93
145
  throwOnFailedReply(result, 'shared update failed');
94
146
  },
95
147
  async vote(key) {
96
148
  const result = await sendTypedRequest(transport, { type: 'SHARED_VOTE', payload: { key } }, 'SHARED_VOTE_RESULT');
149
+ throwOnSharedRateLimit(result, 'shared vote failed');
97
150
  throwOnReplyError(result, 'shared vote failed');
98
151
  return result.count;
99
152
  },
100
153
  async unvote(key) {
101
154
  const result = await sendTypedRequest(transport, { type: 'SHARED_UNVOTE', payload: { key } }, 'SHARED_UNVOTE_RESULT');
155
+ throwOnSharedRateLimit(result, 'shared unvote failed');
102
156
  throwOnReplyError(result, 'shared unvote failed');
103
157
  return result.count;
104
158
  },
105
159
  async withdraw(key) {
106
160
  const result = await sendTypedRequest(transport, { type: 'SHARED_WITHDRAW', payload: { key } }, 'SHARED_WITHDRAW_RESULT');
161
+ throwOnSharedRateLimit(result, 'shared withdraw failed');
107
162
  throwOnFailedReply(result, 'shared withdraw failed');
108
163
  // `deleted` is optional on the wire type because an error reply omits
109
164
  // it. On the success path the validator requires a boolean, so this is
package/dist/index.d.ts CHANGED
@@ -27,6 +27,8 @@ export { useHostOrigin } from './hooks/useHostOrigin.js';
27
27
  export type { UseHostOrigin } from './hooks/useHostOrigin.js';
28
28
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
29
29
  export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
30
+ export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
31
+ export type { BatchEstimateCell, BatchEstimateErrorCode, BatchEstimateResult, BlockEstimateBatchAggregate, EstimateBatchOptions, UseBatchEstimate, } from './hooks/useBatchEstimate.js';
30
32
  export { useEntitlements } from './hooks/useEntitlements.js';
31
33
  export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
32
34
  export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
@@ -74,8 +76,8 @@ export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRun
74
76
  export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, } from '@civitai/app-sdk/blocks';
75
77
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
76
78
  export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
77
- export { useResourcePicker } from './hooks/useResourcePicker.js';
78
- export type { UseResourcePicker } from './hooks/useResourcePicker.js';
79
+ export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
80
+ export type { UseResourcePicker, ResourcePickerOpenOptions, ResourcePickerMultiple, } from './hooks/useResourcePicker.js';
79
81
  export { useImageUpload } from './hooks/useImageUpload.js';
80
82
  export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
81
83
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
@@ -108,6 +110,6 @@ export { useNestedDocument } from './hooks/useNestedDocument.js';
108
110
  export type { NestedDocumentStatus, UseNestedDocument, UseNestedDocumentOptions, } from './hooks/useNestedDocument.js';
109
111
  export { useAppStorage } from './hooks/useAppStorage.js';
110
112
  export type { AppStorageKeyEntry, AppStorageListResult, AppStorageQuota, UseAppStorage, } from './hooks/useAppStorage.js';
111
- export { useSharedStorage } from './hooks/useSharedStorage.js';
113
+ export { useSharedStorage, SharedStorageRateLimitError } from './hooks/useSharedStorage.js';
112
114
  export type { SharedAppendValue, SharedListItem, SharedListResult, UseSharedStorage, } from './hooks/useSharedStorage.js';
113
115
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ export { useBlockSettings } from './hooks/useBlockSettings.js';
31
31
  export { useBlockToken } from './hooks/useBlockToken.js';
32
32
  export { useHostOrigin } from './hooks/useHostOrigin.js';
33
33
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
34
+ export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
34
35
  export { useEntitlements } from './hooks/useEntitlements.js';
35
36
  export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
36
37
  export { useTip } from './hooks/useTip.js';
@@ -62,7 +63,7 @@ export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreat
62
63
  export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
63
64
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
64
65
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
65
- export { useResourcePicker } from './hooks/useResourcePicker.js';
66
+ export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
66
67
  export { useImageUpload } from './hooks/useImageUpload.js';
67
68
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
68
69
  export { useGenerationResources } from './hooks/useGenerationResources.js';
@@ -84,5 +85,5 @@ export { hostToRunUrl, DIRECT_LOAD_TIMEOUT_MS } from './transport/directLoad.js'
84
85
  // `@civitai/app-sdk`'s `src/blocks/nestedDocument.ts` header.
85
86
  export { useNestedDocument } from './hooks/useNestedDocument.js';
86
87
  export { useAppStorage } from './hooks/useAppStorage.js';
87
- export { useSharedStorage } from './hooks/useSharedStorage.js';
88
+ export { useSharedStorage, SharedStorageRateLimitError } from './hooks/useSharedStorage.js';
88
89
  //# sourceMappingURL=index.js.map
@@ -46,8 +46,8 @@
46
46
  * --- BEGIN DERIVED ---
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
- * tRPC procedures ....... 28 = blocks.* (13) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 47, of which 10 REFUSE (enumerated under SCOPE below)
49
+ * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
+ * switch case labels .... 48, of which 10 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -83,6 +83,12 @@
83
83
  * the switch's `default: return` with no reply at all, so `useSharedStorage()`
84
84
  * hung to the 30s protocol timeout in dev:live for code that worked in dev:mock.
85
85
  *
86
+ * TRACK_EVENT: recorded only in production, so the live host forwards nothing
87
+ * and replies nothing (production replies nothing either). What it does is
88
+ * CHECK each event against the manifest passed as `options.manifest` and warn
89
+ * once per distinct problem production would silently drop — see
90
+ * `trackEventCheck.ts`.
91
+ *
86
92
  * 🔴 SCOPE — the capabilities live mode still cannot SERVE. Each REFUSES on its
87
93
  * own reply channel — `logOnce` (or `console.info`) plus an honest error, never
88
94
  * a fabricated success and never silence. Two guards stand behind this list:
@@ -186,6 +192,25 @@ export interface LiveHostOptions {
186
192
  * `dev:live` run this is omitted — the dev clicks a card in the overlay.
187
193
  */
188
194
  onPickerReady?: (handle: PickerOverlayHandle) => void;
195
+ /**
196
+ * The app's manifest — pass `block.manifest.json` as the harness imported it.
197
+ * Used for one thing: each `TRACK_EVENT` is checked against its
198
+ * `analytics.events` declaration, and the console warns ONCE per distinct
199
+ * problem production would silently drop — an undeclared event, an
200
+ * undeclared property, a value that does not match the declared type or enum.
201
+ * A declared, valid event logs nothing. The check never throws, never blocks
202
+ * the block, and sends no reply (production sends none either). Omit it and
203
+ * the first `TRACK_EVENT` says the check is off.
204
+ *
205
+ * Typed `unknown` because an imported JSON file widens its string literals
206
+ * (`"type": "enum"` reads as `string`), which a stricter type would reject.
207
+ *
208
+ * @example
209
+ * // `manifest` = your block.manifest.json, imported as JSON — every
210
+ * // `starters/examples/*` harness does this.
211
+ * createLiveHost({ blockToken, manifest });
212
+ */
213
+ manifest?: unknown;
189
214
  }
190
215
  /**
191
216
  * The reply both training bridges get in dev:live — see their handlers. One
@@ -46,8 +46,8 @@
46
46
  * --- BEGIN DERIVED ---
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
- * tRPC procedures ....... 28 = blocks.* (13) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 47, of which 10 REFUSE (enumerated under SCOPE below)
49
+ * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
+ * switch case labels .... 48, of which 10 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -83,6 +83,12 @@
83
83
  * the switch's `default: return` with no reply at all, so `useSharedStorage()`
84
84
  * hung to the 30s protocol timeout in dev:live for code that worked in dev:mock.
85
85
  *
86
+ * TRACK_EVENT: recorded only in production, so the live host forwards nothing
87
+ * and replies nothing (production replies nothing either). What it does is
88
+ * CHECK each event against the manifest passed as `options.manifest` and warn
89
+ * once per distinct problem production would silently drop — see
90
+ * `trackEventCheck.ts`.
91
+ *
86
92
  * 🔴 SCOPE — the capabilities live mode still cannot SERVE. Each REFUSES on its
87
93
  * own reply channel — `logOnce` (or `console.info`) plus an honest error, never
88
94
  * a fabricated success and never silence. Two guards stand behind this list:
@@ -129,6 +135,8 @@
129
135
  import { consentUnavailablePayload, resolveUngrantableConsentNotice } from './consent.js';
130
136
  import { hostContextWithTheme } from '../transport/transport.js';
131
137
  import { isRoutableRequestId } from '../transport/requestId.js';
138
+ import { parseSharedStorageRateLimit } from '../transport/validate.js';
139
+ import { createTrackEventChecker } from './trackEventCheck.js';
132
140
  import { openPickerOverlay, } from './pickerOverlay.js';
133
141
  /** Default civitai backend the live host forwards to. */
134
142
  const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
@@ -225,6 +233,9 @@ function errorSnapshot(error) {
225
233
  * deploy (the dev token is fine; the pod just hasn't loaded the verifier yet) —
226
234
  * the exact blip the round-5 dogfood hit. Anything ≥ 500, plus 408 (timeout)
227
235
  * and 429 (rate-limit), is also transient.
236
+ *
237
+ * Only {@link pollWithRetry} reads `transient`; a shared-storage 429 is a
238
+ * write-budget refusal whose wait is minutes, so it must never be retried.
228
239
  */
229
240
  function isTransientHttpStatus(status) {
230
241
  return status >= 500 || status === 408 || status === 429 || status === 401;
@@ -323,6 +334,8 @@ export function createLiveHost(options) {
323
334
  'likely minted from an OAuth login. Real generation needs a full-scope personal API key: ' +
324
335
  '`civitai login --token <key>` (https://civitai.com/user/account), then re-mint the dev token.');
325
336
  }
337
+ // `TRACK_EVENT` vs the manifest's `analytics.events`; dedupes its own warnings.
338
+ const trackEventChecker = createTrackEventChecker(options.manifest);
326
339
  // One-time "not supported in live v1" log gating per capability.
327
340
  const loggedOnce = new Set();
328
341
  const logOnce = (key, message) => {
@@ -377,7 +390,12 @@ export function createLiveHost(options) {
377
390
  }
378
391
  if (!res.ok) {
379
392
  const msg = extractTrpcError(parsed) ?? `${procedure} failed: HTTP ${res.status}`;
380
- return { error: msg, transient: isTransientHttpStatus(res.status) };
393
+ const rateLimit = parseSharedStorageRateLimit(extractTrpcErrorData(parsed)?.rateLimit);
394
+ return {
395
+ error: msg,
396
+ transient: isTransientHttpStatus(res.status),
397
+ ...(rateLimit ? { rateLimit } : {}),
398
+ };
381
399
  }
382
400
  return { parsed };
383
401
  }
@@ -411,8 +429,13 @@ export function createLiveHost(options) {
411
429
  */
412
430
  async function callTrpcData(procedure, input, method) {
413
431
  const raw = await rawTrpcCall(procedure, input, method);
414
- if (raw.error !== undefined)
415
- return { error: raw.error, transient: raw.transient };
432
+ if (raw.error !== undefined) {
433
+ return {
434
+ error: raw.error,
435
+ transient: raw.transient,
436
+ ...(raw.rateLimit ? { rateLimit: raw.rateLimit } : {}),
437
+ };
438
+ }
416
439
  const data = extractData(raw.parsed);
417
440
  if (data === undefined) {
418
441
  return { error: `${procedure}: malformed response (no data)` };
@@ -659,16 +682,32 @@ export function createLiveHost(options) {
659
682
  fetchImpl,
660
683
  ...(params.baseModelGroup != null ? { baseModelGroup: params.baseModelGroup } : {}),
661
684
  ...(params.currentVersionId != null ? { currentVersionId: params.currentVersionId } : {}),
685
+ ...(params.multiple ? { multiple: params.multiple } : {}),
662
686
  ...(win.document ? { document: win.document } : {}),
663
687
  ...(options.onPickerReady ? { onReady: options.onPickerReady } : {}),
664
688
  onResolve: (selection) => {
665
689
  openOverlays.delete(handle);
666
690
  if (torn)
667
691
  return;
692
+ if (params.multiple) {
693
+ // MULTI-select: the reply shape follows the REQUEST — always a list,
694
+ // `[]` on dismissal, and never `selected` (the production contract).
695
+ dispatchToBlock({
696
+ type: resultType,
697
+ payload: {
698
+ requestId,
699
+ selectedResources: selection && 'selectedResources' in selection ? selection.selectedResources : [],
700
+ },
701
+ });
702
+ return;
703
+ }
668
704
  dispatchToBlock({
669
705
  type: resultType,
670
706
  // A dismissal omits `selected` (the hooks resolve to undefined/null).
671
- payload: { requestId, ...(selection ? { selected: selection.selected } : {}) },
707
+ payload: {
708
+ requestId,
709
+ ...(selection && 'selected' in selection ? { selected: selection.selected } : {}),
710
+ },
672
711
  });
673
712
  },
674
713
  });
@@ -723,6 +762,27 @@ export function createLiveHost(options) {
723
762
  });
724
763
  return;
725
764
  }
765
+ case 'ESTIMATE_WORKFLOW_BATCH': {
766
+ // Forwarded to the token-bound `blocks.estimateWorkflowBatch`, the
767
+ // batch twin of the estimate above. Its reply is a plain
768
+ // `{ snapshots, aggregate }` (unwrapped with `callTrpcData`); a
769
+ // whole-call failure is FREE-TEXT `error`, a per-cell failure rides in
770
+ // `snapshots`. Estimate only — nothing is submitted. Unroutable
771
+ // without a requestId. Against a civitai that predates the procedure
772
+ // the server's own not-found error comes back here as `error`.
773
+ if (!isRoutableRequestId(requestId))
774
+ return;
775
+ void callTrpcData('blocks.estimateWorkflowBatch', { blockToken: rawToken, bodies: typed.payload?.bodies }, 'POST').then((r) => {
776
+ const data = r.data;
777
+ dispatchToBlock({
778
+ type: 'ESTIMATE_BATCH_RESULT',
779
+ payload: data && Array.isArray(data.snapshots) && data.aggregate
780
+ ? { requestId, snapshots: data.snapshots, aggregate: data.aggregate }
781
+ : { requestId, error: r.error ?? 'batch estimate failed' },
782
+ });
783
+ });
784
+ return;
785
+ }
726
786
  case 'SUBMIT_WORKFLOW': {
727
787
  const body = typed.payload?.body;
728
788
  // 🔴 KNOWN GAP, DELIBERATELY NOT CHANGED HERE — see below before
@@ -1158,11 +1218,37 @@ export function createLiveHost(options) {
1158
1218
  // (or no `selected` on dismissal). DISCOVERY ONLY — re-priced +
1159
1219
  // re-validated server-side at estimate/submit.
1160
1220
  const resourceType = typed.payload?.resourceType === 'Checkpoint' ? 'Checkpoint' : 'LORA';
1221
+ // MULTI-select (`multiple: { max }`) — the production host's rules:
1222
+ // LoRA only, `max` a whole number ≥ 1 clamped to 5, and a refusal is
1223
+ // ANSWERED with `error` (the hook throws on it) rather than opening
1224
+ // a single-pick overlay the block did not ask for.
1225
+ const rawMultiple = typed.payload?.multiple;
1226
+ let multiple;
1227
+ if (rawMultiple !== undefined && rawMultiple !== null) {
1228
+ const max = typeof rawMultiple === 'object' ? rawMultiple.max : undefined;
1229
+ const refusal = resourceType === 'Checkpoint'
1230
+ ? 'OPEN_RESOURCE_PICKER: multiple is only supported for LoRA-family resource types, not Checkpoint.'
1231
+ : typeof max !== 'number' || !Number.isInteger(max) || max < 1
1232
+ ? 'OPEN_RESOURCE_PICKER: multiple.max must be a whole number of at least 1.'
1233
+ : null;
1234
+ if (refusal !== null) {
1235
+ // A refusal nobody can route is not worth sending.
1236
+ if (!isRoutableRequestId(requestId))
1237
+ return;
1238
+ dispatchToBlock({
1239
+ type: 'RESOURCE_PICKER_RESULT',
1240
+ payload: { requestId, error: refusal },
1241
+ });
1242
+ return;
1243
+ }
1244
+ multiple = { max: Math.min(max, 5) };
1245
+ }
1161
1246
  openPicker({
1162
1247
  type: resourceType,
1163
1248
  ...(typeof typed.payload?.baseModelGroup === 'string'
1164
1249
  ? { baseModelGroup: typed.payload.baseModelGroup }
1165
1250
  : {}),
1251
+ ...(multiple ? { multiple } : {}),
1166
1252
  }, 'RESOURCE_PICKER_RESULT', requestId ?? '');
1167
1253
  return;
1168
1254
  }
@@ -1523,7 +1609,7 @@ export function createLiveHost(options) {
1523
1609
  if (r.error) {
1524
1610
  dispatchToBlock({
1525
1611
  type: 'SHARED_APPEND_RESULT',
1526
- payload: { requestId: requestId ?? '', key: '', error: r.error },
1612
+ payload: { requestId: requestId ?? '', key: '', ...sharedRefusal(r) },
1527
1613
  });
1528
1614
  return;
1529
1615
  }
@@ -1542,7 +1628,7 @@ export function createLiveHost(options) {
1542
1628
  dispatchToBlock({
1543
1629
  type: 'SHARED_UPDATE_RESULT',
1544
1630
  payload: r.error
1545
- ? { requestId: requestId ?? '', ok: false, error: r.error }
1631
+ ? { requestId: requestId ?? '', ok: false, ...sharedRefusal(r) }
1546
1632
  : { requestId: requestId ?? '', ok: true },
1547
1633
  });
1548
1634
  });
@@ -1554,7 +1640,7 @@ export function createLiveHost(options) {
1554
1640
  if (r.error) {
1555
1641
  dispatchToBlock({
1556
1642
  type: 'SHARED_VOTE_RESULT',
1557
- payload: { requestId: requestId ?? '', count: 0, error: r.error },
1643
+ payload: { requestId: requestId ?? '', count: 0, ...sharedRefusal(r) },
1558
1644
  });
1559
1645
  return;
1560
1646
  }
@@ -1572,7 +1658,7 @@ export function createLiveHost(options) {
1572
1658
  if (r.error) {
1573
1659
  dispatchToBlock({
1574
1660
  type: 'SHARED_UNVOTE_RESULT',
1575
- payload: { requestId: requestId ?? '', count: 0, error: r.error },
1661
+ payload: { requestId: requestId ?? '', count: 0, ...sharedRefusal(r) },
1576
1662
  });
1577
1663
  return;
1578
1664
  }
@@ -1594,7 +1680,7 @@ export function createLiveHost(options) {
1594
1680
  requestId: requestId ?? '',
1595
1681
  ok: false,
1596
1682
  deleted: false,
1597
- error: r.error,
1683
+ ...sharedRefusal(r),
1598
1684
  },
1599
1685
  });
1600
1686
  return;
@@ -1632,7 +1718,7 @@ export function createLiveHost(options) {
1632
1718
  dispatchToBlock({
1633
1719
  type: 'SHARED_REPORT_RESULT',
1634
1720
  payload: r.error
1635
- ? { requestId: requestId ?? '', ok: false, error: r.error }
1721
+ ? { requestId: requestId ?? '', ok: false, ...sharedRefusal(r) }
1636
1722
  : { requestId: requestId ?? '', ok: true },
1637
1723
  });
1638
1724
  });
@@ -1865,9 +1951,19 @@ export function createLiveHost(options) {
1865
1951
  return;
1866
1952
  }
1867
1953
  case 'TRACK_EVENT':
1954
+ // Fire-and-forget, no reply — as in production, where the host
1955
+ // forwards it and the ingest keeps only what the manifest's
1956
+ // `analytics.events` declares. Nothing is sent anywhere from here;
1957
+ // the only effect is a once-per-problem console warning for what
1958
+ // production would drop (see `trackEventCheck.ts`).
1959
+ for (const message of trackEventChecker.check(typed.payload)) {
1960
+ // eslint-disable-next-line no-console
1961
+ console.warn(`[createLiveHost] ${message}`);
1962
+ }
1963
+ return;
1868
1964
  case 'REQUEST_SIGN_IN':
1869
1965
  case 'BLOCK_ERROR':
1870
- // No-op. TRACK_EVENT/BLOCK_ERROR are fire-and-forget; REQUEST_SIGN_IN
1966
+ // No-op. BLOCK_ERROR is fire-and-forget; REQUEST_SIGN_IN
1871
1967
  // can't open a login in dev (the viewer comes from the token — a
1872
1968
  // failed viewer fetch already warns in resolveViewer).
1873
1969
  return;
@@ -2051,6 +2147,21 @@ function extractData(parsed) {
2051
2147
  * `{ error: { message } }`. Returns undefined when no message is found.
2052
2148
  */
2053
2149
  function extractTrpcError(parsed) {
2150
+ const message = trpcErrorShape(parsed)?.message;
2151
+ return typeof message === 'string' ? message : undefined;
2152
+ }
2153
+ /**
2154
+ * The `data` object of a tRPC error response (`{ error: { json: { data } } }`
2155
+ * or `{ error: { data } }`), or undefined. The platform's error formatter puts
2156
+ * machine-readable refusal fields here — `rateLimit` on a rate-limited
2157
+ * shared-storage write — which {@link extractTrpcError} alone dropped.
2158
+ */
2159
+ function extractTrpcErrorData(parsed) {
2160
+ const data = trpcErrorShape(parsed)?.data;
2161
+ return typeof data === 'object' && data !== null ? data : undefined;
2162
+ }
2163
+ /** The unwrapped tRPC error shape (superjson `{ json }` or plain), or undefined. */
2164
+ function trpcErrorShape(parsed) {
2054
2165
  if (typeof parsed !== 'object' || parsed === null)
2055
2166
  return undefined;
2056
2167
  const error = parsed.error;
@@ -2059,7 +2170,14 @@ function extractTrpcError(parsed) {
2059
2170
  const inner = 'json' in error ? error.json : error;
2060
2171
  if (typeof inner !== 'object' || inner === null)
2061
2172
  return undefined;
2062
- const message = inner.message;
2063
- return typeof message === 'string' ? message : undefined;
2173
+ return inner;
2174
+ }
2175
+ /**
2176
+ * The error fields of a shared-storage WRITE reply — `error`, plus `rateLimit`
2177
+ * only when the refusal carried it. The dev-host twin of the production host's
2178
+ * `sharedStorageErrorReply`, so the six write bridges build the refusal one way.
2179
+ */
2180
+ function sharedRefusal(r) {
2181
+ return { error: r.error ?? '', ...(r.rateLimit ? { rateLimit: r.rateLimit } : {}) };
2064
2182
  }
2065
2183
  //# sourceMappingURL=liveHost.js.map
@@ -93,6 +93,23 @@ export interface MockGenerationScenario {
93
93
  * a `(body) => number`. Overrides the legacy top-level `cost`. Default `8`.
94
94
  */
95
95
  costPerGen?: CostSpec;
96
+ /**
97
+ * The app author's per-generation fee, itemised as `cost.authorFee` on a
98
+ * QUOTE — the `ESTIMATE_RESULT` and the {@link submitCapRefusal} reply — and
99
+ * added into that quote's `cost.total` on top of {@link costPerGen}, as the
100
+ * real host does. A number, or a `(body) => number`. Default `0`, which is
101
+ * reported as `authorFee: 0` ("no fee on this request").
102
+ *
103
+ * Like the real host, only a `textToImage` or registered-`step` body is
104
+ * itemised; `customComfy`, pass-through `step` and `training` quotes carry no
105
+ * `authorFee`. The succeeded snapshot's `cost.total` stays {@link costPerGen}
106
+ * (a submitted workflow reports the generation's own cost), while a simulated
107
+ * {@link MockBuzzScenario.balance} is debited `costPerGen + authorFee`.
108
+ *
109
+ * `false` simulates a host that PREDATES the field: `authorFee` is omitted
110
+ * everywhere and no fee is added — use it to test your "not itemised" branch.
111
+ */
112
+ authorFee?: CostSpec | false;
96
113
  /**
97
114
  * Synthetic latency before the SUBMITTED→succeeded transition lands, in ms.
98
115
  * A single number, or a `[min, max]` range (uniform random). Applied to the
@@ -149,6 +166,35 @@ export interface MockGenerationScenario {
149
166
  * resource-compatibility rejection) to check how your block renders one.
150
167
  */
151
168
  failEstimateMessage?: string;
169
+ /**
170
+ * How the mock answers `ESTIMATE_WORKFLOW_BATCH` (`useBatchEstimate()`), so a
171
+ * block can exercise its per-cell fallback locally.
172
+ *
173
+ * - `'supported'` (default) — prices each cell exactly as an
174
+ * `ESTIMATE_WORKFLOW` of that body would be priced (`costPerGen`,
175
+ * `failEstimate` and the training rules all apply per cell) and replies with
176
+ * the snapshots in order plus the host's aggregate.
177
+ * - `'unsupported'` — replies `{ error: 'unsupported on this host' }`, the
178
+ * host's generic answer for a message it has no handler for.
179
+ * `estimateBatch()` rejects with `BatchEstimateError` code `'unsupported'`.
180
+ * - `'silent'` — never replies, which is what a host that PREDATES the message
181
+ * does. `estimateBatch()` rejects with code `'timeout'` once its wait ends;
182
+ * pass a short `timeoutMs` to it in a test.
183
+ *
184
+ * Live-tunable via `setScenario({ generation: { batchEstimate } })`.
185
+ */
186
+ batchEstimate?: 'supported' | 'unsupported' | 'silent';
187
+ /**
188
+ * Indices of batch cells the mock answers as NOT PRICED IN TIME — the snapshot
189
+ * the host returns for a cell when the call's time budget runs out before that
190
+ * cell priced: `{ workflowId:'failed', status:'failed', error:'estimate timed out' }`
191
+ * (the host's string, held once in `BATCH_ESTIMATE_CELL_TIMEOUT_ERROR`).
192
+ * The other cells price normally and the call still resolves, so this tests a
193
+ * PARTIAL grid: the cell is `ok: false`, `timedOut: true`, with
194
+ * `WorkflowEstimateError` code `'failed'`. Applies only with
195
+ * `batchEstimate: 'supported'`. Default: unset (no cell times out).
196
+ */
197
+ batchEstimateTimedOutCells?: number[];
152
198
  /**
153
199
  * Force every SUBMIT to come back as a caught server EXCEPTION — the host's
154
200
  * real `failureSnapshot(err)` shape: `{ workflowId:'failed', status:'failed',
@@ -424,6 +470,27 @@ export interface MockSharedScenario {
424
470
  * error UX.
425
471
  */
426
472
  failNext?: number;
473
+ /**
474
+ * Refuse the next N shared WRITES (`append`/`update`/`vote`/`unvote`/
475
+ * `withdraw`/`report`) as RATE-LIMITED (counts down), the way the platform
476
+ * does when a per-(viewer, app) budget is empty: the reply carries the
477
+ * server's refusal text plus `rateLimit` metadata, so the hook rejects with
478
+ * `SharedStorageRateLimitError`. Checked before {@link failNext}, as the
479
+ * server checks the budget before attempting the write. Live-tunable via
480
+ * `setScenario({ shared: { rateLimitNext } })`.
481
+ */
482
+ rateLimitNext?: number;
483
+ /**
484
+ * The metadata a {@link rateLimitNext} refusal carries. `retryAfterSeconds`
485
+ * defaults to 60; `retryAt` defaults to now + `retryAfterSeconds`, computed
486
+ * when each refusal is sent. Pass a malformed value to exercise a block's
487
+ * fallback: the hook parses it, ignores the malformed value and throws a plain
488
+ * `Error`.
489
+ */
490
+ rateLimit?: {
491
+ retryAfterSeconds?: number;
492
+ retryAt?: string;
493
+ };
427
494
  }
428
495
  /**
429
496
  * Drives `createMockHost`. Every field is optional with a sensible default so
@@ -477,6 +544,18 @@ export interface MockHostOptions {
477
544
  * for that type. Defaults to a curated Checkpoint + LoRA pick.
478
545
  */
479
546
  cannedPicks?: Partial<Record<BlockResourcePickerType, CannedPick | null>>;
547
+ /**
548
+ * What a MULTI-select `OPEN_RESOURCE_PICKER` (`multiple: { max }`, LoRA only)
549
+ * answers with: these picks, in this order, cut to the request's `max` (itself
550
+ * clamped to 5) — the real host's `selectedResources`. `null` or `[]`
551
+ * simulates a dismissed picker (→ an empty list). Defaults to two curated
552
+ * LoRAs, or to `[]` when `cannedPicks.LORA` is `null`, so one "the viewer
553
+ * dismissed" setting covers both pick modes.
554
+ *
555
+ * The mock applies the real host's refusals too: `multiple` with a Checkpoint,
556
+ * or a `max` that is not a whole number ≥ 1, is answered with an `error`.
557
+ */
558
+ cannedMultiPicks?: CannedPick[] | null;
480
559
  /**
481
560
  * The canned moderated image returned from `OPEN_IMAGE_UPLOAD` when the block
482
561
  * requests `purpose:'display'` (the default) — what `useImageUpload().open()`
@@ -1054,7 +1133,7 @@ export interface MockHostOptions {
1054
1133
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
1055
1134
  * the viewer toggling dark mode with the block already mounted.)
1056
1135
  */
1057
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1136
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1058
1137
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
1059
1138
  export interface MockBuzzHandle {
1060
1139
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -1099,7 +1178,7 @@ export declare function disallowedAccountError(accountType: BuzzAccountType): st
1099
1178
  /**
1100
1179
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
1101
1180
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
1102
- * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
1181
+ * Layer-1 additions: `?balance/?latency/?costPerGen/?authorFee/?failNext/?failRate/?seed`
1103
1182
  * map onto the new scenario groups so a dev can flip out-of-Buzz /
1104
1183
  * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
1105
1184
  * {@link MockGenerationScenario.submitCapRefusal}.