@civitai/blocks-react 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/hooks/useBuzzWorkflow.d.ts +133 -13
  2. package/dist/hooks/useBuzzWorkflow.d.ts.map +1 -1
  3. package/dist/hooks/useBuzzWorkflow.js +179 -18
  4. package/dist/hooks/useBuzzWorkflow.js.map +1 -1
  5. package/dist/hooks/useSaveImage.d.ts +47 -0
  6. package/dist/hooks/useSaveImage.d.ts.map +1 -0
  7. package/dist/hooks/useSaveImage.js +27 -0
  8. package/dist/hooks/useSaveImage.js.map +1 -0
  9. package/dist/hooks/useSharedStorage.d.ts +26 -25
  10. package/dist/hooks/useSharedStorage.d.ts.map +1 -1
  11. package/dist/hooks/useSharedStorage.js +31 -8
  12. package/dist/hooks/useSharedStorage.js.map +1 -1
  13. package/dist/hooks/useTip.d.ts +62 -0
  14. package/dist/hooks/useTip.d.ts.map +1 -0
  15. package/dist/hooks/useTip.js +97 -0
  16. package/dist/hooks/useTip.js.map +1 -0
  17. package/dist/hooks/useTipAllowance.d.ts +40 -0
  18. package/dist/hooks/useTipAllowance.d.ts.map +1 -0
  19. package/dist/hooks/useTipAllowance.js +90 -0
  20. package/dist/hooks/useTipAllowance.js.map +1 -0
  21. package/dist/index.d.ts +8 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal/iframeTransport.d.ts +29 -0
  26. package/dist/internal/iframeTransport.d.ts.map +1 -1
  27. package/dist/internal/iframeTransport.js +113 -1
  28. package/dist/internal/iframeTransport.js.map +1 -1
  29. package/dist/internal/mockHost.d.ts.map +1 -1
  30. package/dist/internal/mockHost.js +92 -3
  31. package/dist/internal/mockHost.js.map +1 -1
  32. package/dist/internal/transport.d.ts +9 -0
  33. package/dist/internal/transport.d.ts.map +1 -1
  34. package/dist/internal/transport.js +16 -0
  35. package/dist/internal/transport.js.map +1 -1
  36. package/dist/internal/validate.d.ts +20 -0
  37. package/dist/internal/validate.d.ts.map +1 -1
  38. package/dist/internal/validate.js +83 -0
  39. package/dist/internal/validate.js.map +1 -1
  40. package/package.json +5 -5
@@ -1,8 +1,121 @@
1
1
  import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * Default orchestrator-side hold per {@link UseBuzzWorkflowReturn.watch} poll,
4
+ * in SECONDS.
5
+ *
6
+ * 🔴 THE UNIT IS SECONDS, matching the orchestrator's `?wait=` parameter — not
7
+ * the milliseconds every other timing option in this file uses. Named
8
+ * `waitSeconds` everywhere for exactly that reason.
9
+ *
10
+ * 15 rather than something larger for two reasons, both of them ceilings this
11
+ * value must stay UNDER, not preferences:
12
+ * - civitai's shared `getWorkflow` helper aborts a single orchestrator read at
13
+ * 20s. A hold at or above that is cancelled by our own backstop before the
14
+ * orchestrator can answer.
15
+ * - the host's poll additionally runs an inline output-moderation scan with
16
+ * its own ~12s ceiling, and the two are SEQUENTIAL — so the round trip's
17
+ * worst case is 15 + 12 = 27s, which must stay inside the host's own
18
+ * end-to-end response budget.
19
+ */
20
+ export declare const DEFAULT_WATCH_WAIT_SECONDS = 15;
21
+ /** Optional controls for {@link UseBuzzWorkflowReturn.watch}. */
22
+ export interface WatchWorkflowOptions {
23
+ /**
24
+ * Called with EVERY snapshot the host returns, intermediate ones included, in
25
+ * order. This is the push side of the API: render from here instead of
26
+ * re-reading `result` on a timer.
27
+ *
28
+ * A throw from this callback is not caught — it rejects the `watch` promise.
29
+ */
30
+ onUpdate?: (snapshot: BlockWorkflowSnapshot) => void;
31
+ /**
32
+ * Stop watching. The promise RESOLVES with the last snapshot seen rather than
33
+ * rejecting: an abort is the caller's own decision, not a failure, and the
34
+ * common case (a component unmounting) has nobody left to catch a rejection.
35
+ *
36
+ * 🔴 This does NOT cancel the workflow — it stops watching it. Buzz is already
37
+ * spent and the orchestrator keeps running. To actually stop the work, call
38
+ * {@link UseBuzzWorkflowReturn.cancel}.
39
+ */
40
+ signal?: AbortSignal;
41
+ /**
42
+ * Orchestrator-side hold per poll, in **seconds**. Default
43
+ * {@link DEFAULT_WATCH_WAIT_SECONDS}. `0` disables long polling and falls back
44
+ * to a plain read per `intervalMs`.
45
+ *
46
+ * 🔴 CURRENTLY ADVISORY. It travels on the `POLL_WORKFLOW` message and a host
47
+ * that does not yet read the field simply answers immediately, exactly as
48
+ * today — so `watch` is correct either way, it just polls more often. See the
49
+ * field's note on `BlockToParentMessage`.
50
+ */
51
+ waitSeconds?: number;
52
+ /**
53
+ * Delay between polls, in ms. Default 1500.
54
+ *
55
+ * 🔴 NOT REDUNDANT WITH `waitSeconds`. When the host long-polls, the hold
56
+ * dominates and this is a few percent of overhead. When it does NOT — an
57
+ * older host, or `waitSeconds: 0` — this is the only thing standing between
58
+ * this loop and a request storm.
59
+ */
60
+ intervalMs?: number;
61
+ /** Give up and resolve with the last snapshot after this long. Default 10min. */
62
+ timeoutMs?: number;
63
+ /**
64
+ * Consecutive transport failures to absorb before rejecting. Default 3.
65
+ *
66
+ * A poll can fail for reasons that have nothing to do with the workflow (a
67
+ * pod rolling, a network blip). Because `watch` OWNS the loop, a single blip
68
+ * would otherwise end a generation the caller's own retry loop used to
69
+ * survive. The counter RESETS on any successful poll, so this bounds a burst,
70
+ * not a lifetime.
71
+ */
72
+ maxRetries?: number;
73
+ }
74
+ /** Optional per-submit controls. */
75
+ export interface SubmitWorkflowOptions {
76
+ /**
77
+ * A STABLE idempotency key for this logical submit. Reuse the SAME value when
78
+ * RETRYING a submit whose response was lost (timeout / network drop) so the
79
+ * host+orchestrator collapse it to ONE Buzz charge instead of double-charging.
80
+ * Omit → the hook generates a fresh key per `submit()` call (each call is a new
81
+ * logical submit); pass a stable id (e.g. a grid-cell id) to make a retry safe.
82
+ */
83
+ idempotencyKey?: string;
84
+ }
2
85
  interface UseBuzzWorkflowReturn {
3
86
  estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
4
- submit: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
87
+ submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
88
+ /**
89
+ * ONE host round-trip. The low-level pull primitive — you almost certainly
90
+ * want {@link UseBuzzWorkflowReturn.watch} instead, which owns the loop.
91
+ */
5
92
  poll: (workflowId: string) => Promise<BlockWorkflowSnapshot>;
93
+ /**
94
+ * Watch a workflow to completion. Resolves with the TERMINAL snapshot; calls
95
+ * `onUpdate` with every intermediate snapshot along the way.
96
+ *
97
+ * This is the replacement for the `useEffect` + `setTimeout` backoff every
98
+ * block used to hand-write around {@link UseBuzzWorkflowReturn.poll}. The app
99
+ * consumes a promise and/or a callback; the loop lives here.
100
+ *
101
+ * 🔴 THE LOOP IS SEQUENTIAL AND NON-OVERLAPPING BY CONSTRUCTION — each poll is
102
+ * awaited before the next is scheduled, so exactly one request per watched
103
+ * workflow is ever in flight. That is not tidiness: it is the property that
104
+ * makes a long hold SAFE. A caller-written `setInterval(poll, 2000)` against
105
+ * a host holding 15s would stack ~7 concurrent requests per workflow, and
106
+ * that is precisely why long polling is opt-in on the wire rather than
107
+ * switched on for every deployed block.
108
+ *
109
+ * @example
110
+ * const { submit, watch, cancel } = useBuzzWorkflow();
111
+ * const submitted = await submit(body);
112
+ * const done = await watch(submitted.workflowId, {
113
+ * onUpdate: (snap) => setProgress(snap.status),
114
+ * signal: abortRef.current.signal,
115
+ * });
116
+ * if (done.status === 'succeeded') setImages(done.imageUrls ?? []);
117
+ */
118
+ watch: (workflowId: string, options?: WatchWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
6
119
  /**
7
120
  * Cancel a running workflow on the orchestrator (a real server-side stop,
8
121
  * not just client-side untracking). The host re-derives ownership from the
@@ -24,22 +137,29 @@ interface UseBuzzWorkflowReturn {
24
137
  * refuses. Block apps should call `useBuzzPurchase().openPurchaseModal()`
25
138
  * when that happens.
26
139
  *
27
- * The hook does NOT auto-poll: after `submit` flips `status` to `'polling'`,
28
- * the caller runs a `useEffect` that calls `poll(workflowId)` on a backoff
29
- * until the snapshot is terminal. `status === 'confirming'` is IDLE (estimate
30
- * landed, user reviewing cost) — keep the Generate button enabled.
140
+ * AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns
141
+ * the loop, resolves on the terminal snapshot, and pushes every intermediate
142
+ * one to an `onUpdate` callback — so a block consumes a promise/callback rather
143
+ * than running its own timer. `poll(workflowId)` remains the single-round-trip
144
+ * primitive for callers that genuinely want to drive their own cadence; the
145
+ * hand-written `useEffect` + backoff around it that this docstring used to
146
+ * prescribe is no longer the recommended shape. `status === 'confirming'` is
147
+ * IDLE (estimate landed, user reviewing cost) — keep the Generate button
148
+ * enabled.
31
149
  *
32
150
  * `estimate`/`submit` take a full {@link WorkflowBody} — the discriminated
33
- * union keyed by `kind`, so either a `textToImage` body (`{ kind, modelId,
34
- * modelVersionId, params }`) or a `customComfy` recipe body (`{ kind, recipe,
35
- * params }`), not a bare `{ prompt }`. The hook forwards the body to the host
36
- * verbatim and never reads variant-specific fields, so both members flow
37
- * through unchanged.
151
+ * union keyed by `kind`, with THREE members as of `@civitai/app-sdk@0.30.0`:
152
+ * a `textToImage` body (`{ kind, modelId, modelVersionId, params }`), a
153
+ * `customComfy` recipe body (`{ kind, recipe, params }`), or a `step` body
154
+ * (`{ kind: 'step', step, params }` — a server-registered orchestrator step
155
+ * such as `'chat-completion'`), never a bare `{ prompt }`. The hook forwards
156
+ * the body to the host verbatim and never reads variant-specific fields, so
157
+ * every member flows through unchanged, including any member added later.
38
158
  *
39
- * @returns `{ estimate, submit, poll, cancel, status, result, error }`.
159
+ * @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
40
160
  *
41
161
  * @example
42
- * const { estimate, submit, poll, status, result } = useBuzzWorkflow();
162
+ * const { estimate, submit, watch, status, result } = useBuzzWorkflow();
43
163
  * const body = {
44
164
  * kind: 'textToImage' as const,
45
165
  * modelId,
@@ -48,7 +168,7 @@ interface UseBuzzWorkflowReturn {
48
168
  * };
49
169
  * await estimate(body); // status 'estimating' → 'confirming' (cost in result.cost.total)
50
170
  * const snap = await submit(body); // status 'submitting' → 'polling'; returns a workflowId
51
- * await poll(snap.workflowId); // caller loops this on a backoff until terminal
171
+ * const done = await watch(snap.workflowId, { onUpdate: render }); // → terminal
52
172
  */
53
173
  export declare function useBuzzWorkflow(): UseBuzzWorkflowReturn;
54
174
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"useBuzzWorkflow.d.ts","sourceRoot":"","sources":["../../src/hooks/useBuzzWorkflow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AA8BnG,UAAU,qBAAqB;IAC7B,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACjE,MAAM,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC/D,IAAI,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7D;;;;;;OAMG;IACH,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC/D,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACrC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,eAAe,IAAI,qBAAqB,CAuFvD"}
1
+ {"version":3,"file":"useBuzzWorkflow.d.ts","sourceRoot":"","sources":["../../src/hooks/useBuzzWorkflow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AA8BnG;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,0BAA0B,KAAK,CAAC;AAW7C,iEAAiE;AACjE,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,qBAAqB,KAAK,IAAI,CAAC;IACrD;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAyBD,oCAAoC;AACpC,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,UAAU,qBAAqB;IAC7B,QAAQ,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACjE,MAAM,EAAE,CACN,IAAI,EAAE,YAAY,EAClB,OAAO,CAAC,EAAE,qBAAqB,KAC5B,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACpC;;;OAGG;IACH,IAAI,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7D;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,KAAK,EAAE,CACL,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,oBAAoB,KAC3B,OAAO,CAAC,qBAAqB,CAAC,CAAC;IACpC;;;;;;OAMG;IACH,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC/D,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACrC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAgB,eAAe,IAAI,qBAAqB,CA+MvD"}
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useState } from 'react';
2
2
  import { getTransport } from '../internal/singleton.js';
3
- import { sendTypedRequest } from '../internal/transport.js';
3
+ import { generateIdempotencyKey, sendTypedRequest } from '../internal/transport.js';
4
4
  /**
5
5
  * Snapshot statuses that mean "no further polling is needed."
6
6
  * Used by both `submit` (a host can return an instant-fail / cached result)
@@ -24,6 +24,54 @@ const TERMINAL_STATUSES = new Set([
24
24
  * a spurious `request "SUBMIT_WORKFLOW" timed out` rejection.
25
25
  */
26
26
  const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
27
+ /**
28
+ * Default orchestrator-side hold per {@link UseBuzzWorkflowReturn.watch} poll,
29
+ * in SECONDS.
30
+ *
31
+ * 🔴 THE UNIT IS SECONDS, matching the orchestrator's `?wait=` parameter — not
32
+ * the milliseconds every other timing option in this file uses. Named
33
+ * `waitSeconds` everywhere for exactly that reason.
34
+ *
35
+ * 15 rather than something larger for two reasons, both of them ceilings this
36
+ * value must stay UNDER, not preferences:
37
+ * - civitai's shared `getWorkflow` helper aborts a single orchestrator read at
38
+ * 20s. A hold at or above that is cancelled by our own backstop before the
39
+ * orchestrator can answer.
40
+ * - the host's poll additionally runs an inline output-moderation scan with
41
+ * its own ~12s ceiling, and the two are SEQUENTIAL — so the round trip's
42
+ * worst case is 15 + 12 = 27s, which must stay inside the host's own
43
+ * end-to-end response budget.
44
+ */
45
+ export const DEFAULT_WATCH_WAIT_SECONDS = 15;
46
+ /** Gap between `watch` polls, in ms. See {@link WatchWorkflowOptions.intervalMs}. */
47
+ const DEFAULT_WATCH_INTERVAL_MS = 1_500;
48
+ /** Total `watch` budget, in ms. Generation can legitimately take minutes. */
49
+ const DEFAULT_WATCH_TIMEOUT_MS = 10 * 60_000;
50
+ /** How many CONSECUTIVE transport failures `watch` absorbs before rejecting. */
51
+ const DEFAULT_WATCH_MAX_RETRIES = 3;
52
+ /**
53
+ * Sleep, waking EARLY if `signal` aborts.
54
+ *
55
+ * 🔴 A PLAIN `setTimeout` WOULD MAKE ABORT LATENCY EQUAL THE POLL INTERVAL. A
56
+ * component unmounting mid-gap would keep a pending timer alive for up to
57
+ * `intervalMs` and only then notice, which is exactly the "unmounted component
58
+ * kept working" shape an abort signal exists to prevent. The listener is always
59
+ * removed, so an abort long after the sleep resolved cannot fire into a
60
+ * finished loop.
61
+ */
62
+ function sleep(ms, signal) {
63
+ if (signal?.aborted)
64
+ return Promise.resolve();
65
+ return new Promise((resolve) => {
66
+ const done = () => {
67
+ clearTimeout(timer);
68
+ signal?.removeEventListener('abort', done);
69
+ resolve();
70
+ };
71
+ const timer = setTimeout(done, ms);
72
+ signal?.addEventListener('abort', done);
73
+ });
74
+ }
27
75
  /**
28
76
  * Orchestrates the estimate → confirm → submit → poll dance through the
29
77
  * host-mediated `postMessage` path.
@@ -33,22 +81,29 @@ const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
33
81
  * refuses. Block apps should call `useBuzzPurchase().openPurchaseModal()`
34
82
  * when that happens.
35
83
  *
36
- * The hook does NOT auto-poll: after `submit` flips `status` to `'polling'`,
37
- * the caller runs a `useEffect` that calls `poll(workflowId)` on a backoff
38
- * until the snapshot is terminal. `status === 'confirming'` is IDLE (estimate
39
- * landed, user reviewing cost) — keep the Generate button enabled.
84
+ * AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns
85
+ * the loop, resolves on the terminal snapshot, and pushes every intermediate
86
+ * one to an `onUpdate` callback — so a block consumes a promise/callback rather
87
+ * than running its own timer. `poll(workflowId)` remains the single-round-trip
88
+ * primitive for callers that genuinely want to drive their own cadence; the
89
+ * hand-written `useEffect` + backoff around it that this docstring used to
90
+ * prescribe is no longer the recommended shape. `status === 'confirming'` is
91
+ * IDLE (estimate landed, user reviewing cost) — keep the Generate button
92
+ * enabled.
40
93
  *
41
94
  * `estimate`/`submit` take a full {@link WorkflowBody} — the discriminated
42
- * union keyed by `kind`, so either a `textToImage` body (`{ kind, modelId,
43
- * modelVersionId, params }`) or a `customComfy` recipe body (`{ kind, recipe,
44
- * params }`), not a bare `{ prompt }`. The hook forwards the body to the host
45
- * verbatim and never reads variant-specific fields, so both members flow
46
- * through unchanged.
95
+ * union keyed by `kind`, with THREE members as of `@civitai/app-sdk@0.30.0`:
96
+ * a `textToImage` body (`{ kind, modelId, modelVersionId, params }`), a
97
+ * `customComfy` recipe body (`{ kind, recipe, params }`), or a `step` body
98
+ * (`{ kind: 'step', step, params }` — a server-registered orchestrator step
99
+ * such as `'chat-completion'`), never a bare `{ prompt }`. The hook forwards
100
+ * the body to the host verbatim and never reads variant-specific fields, so
101
+ * every member flows through unchanged, including any member added later.
47
102
  *
48
- * @returns `{ estimate, submit, poll, cancel, status, result, error }`.
103
+ * @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
49
104
  *
50
105
  * @example
51
- * const { estimate, submit, poll, status, result } = useBuzzWorkflow();
106
+ * const { estimate, submit, watch, status, result } = useBuzzWorkflow();
52
107
  * const body = {
53
108
  * kind: 'textToImage' as const,
54
109
  * modelId,
@@ -57,7 +112,7 @@ const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
57
112
  * };
58
113
  * await estimate(body); // status 'estimating' → 'confirming' (cost in result.cost.total)
59
114
  * const snap = await submit(body); // status 'submitting' → 'polling'; returns a workflowId
60
- * await poll(snap.workflowId); // caller loops this on a backoff until terminal
115
+ * const done = await watch(snap.workflowId, { onUpdate: render }); // → terminal
61
116
  */
62
117
  export function useBuzzWorkflow() {
63
118
  const [status, setStatus] = useState('idle');
@@ -78,11 +133,14 @@ export function useBuzzWorkflow() {
78
133
  throw err;
79
134
  }
80
135
  }, []);
81
- const submit = useCallback(async (body) => {
136
+ const submit = useCallback(async (body, options) => {
82
137
  setError(null);
83
138
  setStatus('submitting');
139
+ // Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
140
+ // charge), or mint a fresh one per call (each call is a new logical submit).
141
+ const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
84
142
  try {
85
- const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
143
+ const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
86
144
  setResult(snapshot);
87
145
  setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
88
146
  return snapshot;
@@ -93,10 +151,45 @@ export function useBuzzWorkflow() {
93
151
  throw err;
94
152
  }
95
153
  }, []);
154
+ /**
155
+ * ONE poll round-trip, with an optional long-poll hint. The single place that
156
+ * builds a `POLL_WORKFLOW` message, so `poll` and `watch` cannot drift on the
157
+ * message shape or the request timeout.
158
+ */
159
+ const pollOnce = useCallback(async (workflowId, waitSeconds) => {
160
+ const { snapshot } = await sendTypedRequest(getTransport(), {
161
+ type: 'POLL_WORKFLOW',
162
+ payload: {
163
+ workflowId,
164
+ // Omitted rather than sent as 0 when long polling is off, so the
165
+ // message stays byte-identical to what pre-`watch` blocks send.
166
+ ...(waitSeconds !== undefined && waitSeconds > 0 ? { waitSeconds } : {}),
167
+ },
168
+ }, 'WORKFLOW_STATUS', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
169
+ // A reply without a usable snapshot is a failure, not a result: returning
170
+ // `undefined` would make `poll` resolve with a non-snapshot, and would
171
+ // make `watch` — whose stop condition is
172
+ // `TERMINAL_STATUSES.has(snapshot.status)` — loop to its timeout against a
173
+ // host that is answering with nothing. Throwing routes it into `watch`'s
174
+ // bounded retry instead.
175
+ //
176
+ // 🔴 NOT CLAIMED TO BE REACHABLE THROUGH `IframeTransport`, WHICH ALREADY
177
+ // FAIL-CLOSES THIS. Its `payloadValidatorFor('WORKFLOW_STATUS')`
178
+ // (internal/validate.ts) drops a reply whose `snapshot.status` is absent
179
+ // or outside the known set, so the request never resolves at all and
180
+ // times out instead. This guard covers the OTHER transports
181
+ // `sendTypedRequest` accepts (mock/test hosts, `dev:live`), and is kept as
182
+ // cheap defence — do not cite it as validated input handling on the
183
+ // iframe path.
184
+ if (!snapshot || typeof snapshot.status !== 'string') {
185
+ throw new Error(`POLL_WORKFLOW: malformed response (no snapshot) for ${workflowId}`);
186
+ }
187
+ return snapshot;
188
+ }, []);
96
189
  const poll = useCallback(async (workflowId) => {
97
190
  setStatus('polling');
98
191
  try {
99
- const { snapshot } = await sendTypedRequest(getTransport(), { type: 'POLL_WORKFLOW', payload: { workflowId } }, 'WORKFLOW_STATUS', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
192
+ const snapshot = await pollOnce(workflowId);
100
193
  setResult(snapshot);
101
194
  if (TERMINAL_STATUSES.has(snapshot.status)) {
102
195
  setStatus('done');
@@ -108,7 +201,75 @@ export function useBuzzWorkflow() {
108
201
  setStatus('error');
109
202
  throw err;
110
203
  }
111
- }, []);
204
+ }, [pollOnce]);
205
+ const watch = useCallback(async (workflowId, options) => {
206
+ const interval = options?.intervalMs ?? DEFAULT_WATCH_INTERVAL_MS;
207
+ const waitSeconds = options?.waitSeconds ?? DEFAULT_WATCH_WAIT_SECONDS;
208
+ const maxRetries = options?.maxRetries ?? DEFAULT_WATCH_MAX_RETRIES;
209
+ const deadline = Date.now() + (options?.timeoutMs ?? DEFAULT_WATCH_TIMEOUT_MS);
210
+ setError(null);
211
+ setStatus('polling');
212
+ let last = null;
213
+ let consecutiveFailures = 0;
214
+ // 🔴 SEQUENTIAL BY CONSTRUCTION. Every iteration AWAITS its poll before
215
+ // scheduling the next, so at most one request per watched workflow is
216
+ // ever in flight no matter how long the host holds it. A timer-driven
217
+ // caller cannot make that guarantee, which is why long polling is
218
+ // requested here and not enabled globally.
219
+ for (;;) {
220
+ if (options?.signal?.aborted)
221
+ break;
222
+ let snapshot;
223
+ try {
224
+ snapshot = await pollOnce(workflowId, waitSeconds);
225
+ consecutiveFailures = 0;
226
+ }
227
+ catch (err) {
228
+ // A blip is not the end of a generation. `watch` owns the loop, so a
229
+ // single transport failure would otherwise kill a run that a
230
+ // caller-written retry loop used to survive.
231
+ consecutiveFailures += 1;
232
+ if (consecutiveFailures > maxRetries || Date.now() >= deadline) {
233
+ setError(err);
234
+ setStatus('error');
235
+ throw err;
236
+ }
237
+ await sleep(interval, options?.signal);
238
+ continue;
239
+ }
240
+ last = snapshot;
241
+ setResult(snapshot);
242
+ // 🔴 `onUpdate` RECEIVES THE SNAPSHOT AS AN ARGUMENT AND MUST USE IT.
243
+ // `setResult` above is a React state update, so it is NOT visible to a
244
+ // callback reading the hook's `result` in this same tick — that reads
245
+ // the PREVIOUS render's value. The argument is the current one; the
246
+ // hook state catches up on the next render.
247
+ //
248
+ // A throw here propagates deliberately — swallowing a consumer's error
249
+ // would leave the loop spinning against a broken consumer.
250
+ options?.onUpdate?.(snapshot);
251
+ if (TERMINAL_STATUSES.has(snapshot.status)) {
252
+ setStatus('done');
253
+ return snapshot;
254
+ }
255
+ // Budget exhausted: resolve with what we have rather than throwing. A
256
+ // non-terminal result is a legitimate answer ("still running"), and the
257
+ // caller can tell the difference by reading `.status`.
258
+ if (Date.now() + interval >= deadline)
259
+ break;
260
+ await sleep(interval, options?.signal);
261
+ }
262
+ // Aborted or timed out with a snapshot in hand — resolve with it.
263
+ if (last)
264
+ return last;
265
+ // 🔴 NO SNAPSHOT AT ALL. Only reachable when the signal was ALREADY
266
+ // aborted on entry. Rejecting is the only honest option: there is nothing
267
+ // to resolve with, and issuing a poll here would make a request the
268
+ // caller explicitly asked us not to make.
269
+ const aborted = new Error(`watch(${workflowId}) aborted before any snapshot`);
270
+ aborted.name = 'AbortError';
271
+ throw aborted;
272
+ }, [pollOnce]);
112
273
  const cancel = useCallback(async (workflowId) => {
113
274
  try {
114
275
  const { snapshot } = await sendTypedRequest(getTransport(), { type: 'CANCEL_WORKFLOW', payload: { workflowId } }, 'WORKFLOW_CANCELED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
@@ -124,6 +285,6 @@ export function useBuzzWorkflow() {
124
285
  throw err;
125
286
  }
126
287
  }, []);
127
- return { estimate, submit, poll, cancel, status, result, error };
288
+ return { estimate, submit, poll, watch, cancel, status, result, error };
128
289
  }
129
290
  //# sourceMappingURL=useBuzzWorkflow.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useBuzzWorkflow.js","sourceRoot":"","sources":["../../src/hooks/useBuzzWorkflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAI9C,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D;;;;GAIG;AACH,MAAM,iBAAiB,GAAiD,IAAI,GAAG,CAAC;IAC9E,WAAW;IACX,QAAQ;IACR,UAAU;IACV,SAAS;CACV,CAAC,CAAC;AAEH;;;;;;;;;;GAUG;AACH,MAAM,2BAA2B,GAAG,OAAO,CAAC;AAmB5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,UAAU,eAAe;IAC7B,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAiB,MAAM,CAAC,CAAC;IAC7D,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAA+B,IAAI,CAAC,CAAC;IACzE,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAe,IAAI,CAAC,CAAC;IAEvD,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,EAAE,IAAkB,EAAE,EAAE;QACxD,QAAQ,CAAC,IAAI,CAAC,CAAC;QACf,SAAS,CAAC,YAAY,CAAC,CAAC;QACxB,IAAI,CAAC;YACH,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,gBAAgB,CACzC,YAAY,EAAE,EACd,EAAE,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,EAAE,EAChD,iBAAiB,EACjB,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAC3C,CAAC;YACF,SAAS,CAAC,QAAQ,CAAC,CAAC;YACpB,SAAS,CAAC,YAAY,CAAC,CAAC;YACxB,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,GAAY,CAAC,CAAC;YACvB,SAAS,CAAC,OAAO,CAAC,CAAC;YACnB,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,EAAE,IAAkB,EAAE,EAAE;QACtD,QAAQ,CAAC,IAAI,CAAC,CAAC;QACf,SAAS,CAAC,YAAY,CAAC,CAAC;QACxB,IAAI,CAAC;YACH,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,gBAAgB,CACzC,YAAY,EAAE,EACd,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,EAAE,EAC9C,oBAAoB,EACpB,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAC3C,CAAC;YACF,SAAS,CAAC,QAAQ,CAAC,CAAC;YACpB,SAAS,CAAC,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;YACvE,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,GAAY,CAAC,CAAC;YACvB,SAAS,CAAC,OAAO,CAAC,CAAC;YACnB,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,EAAE,UAAkB,EAAE,EAAE;QACpD,SAAS,CAAC,SAAS,CAAC,CAAC;QACrB,IAAI,CAAC;YACH,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,gBAAgB,CACzC,YAAY,EAAE,EACd,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,EAClD,iBAAiB,EACjB,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAC3C,CAAC;YACF,SAAS,CAAC,QAAQ,CAAC,CAAC;YACpB,IAAI,iBAAiB,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC3C,SAAS,CAAC,MAAM,CAAC,CAAC;YACpB,CAAC;YACD,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,GAAY,CAAC,CAAC;YACvB,SAAS,CAAC,OAAO,CAAC,CAAC;YACnB,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,EAAE,UAAkB,EAAE,EAAE;QACtD,IAAI,CAAC;YACH,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,gBAAgB,CACzC,YAAY,EAAE,EACd,EAAE,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,EACpD,mBAAmB,EACnB,EAAE,SAAS,EAAE,2BAA2B,EAAE,CAC3C,CAAC;YACF,SAAS,CAAC,QAAQ,CAAC,CAAC;YACpB,SAAS,CAAC,MAAM,CAAC,CAAC;YAClB,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,0EAA0E;YAC1E,sEAAsE;YACtE,sDAAsD;YACtD,QAAQ,CAAC,GAAY,CAAC,CAAC;YACvB,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;AACnE,CAAC"}
1
+ {"version":3,"file":"useBuzzWorkflow.js","sourceRoot":"","sources":["../../src/hooks/useBuzzWorkflow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Input to {@link UseSaveImage.saveImage}. Exactly ONE of `url` / `imageId` is
3
+ * required — the two variants map to the host's two download paths:
4
+ *
5
+ * - `{ url }` — the block's OWN fresh output (e.g. an orchestration blob it has
6
+ * no `imageId` for yet). The host ALLOWLISTS the URL's origin to the civitai
7
+ * image/blob CDN and refuses an arbitrary host — a sandboxed block can't
8
+ * coerce a host-side fetch of an attacker origin.
9
+ * - `{ imageId }` — a cross-user grid image (e.g. a benchmark cell). The host
10
+ * resolves it through the SAME per-viewer gated read that backs
11
+ * `useGatedImages()`, so a withheld/above-ceiling image can never be saved.
12
+ */
13
+ export type SaveImageInput = {
14
+ url: string;
15
+ imageId?: never;
16
+ filename?: string;
17
+ } | {
18
+ imageId: number;
19
+ url?: never;
20
+ filename?: string;
21
+ };
22
+ export interface UseSaveImage {
23
+ /**
24
+ * Ask the host to DOWNLOAD an image the block already displays — the host
25
+ * fetches the blob in its unsandboxed top frame and triggers the browser
26
+ * "Save As". A sandboxed opaque-origin block lacks `allow-downloads`, so it
27
+ * otherwise can only copy a URL; this bridge is the only way to save a paid
28
+ * output. Resolves once the host has started the download; rejects with the
29
+ * host's error string on failure (a disallowed URL origin, a withheld image,
30
+ * an over-size blob, or a fetch failure), or the transport timeout — the hook
31
+ * never hangs.
32
+ */
33
+ saveImage: (input: SaveImageInput) => Promise<void>;
34
+ }
35
+ /**
36
+ * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
37
+ * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
38
+ *
39
+ * @example
40
+ * const { saveImage } = useSaveImage();
41
+ * // block's own generation output (origin-allowlisted host-side):
42
+ * await saveImage({ url: output.url, filename: 'my-render.png' });
43
+ * // a cross-user grid cell (routed through the gated per-viewer read):
44
+ * await saveImage({ imageId: cell.imageId });
45
+ */
46
+ export declare function useSaveImage(): UseSaveImage;
47
+ //# sourceMappingURL=useSaveImage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSaveImage.d.ts","sourceRoot":"","sources":["../../src/hooks/useSaveImage.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GACtB;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAExD,MAAM,WAAW,YAAY;IAC3B;;;;;;;;;OASG;IACH,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,IAAI,YAAY,CAiB3C"}
@@ -0,0 +1,27 @@
1
+ import { useCallback } from 'react';
2
+ import { getTransport } from '../internal/singleton.js';
3
+ import { sendTypedRequest } from '../internal/transport.js';
4
+ /**
5
+ * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
6
+ * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
7
+ *
8
+ * @example
9
+ * const { saveImage } = useSaveImage();
10
+ * // block's own generation output (origin-allowlisted host-side):
11
+ * await saveImage({ url: output.url, filename: 'my-render.png' });
12
+ * // a cross-user grid cell (routed through the gated per-viewer read):
13
+ * await saveImage({ imageId: cell.imageId });
14
+ */
15
+ export function useSaveImage() {
16
+ const saveImage = useCallback(async (input) => {
17
+ const payload = 'url' in input && input.url !== undefined
18
+ ? { url: input.url, filename: input.filename }
19
+ : { imageId: input.imageId, filename: input.filename };
20
+ const reply = await sendTypedRequest(getTransport(), { type: 'SAVE_IMAGE', payload }, 'SAVE_IMAGE_RESULT');
21
+ if (!reply.ok || reply.error) {
22
+ throw new Error(reply.error ?? 'failed to save image');
23
+ }
24
+ }, []);
25
+ return { saveImage };
26
+ }
27
+ //# sourceMappingURL=useSaveImage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSaveImage.js","sourceRoot":"","sources":["../../src/hooks/useSaveImage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAEpC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAgC5D;;;;;;;;;;GAUG;AACH,MAAM,UAAU,YAAY;IAC1B,MAAM,SAAS,GAAG,WAAW,CAAC,KAAK,EAAE,KAAqB,EAAiB,EAAE;QAC3E,MAAM,OAAO,GACX,KAAK,IAAI,KAAK,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS;YACvC,CAAC,CAAC,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE;YAC9C,CAAC,CAAC,EAAE,OAAO,EAAG,KAA6B,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC;QACpF,MAAM,KAAK,GAAG,MAAM,gBAAgB,CAClC,YAAY,EAAE,EACd,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,EAC/B,mBAAmB,CACpB,CAAC;QACF,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI,sBAAsB,CAAC,CAAC;QACzD,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,EAAE,SAAS,EAAE,CAAC;AACvB,CAAC"}
@@ -20,6 +20,14 @@ export interface SharedListItem {
20
20
  count: number;
21
21
  createdAt: Date;
22
22
  updatedAt: Date;
23
+ /**
24
+ * Whether the current viewer has an active up-vote on this entry — hydrate the
25
+ * vote-button state from this on load instead of guessing (fixes the
26
+ * "double-click to unvote" bug). Anonymous viewers are always `false`. Defaults
27
+ * to `false` when talking to an older host that doesn't send it, so a new block
28
+ * on an old host degrades to today's behavior.
29
+ */
30
+ viewerVoted: boolean;
23
31
  }
24
32
  export interface SharedListResult {
25
33
  items: SharedListItem[];
@@ -36,6 +44,24 @@ export interface UseSharedStorage {
36
44
  limit?: number;
37
45
  cursor?: string;
38
46
  }): Promise<SharedListResult>;
47
+ /**
48
+ * Fetch ONE entry by its key — the single-row companion to {@link list} for
49
+ * resolving a `?g=<key>` deep-link to an item past the first page. Resolves
50
+ * with the full {@link SharedListItem} (incl. `count`/`viewerVoted`) or `null`
51
+ * when the key is missing / hidden (a withdrawn or moderated row is never
52
+ * leaked). Respects the same per-viewer visibility as `list`. Rejects with the
53
+ * host's `error` string on host-side failure.
54
+ */
55
+ get(key: string): Promise<SharedListItem | null>;
56
+ /**
57
+ * Report a posted entry for moderator review — the post-write abuse seam for a
58
+ * shared board. `reason` is optional free text. Resolves once the report is
59
+ * filed; rejects with the host's `error` string on failure (`NOT_FOUND` for a
60
+ * missing key, a trust/scope rejection, or when the viewer is anonymous).
61
+ * Filing a report does not hide the row — a moderator decides. Gated by the
62
+ * same `apps:storage:shared:write` trust boundary as {@link append}.
63
+ */
64
+ report(key: string, reason?: string): Promise<void>;
39
65
  /** Current vote total for a single entry (`0` when the key isn't present). */
40
66
  getCount(key: string): Promise<number>;
41
67
  /**
@@ -83,30 +109,5 @@ export interface UseSharedStorage {
83
109
  deleted: boolean;
84
110
  }>;
85
111
  }
86
- /**
87
- * App-scoped, append-only, community-votable SHARED datastore. Sibling of
88
- * {@link useAppStorage} (the per-viewer KV store) — same postMessage bridge,
89
- * same request/await/correlate-by-requestId mechanism — but the scope is the
90
- * APP (every viewer sees the same list) and entries are structured
91
- * `{ title, body? }` records that any viewer can vote on.
92
- *
93
- * Calls flow through the host's postMessage bridge; the block never sees the
94
- * datastore credentials and never sends its block token (the host injects both
95
- * the token and the viewer identity). Anonymous viewers get a read path (`list`
96
- * / `getCount(s)`) and a hard reject on mutations (`append`/`vote`/`unvote`/
97
- * `withdraw`).
98
- *
99
- * The hook is stable across renders — it returns the same object identity once
100
- * the transport singleton is created, so it's safe in `useEffect`/`useMemo`
101
- * dependency arrays.
102
- *
103
- * @example
104
- * const shared = useSharedStorage();
105
- * const { key } = await shared.append({ title: 'Add dark mode', body: '…' });
106
- * const { items } = await shared.list({ limit: 20 }); // newest-first
107
- * const count = await shared.vote(key); // idempotent up-vote
108
- * await shared.unvote(key);
109
- * await shared.withdraw(key); // remove my own entry
110
- */
111
112
  export declare function useSharedStorage(): UseSharedStorage;
112
113
  //# sourceMappingURL=useSharedStorage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useSharedStorage.d.ts","sourceRoot":"","sources":["../../src/hooks/useSharedStorage.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAKlE;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,kBAAkB,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,iBAAiB,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,sFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,IAAI,CAAC,IAAI,CAAC,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACvC;;;OAGG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D;;;;;;;;;OASG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnC;;;OAGG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,gBAAgB,IAAI,gBAAgB,CAkGnD"}
1
+ {"version":3,"file":"useSharedStorage.d.ts","sourceRoot":"","sources":["../../src/hooks/useSharedStorage.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAyB,MAAM,yBAAyB,CAAC;AAKzF;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,kBAAkB,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,iBAAiB,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;IAChB;;;;;;OAMG;IACH,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,sFAAsF;IACtF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,IAAI,CAAC,IAAI,CAAC,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC9B;;;;;;;OAOG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;IACjD;;;;;;;OAOG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACvC;;;OAGG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D;;;;;;;;;OASG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D;;;OAGG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnC;;;OAGG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACnE;AA8CD,wBAAgB,gBAAgB,IAAI,gBAAgB,CA8GnD"}