@stainless-code/svelte-layers
Version:
Svelte adapter for @stainless-code/layers — call a layer like an async function and await its response.
514 lines (479 loc) • 14.4 kB
text/typescript
import type {
DataTag,
DefaultLayerError,
ErrorOf,
InferValidatorOutput,
LayerCallContext,
LayerComponentProps,
LayerGroupOptions,
LayerHandle,
LayerKey,
LayerOptions,
LayerState,
OmitKeyof,
OpenLayerOptions,
ResponseOf,
ValidatedLayerHandle,
Validator,
} from "@stainless-code/layers";
import {
childStackId,
createCallContext,
createLayer as createLayerHandle,
createLayerGroup,
keySignature,
shallowArrayEqual,
LayerClient,
} from "@stainless-code/layers";
// This entry keeps the Svelte 3+ store contract; the package root uses Svelte
// 5.7's `createSubscriber` for runes-aware reads instead.
import { getContext, onDestroy, setContext } from "svelte";
import { derived, readable, writable } from "svelte/store";
import type { Readable } from "svelte/store";
/**
* Core headless factory — not the wired {@link createLayer}.
* @reexport
*/
export { createLayer as createLayerHandle } from "@stainless-code/layers";
export {
hashKey,
keySignature,
shallowArrayEqual,
Subscribable,
notifyManager,
ControlledPromise,
Layer,
LayerStack,
LayerClient,
layerOptions,
layerKey,
createCallContext,
childStackId,
createLayerGroup,
PayloadValidationError,
isPayloadValidationError,
} from "@stainless-code/layers";
export type {
Resolve,
Reject,
LayerHandle,
ValidatedLayerHandle,
LayerGroupHandle,
LayerGroupOptions,
DataTag,
InferDataTagResponse,
InferDataTagError,
ResponseOf,
ErrorOf,
StandardSchemaV1,
Validator,
InferValidatorInput,
InferValidatorOutput,
OpenValidatePayload,
ValidationIssue,
} from "@stainless-code/layers";
export type * from "@stainless-code/layers";
const LAYER_CLIENT_KEY = Symbol("layers.client");
/**
* Provides a {@link LayerClient} to descendant components.
*
* @param client Client to provide. A new client is created when omitted.
* @returns The provided client.
*/
export function setLayerClient(client?: LayerClient): LayerClient {
const c = client ?? new LayerClient();
setContext(LAYER_CLIENT_KEY, c);
return c;
}
/**
* Reads the nearest {@link LayerClient} from Svelte context.
*
* @returns The nearest provided client.
*/
export function useLayerClient(): LayerClient {
const c = getContext<LayerClient | undefined>(LAYER_CLIENT_KEY);
if (!c) {
throw new Error(
"[layers/svelte-store] No LayerClient in context — call setLayerClient() in a parent component.",
);
}
return c;
}
function defaultSelector(states: LayerState[]): LayerState[] {
return states;
}
type NoValidateOptions<Opts> = Opts extends { validate: Validator<unknown> }
? never
: Opts;
export interface UseStackOptions<T = LayerState[]> {
stack?: string;
select?: (states: LayerState[]) => T;
compare?: (a: T, b: T) => boolean;
}
export interface UseLayerStateOptions<
Key extends LayerKey,
P = unknown,
D = unknown,
U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[],
> {
key: Key;
stack?: string;
select?: (states: LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[]) => U;
compare?: (a: U, b: U) => boolean;
}
export type WiredLayerStoreHandle<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
> = LayerHandle<P, R, E, D, RP> & {
state: Readable<LayerState<P, R, E, D>[]>;
queued: Readable<LayerState<P, R, E, D>[]>;
top: Readable<LayerState<P, R, E, D> | null>;
};
export type WiredValidatedLayerStoreHandle<
V extends Validator<unknown>,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
> = ValidatedLayerHandle<V, R, E, D, RP> & {
state: Readable<LayerState<InferValidatorOutput<V>, R, E, D>[]>;
queued: Readable<LayerState<InferValidatorOutput<V>, R, E, D>[]>;
top: Readable<LayerState<InferValidatorOutput<V>, R, E, D> | null>;
};
function makeReadableStack<T>(
stack: ReturnType<LayerClient["getStack"]>,
getSource: () => LayerState[],
select: (states: LayerState[]) => T,
compare: (a: T, b: T) => boolean,
): Readable<T> {
let cache: { base: LayerState[]; value: T } | null = null;
// Preserve selector output identity while the base snapshot is unchanged or
// `compare` considers a new result equal, preventing object/array churn.
const runSelect = (base: LayerState[]): T => {
const prev = cache;
if (prev && prev.base === base) return prev.value;
const next = select(base);
if (prev && compare(prev.value, next)) {
cache = { base, value: prev.value };
return prev.value;
}
cache = { base, value: next };
return next;
};
return readable(runSelect(getSource()), (set) => {
const unsubscribe = stack.subscribe(() => {
const prevValue = cache?.value;
const next = runSelect(getSource());
if (prevValue === undefined || !compare(prevValue, next)) {
set(next);
}
});
// Re-read on the first subscriber so an idle store cannot expose a stale
// value.
set(runSelect(getSource()));
return unsubscribe;
});
}
/**
* Exposes a {@link LayerClient} stack as a Svelte readable store.
*
* Use `$stack` in components to subscribe. Pair each state with {@link callFor}
* when rendering a layer.
*
* @param opts Stack id, selector, and compare options.
* @param client Optional client override; defaults to {@link useLayerClient}.
* @returns A readable store of the selected stack value.
* @default `stack` is `"default"`; `select` is identity; `compare` is
* `Object.is`.
* @example
* ```svelte
* <script>
* import {
* callFor,
* useLayerClient,
* useStack,
* } from "@stainless-code/svelte-layers/store";
*
* const client = useLayerClient();
* const stack = useStack({ stack: "confirm" });
* </script>
* {#each $stack as state (state.id)}
* {@const call = callFor(client, "confirm", state)}
* {#if call}<Confirm {call} />{/if}
* {/each}
* ```
*/
export function useStack<T = LayerState[]>(
opts: UseStackOptions<T> = {},
client?: LayerClient,
): Readable<T> {
const resolved = client ?? useLayerClient();
const stackId = opts.stack ?? "default";
const stack = resolved.getStack(stackId);
const select =
opts.select ?? (defaultSelector as unknown as (states: LayerState[]) => T);
const compare = opts.compare ?? Object.is;
return makeReadableStack(stack, () => stack.getSnapshot(), select, compare);
}
/** Exposes a stack's queued snapshot as a Svelte readable store. */
export function createQueuedStack<T = LayerState[]>(
opts: UseStackOptions<T> = {},
client?: LayerClient,
): Readable<T> {
const resolved = client ?? useLayerClient();
const stackId = opts.stack ?? "default";
const stack = resolved.getStack(stackId);
const select =
opts.select ?? (defaultSelector as unknown as (states: LayerState[]) => T);
const compare = opts.compare ?? Object.is;
return makeReadableStack(
stack,
() => stack.getQueuedSnapshot(),
select,
compare,
);
}
/** Observe all mounted layers matching a key. */
export function createLayerState<
Key extends LayerKey,
P = unknown,
D = unknown,
U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[],
>(opts: UseLayerStateOptions<Key, P, D, U>, client?: LayerClient): Readable<U> {
const sig = keySignature(opts.key);
return useStack<U>(
{
stack: opts.stack,
select: (states) => {
const filtered = states.filter(
(s) => keySignature(s.key) === sig,
) as LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[];
return opts.select ? opts.select(filtered) : (filtered as unknown as U);
},
compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean),
},
client,
);
}
/** Observe all queued layers matching a key. */
export function createLayerQueuedState<
Key extends LayerKey,
P = unknown,
D = unknown,
U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[],
>(opts: UseLayerStateOptions<Key, P, D, U>, client?: LayerClient): Readable<U> {
const sig = keySignature(opts.key);
return createQueuedStack<U>(
{
stack: opts.stack,
select: (states) => {
const filtered = states.filter(
(s) => keySignature(s.key) === sig,
) as LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[];
return opts.select ? opts.select(filtered) : (filtered as unknown as U);
},
compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean),
},
client,
);
}
function createLayerImpl<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: LayerOptions<P, R, E, D, RP> & { key: LayerKey },
client?: LayerClient,
): WiredLayerStoreHandle<P, R, E, D, RP> {
const resolved = client ?? useLayerClient();
const stackId = options.stack ?? "default";
const sig = keySignature(options.key);
const selectByKey = (states: LayerState[]) =>
states.filter((s) => keySignature(s.key) === sig) as LayerState<
P,
R,
E,
D
>[];
const handle = createLayerHandle(options, resolved);
const state = useStack(
{ stack: stackId, select: selectByKey, compare: shallowArrayEqual },
resolved,
);
const queued = createQueuedStack(
{ stack: stackId, select: selectByKey, compare: shallowArrayEqual },
resolved,
);
const top = derived(state, ($s) => $s.at(-1) ?? null);
Object.assign(handle, { state, queued, top });
return handle as WiredLayerStoreHandle<P, R, E, D, RP>;
}
/** Wired handle: wraps core {@link createLayerHandle} with store reactivity. */
export function createLayer<
V extends Validator<unknown>,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: LayerOptions<InferValidatorOutput<V>, R, E, D, RP> & {
key: LayerKey;
validate: V;
},
client?: LayerClient,
): WiredValidatedLayerStoreHandle<V, R, E, D, RP>;
export function createLayer<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: NoValidateOptions<LayerOptions<P, R, E, D, RP> & { key: LayerKey }>,
client?: LayerClient,
): WiredLayerStoreHandle<P, R, E, D, RP>;
export function createLayer<
P,
R,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
options: LayerOptions<P, R, E, D, RP> & { key: LayerKey },
client?: LayerClient,
): WiredLayerStoreHandle<P, R, E, D, RP> {
return createLayerImpl(options, client);
}
/**
* Builds the call context for a layer state.
*
* @param client Client that owns the stack.
* @param stackId Stack containing the state.
* @param state State whose layer should receive the call.
* @param rootProps Props supplied by the layer host.
* @returns The call context, or `null` if the layer is no longer mounted.
*/
export function callFor<RootProps = unknown>(
client: LayerClient,
stackId: string,
state: LayerState,
rootProps?: RootProps,
): LayerCallContext<unknown, unknown, RootProps> | null {
const stack = client.getStack(stackId);
const layer = stack.getLayer(state.id);
return layer
? (createCallContext(stack, layer, state, rootProps) as LayerCallContext<
unknown,
unknown,
RootProps
>)
: null;
}
export interface MutationRun<R> {
/** On success, end the layer with `response`; on failure, leave it open and rethrow. */
orEnd: (response: R) => Promise<void>;
}
export interface MutationFlow<R> {
/** Store that is `true` while a `run(...)` async action is in flight; subscribe with `$pending`. Mirrors the layer's `actionStatus: "running"`. */
pending: Readable<boolean>;
run: (fn: () => Promise<void> | void) => MutationRun<R>;
}
/**
* Coordinate a layer's pending state with an async mutation and end it on success.
*
* @example
* ```svelte
* <script lang="ts">
* import { type LayerComponentProps, useMutationFlow } from "@stainless-code/svelte-layers/store";
*
* let { call }: LayerComponentProps<void, boolean> = $props();
* const { pending, run } = useMutationFlow(call);
* </script>
* <button disabled={$pending} onclick={() => void run(() => Promise.resolve()).orEnd(true)}>
* Confirm
* </button>
* ```
*/
export function useMutationFlow<P, R, RootProps = unknown>(
call: LayerCallContext<P, R, RootProps>,
): MutationFlow<R> {
const pending = writable(false);
return {
pending,
run: (fn) => ({
orEnd: async (response) => {
pending.set(true);
call.setRunning(true);
try {
await fn();
call.end(response);
} finally {
call.setRunning(false);
pending.set(false);
}
},
}),
};
}
export interface LayerGroup {
open: ScopedOpen;
dismissAll: (response?: unknown) => void;
/** The child stack store — render `{#each $stack as s}` and pair with `callFor(client, stackId, s)`. */
stack: Readable<LayerState[]>;
stackId: string;
}
/** Open a layer on a pre-bound stack, with {@link DataTag} response and error inference. */
export interface ScopedOpen {
<P, R, E = DefaultLayerError, D = unknown, RootProps = unknown>(
options: OmitKeyof<
OpenLayerOptions<P, R, E, D, RootProps> & {
key: DataTag<LayerKey, R, E>;
},
"stack" | "validate"
>,
): Promise<R>;
<P, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown>(
options: OmitKeyof<OpenLayerOptions<P, R, E, D, RootProps>, "stack">,
): Promise<R>;
}
/**
* Create a child stack scoped to the calling layer's lifetime.
*
* The child stack is disposed and cleared via `cancelAll` when its parent
* layer unmounts (`LayerCancelledError`).
*/
export function useLayerGroup<P, R, RootProps = unknown>(
call: LayerCallContext<P, R, RootProps>,
options?: LayerGroupOptions,
): LayerGroup {
const client = useLayerClient();
const stackId = childStackId(call, options?.name);
const group = createLayerGroup(client, call, options);
onDestroy(() => {
group.dispose();
client.cancelAll(group.stackId, { reason: "groupDispose" });
});
const stack = useStack({ stack: stackId });
const open = (<
P2,
R2 = void,
E = DefaultLayerError,
D = unknown,
RP = unknown,
>(
opts: OmitKeyof<OpenLayerOptions<P2, R2, E, D, RP>, "stack">,
) =>
client.open({
...opts,
stack: stackId,
} as OpenLayerOptions<P2, R2, E, D, RP>)) as unknown as ScopedOpen;
const dismissAll = (response?: unknown) =>
client.dismissAll(stackId, response);
return { open, dismissAll, stack, stackId };
}
export type { LayerComponentProps };