Packages

Ash Live Vue Adapter (Alva) connects Ash Framework resources seamlessly to Phoenix LiveView and Vue 3 with end-to-end TypeScript code generation.

Current section

Files

Jump to
alva lib mix tasks alva.codegen.ex
Raw

lib/mix/tasks/alva.codegen.ex

defmodule Mix.Tasks.Alva.Codegen do
@shortdoc "Generates TypeScript bindings for LiveVue events"
@moduledoc since: "0.1.0"
@moduledoc """
Generates TypeScript bindings for LiveVue events defined in Ash resources.
Reads the host application's configured `ash_domains`.
Outputs generated files to `:alva, :output_dir` (defaults to `assets/js/alva`).
## Generated Files
* `types.ts` - TypeScript interfaces for all Ash resources and filter types
* `events.ts` - Type-safe event map with input/output types
* `signals.ts` - Type-safe signal map with payload types
* `composables/useAlva.ts` - Main Vue composable with domain-scoped methods
* `composables/useAlvaForm.ts` - Form handling composable
* `composables/useAlvaQuery.ts` - Reactive query composable with debouncing
* `composables/useAlvaUpload.ts` - File upload composable
* `index.ts` - Public exports
## Usage
mix alva.codegen
See `Alva.Codegen.DtoGenerator`, `Alva.Codegen.InputContract`, and
`Alva.Codegen.TypeMapper` for the individual code generation modules.
"""
use Mix.Task
alias Alva.Codegen.DtoGenerator
alias Alva.Codegen.InputContract
alias Ash.Resource.Info
@impl true
@doc """
Executes the code generation pipeline.
"""
@doc since: "0.1.0"
def run(_args) do
Mix.Task.run("compile")
Mix.Task.run("app.start")
app = Mix.Project.config()[:app]
Application.ensure_all_started(app)
output_dir = Application.get_env(:alva, :output_dir, "assets/js/alva")
if Ash.Info.domains(app) == [] do
Mix.shell().info(
"No Ash domains found. Please configure config :#{app}, ash_domains: [...]"
)
else
composables_dir = Path.join(output_dir, "composables")
File.mkdir_p!(composables_dir)
File.mkdir_p!(output_dir)
events_map = Alva.Registry.event_map(app)
signal_map = Alva.Registry.registry(app).signal_map
resources =
events_map
|> Map.values()
|> Enum.map(fn {resource, _event} -> resource end)
|> Enum.uniq()
generate_types_ts(resources, events_map, output_dir)
generate_events_ts(events_map, output_dir)
generate_signals_ts(signal_map, events_map, output_dir)
generate_use_alva_ts(events_map, signal_map, composables_dir)
generate_use_alva_form_ts(composables_dir)
generate_use_alva_query_ts(composables_dir)
generate_use_alva_paginated_query_ts(composables_dir)
generate_use_alva_upload_ts(composables_dir)
generate_use_alva_assigns_ts(composables_dir)
generate_testing_utils_ts(events_map, output_dir)
generate_index_ts(output_dir)
Mix.shell().info("Successfully generated TypeScript SDK in #{output_dir}")
end
end
defp generate_types_ts(resources, events_map, output_dir) do
content = DtoGenerator.generate_types_ts(resources, events_map)
write_file!(output_dir, "types.ts", content)
end
defp generate_events_ts(events_map, output_dir) do
events_content =
events_map
|> Enum.map_join("\n", fn {event_name, {resource, event_def}} ->
output_type = DtoGenerator.output_type(resource, event_def)
action = Info.action(resource, event_def.action)
input_shape =
InputContract.generate_input_shape(
resource,
event_def,
action,
" "
)
"""
"#{event_name}": {
input: #{input_shape};
output: AlvaResult<Types.#{output_type}>;
};
"""
end)
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import type { AlvaResult } from "./types";
import type * as Types from "./types";
export type AlvaEvents = {
#{events_content}
};
"""
write_file!(output_dir, "events.ts", content)
end
defp generate_signals_ts(signal_map, events_map, output_dir) do
subs_content =
signal_map
|> Enum.map_join("\n", fn {_key, {resource, signal_def}} ->
payload_prop = generate_signal_payload_prop(signal_def, events_map, resource)
"""
"#{signal_def.name}": {
#{payload_prop}
};
"""
end)
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import type * as Types from "./types";
export type AlvaSignals = {
#{subs_content}
};
"""
write_file!(output_dir, "signals.ts", content)
end
defp generate_signal_payload_prop(_signal_def, _events_map, resource) do
output_type = resource |> Module.split() |> List.last()
"payload: Types.#{output_type};"
end
defp generate_use_alva_ts(events_map, signal_map, composables_dir) do
all_keys = Map.keys(events_map) ++ Map.keys(signal_map)
domains =
all_keys
|> Enum.filter(&String.contains?(&1, "."))
|> Enum.map(&(String.split(&1, ".") |> List.first()))
|> Enum.uniq()
domain_objects =
Enum.map_join(domains, ",\n", fn domain ->
domain_prefix = "#{domain}."
domain_events = generate_domain_events(events_map, domain, domain_prefix)
domain_signals = generate_domain_signals(signal_map, domain, domain_prefix)
fields = [domain_events, domain_signals] |> Enum.reject(&(&1 == "")) |> Enum.join(",\n")
"""
#{domain}: {
#{fields}
}
"""
end)
return_entries =
[
domain_objects,
"""
use_upload: (name: string, options?: import("./useAlvaUpload").AlvaUploadOptions) => {
return useAlvaUpload(name, options);
}
"""
]
|> Enum.reject(&(String.trim(&1) == ""))
|> Enum.join(",\n")
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { useLiveVue, useLiveEvent } from "live_vue";
import { ref, computed, onUnmounted } from "vue";
import { useAlvaForm, type AlvaFormOptions } from "./useAlvaForm";
import { useAlvaQuery } from "./useAlvaQuery";
import { useAlvaUpload } from "./useAlvaUpload";
import type { AlvaEvents } from "../events";
import type { AlvaSignals } from "../signals";
import type { AlvaError } from "../types";
export type { AlvaError };
export interface AlvaConfig {
onError?: (error: AlvaError, event: string) => void;
onSuccess?: (data: unknown, event: string) => void;
}
export function useAlva(config?: AlvaConfig) {
let live: any = null;
try {
live = useLiveVue();
} catch (_e) {
live = null;
}
const safePushEvent = (event: string, payload: any, callback: any) => {
if (live && typeof live.pushEvent === "function") {
return live.pushEvent(event, payload, callback);
}
callback?.({ ok: false, error: { type: "unmounted", message: "LiveVue context not available" } });
};
return {
#{return_entries}
};
}
"""
write_file!(composables_dir, "useAlva.ts", content)
end
defp generate_use_alva_form_ts(composables_dir) do
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { reactive } from "vue";
import { useLiveForm, type Form, type UseLiveFormReturn } from "live_vue";
import type { AlvaEvents } from "../events";
export type AlvaFormOptions<FormValues, EventKeys> = {
initialValues: FormValues;
validateEvent?: EventKeys;
debounceMs?: number;
uploads?: Record<string, { getFileReferences: () => string[] }>;
onOptimisticSubmit?: (formData: FormValues) => (() => void) | void;
};
export type AlvaFormReturn<K extends keyof AlvaEvents, FormValues extends object> =
Omit<UseLiveFormReturn<FormValues>, "submit"> & {
values: FormValues;
submit: () => Promise<AlvaEvents[K]["output"]>;
reset: (newValues?: Partial<FormValues>) => void;
};
function clearFormErrors(errors: Record<string, string[]>) {
for (const key of Object.keys(errors)) {
delete errors[key];
}
}
export function useAlvaForm<
K extends keyof AlvaEvents,
FormValues extends object = AlvaEvents[K]["input"],
>(
submitEvent: K,
options: AlvaFormOptions<FormValues, keyof AlvaEvents>,
): AlvaFormReturn<K, FormValues> {
const pseudoForm = reactive({
name: submitEvent as string,
values: JSON.parse(JSON.stringify(options.initialValues)),
errors: {} as Record<string, string[]>,
valid: true,
}) as Form<FormValues>;
const liveForm = useLiveForm(pseudoForm, {
submitEvent: submitEvent as string,
changeEvent: (options.validateEvent as unknown as string) || null,
debounceInMiliseconds: options.debounceMs || 300,
prepareData: (data: any) => {
if (options.uploads) {
for (const [key, upload] of Object.entries(options.uploads)) {
data[key] = upload.getFileReferences();
}
}
return data;
}
});
const originalSubmit = liveForm.submit;
const reset = (newValues?: Partial<FormValues>) => {
const target = newValues ? { ...options.initialValues, ...newValues } : options.initialValues;
Object.assign(pseudoForm.values, JSON.parse(JSON.stringify(target)));
clearFormErrors(pseudoForm.errors as any);
pseudoForm.valid = true;
};
const submit = async (): Promise<AlvaEvents[K]["output"]> => {
for (const key of Object.keys(pseudoForm.values as object)) {
try {
const fieldRef = liveForm.field(key as any);
if (fieldRef && fieldRef.value && fieldRef.value.value !== undefined) {
(pseudoForm.values as any)[key] = fieldRef.value.value;
}
} catch (_e) {}
}
const rollbackFn = options.onOptimisticSubmit?.(pseudoForm.values);
let result: any;
try {
result = await originalSubmit();
} catch (e: any) {
result = { ok: false, error: { type: "unknown", message: e.message || String(e) } };
}
if (!result || !result.ok) {
if (rollbackFn) rollbackFn();
if (result?.error?.fields) {
clearFormErrors(pseudoForm.errors as any);
Object.assign(pseudoForm.errors, result.error.fields);
}
} else {
clearFormErrors(pseudoForm.errors as any);
}
return result;
};
return {
...liveForm,
values: pseudoForm.values,
submit,
reset,
} as AlvaFormReturn<K, FormValues>;
}
"""
write_file!(composables_dir, "useAlvaForm.ts", content)
end
defp generate_use_alva_query_ts(composables_dir) do
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { ref, watch, onUnmounted, type Ref, type WatchSource } from "vue";
import { useLiveVue, useLiveEvent } from "live_vue";
import type { AlvaEvents } from "../events";
import type { AlvaError } from "./useAlva";
export interface AlvaQueryOptions {
debounceMs?: number;
pollIntervalMs?: number | Ref<number> | (() => number);
autoRefreshOnSignal?: string | string[];
}
export interface AlvaQueryReturn<Output> {
data: Ref<Output | null>;
loading: Ref<boolean>;
error: Ref<AlvaError | null>;
refetch: () => Promise<void>;
}
export function useAlvaQuery<
K extends keyof AlvaEvents,
Input = AlvaEvents[K]["input"],
Output = Extract<AlvaEvents[K]["output"], { ok: true }> extends { data: infer D } ? D : never
>(
queryEvent: K,
inputGetter: WatchSource<Input>,
options: AlvaQueryOptions = {}
): AlvaQueryReturn<Output> {
let live: any = null;
try {
live = useLiveVue();
} catch (_e) {
live = null;
}
const data = ref<Output | null>(null) as Ref<Output | null>;
const loading = ref(false);
const error = ref<AlvaError | null>(null);
const debounceMs = options.debounceMs ?? 0;
let timeoutId: any;
let currentRequestId = 0;
let pollTimer: any;
const fetchQuery = async (currentInput: Input) => {
const requestId = ++currentRequestId;
return new Promise<void>((resolve) => {
if (!live || typeof live.pushEvent !== "function") {
loading.value = false;
return resolve();
}
live.pushEvent(queryEvent as string, currentInput as any, (reply: any) => {
if (requestId !== currentRequestId) return resolve();
loading.value = false;
if (reply && reply.ok) {
data.value = reply.data;
error.value = null;
} else if (reply && !reply.ok) {
error.value = reply.error || { type: "error", message: "Query failed" };
}
resolve();
});
});
};
const getCurrentInput = (): Input => {
return typeof inputGetter === "function" ? (inputGetter as any)() : (inputGetter as any).value;
};
const debouncedFetch = (currentInput: Input) => {
loading.value = true;
if (debounceMs > 0) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fetchQuery(currentInput);
}, debounceMs);
} else {
fetchQuery(currentInput);
}
};
watch(inputGetter, (newInput) => {
debouncedFetch(newInput);
}, { deep: true, immediate: true });
const getPollInterval = (): number => {
if (!options.pollIntervalMs) return 0;
if (typeof options.pollIntervalMs === "function") return options.pollIntervalMs();
if (typeof options.pollIntervalMs === "object" && options.pollIntervalMs !== null && "value" in options.pollIntervalMs) {
return (options.pollIntervalMs as Ref<number>).value;
}
return Number(options.pollIntervalMs) || 0;
};
const setupPollTimer = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
const interval = getPollInterval();
if (interval > 0) {
pollTimer = setInterval(() => {
fetchQuery(getCurrentInput());
}, interval);
}
};
if (typeof options.pollIntervalMs === "function" || (typeof options.pollIntervalMs === "object" && options.pollIntervalMs !== null && "value" in options.pollIntervalMs)) {
watch(() => getPollInterval(), () => {
setupPollTimer();
}, { immediate: true });
} else {
setupPollTimer();
}
if (options.autoRefreshOnSignal) {
const signals = Array.isArray(options.autoRefreshOnSignal)
? options.autoRefreshOnSignal
: [options.autoRefreshOnSignal];
for (const sig of signals) {
useLiveEvent(sig, () => {
fetchQuery(getCurrentInput());
});
}
}
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
});
return {
data,
loading,
error,
refetch: async () => {
loading.value = true;
await fetchQuery(getCurrentInput());
}
};
}
"""
write_file!(composables_dir, "useAlvaQuery.ts", content)
end
defp generate_use_alva_upload_ts(composables_dir) do
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { computed, ref, watch } from "vue";
import { useLiveUpload, useLiveVue } from "live_vue";
export type UploadEntry = {
ref: string;
client_name?: string;
client_size?: number;
client_type?: string;
name?: string;
size?: number;
type?: string;
progress: number;
error?: string;
};
export type AlvaUploadOptions = {
maxFiles?: number;
maxSize?: number;
};
export type AlvaUploadDispatchContext = {
files: UploadEntry[];
references: string[];
primaryReference: string;
};
const ALVA_UPLOAD_CHANGE_EVENT = "alva.validate_upload";
const ALVA_UPLOAD_SUBMIT_EVENT = "alva.save_upload";
type UploadConfigShape = {
ref: string;
name: string;
accept: string | false;
max_entries: number;
auto_upload: boolean;
entries: UploadEntry[];
errors: unknown[];
};
export function useAlvaUpload(name: string, options?: AlvaUploadOptions) {
let live: any;
try {
live = useLiveVue();
} catch (_e) {
return missingUpload(name);
}
const uploadProps = live?.vue?.props as Record<string, unknown> | null | undefined;
const getUploadConfig = (): UploadConfigShape | null => {
const directConfig = uploadProps?.[name];
if (isUploadConfig(directConfig)) return directConfig;
const nested = uploadProps?.uploads;
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
const nestedConfig = (nested as Record<string, unknown>)[name];
if (isUploadConfig(nestedConfig)) return nestedConfig;
}
return null;
};
const config = getUploadConfig();
if (!config) {
return missingUpload(name);
}
const upload = useLiveUpload(getUploadConfig as any, {
changeEvent: ALVA_UPLOAD_CHANGE_EVENT,
submitEvent: ALVA_UPLOAD_SUBMIT_EVENT,
...(options || {}),
}) as any;
const files = computed<UploadEntry[]>(() => upload.entries?.value || []);
const errors = computed(() => upload.errors?.value || []);
const progress = computed(() => {
if (files.value.length === 0) return 0;
let totalSize = 0;
let totalUploaded = 0;
for (const file of files.value) {
const size = file.client_size ?? file.size ?? 0;
totalSize += size;
totalUploaded += size * ((file.progress || 0) / 100);
}
return totalSize === 0 ? 0 : Math.round((totalUploaded / totalSize) * 100);
});
const enforceLimits = () => {
if (!options || !upload.cancel) return;
if (options.maxFiles && files.value.length > options.maxFiles) {
files.value.slice(options.maxFiles).forEach((f) => upload.cancel(f.ref));
}
if (options.maxSize) {
files.value.forEach((f) => {
const size = f.client_size ?? f.size ?? 0;
if (size > options.maxSize!) upload.cancel(f.ref);
});
}
};
watch(files, enforceLimits, { deep: true, immediate: true });
const getFileReferences = () => files.value.map((f) => f.ref);
const waitForCompletion = (): Promise<AlvaUploadDispatchContext> => {
return new Promise((resolve, reject) => {
let settled = false;
let stop: (() => void) | undefined;
const settle = (cb: () => void) => {
if (settled) return;
settled = true;
stop?.();
cb();
};
const tryComplete = () => {
if (files.value.length === 0 || progress.value !== 100) return;
const refs = getFileReferences();
const primary = refs[0];
if (!primary) {
settle(() => reject(new Error("No uploaded file reference was produced.")));
return;
}
settle(() => resolve({ files: [...files.value], references: refs, primaryReference: primary }));
};
tryComplete();
if (settled) return;
stop = watch([files, progress], tryComplete, { deep: true });
});
};
const dispatch = async <T>(
submit: (ctx: AlvaUploadDispatchContext) => Promise<T>,
opts: { clear?: boolean } = {},
) => {
try {
const ctx = await waitForCompletion();
return await submit(ctx);
} finally {
if (opts.clear !== false) upload.clear?.();
}
};
return { ...upload, files, errors, progress, getFileReferences, dispatch };
}
function isUploadConfig(value: unknown): value is UploadConfigShape {
return Boolean(
value && typeof value === "object" &&
typeof (value as UploadConfigShape).ref === "string" &&
typeof (value as UploadConfigShape).name === "string" &&
Array.isArray((value as UploadConfigShape).entries) &&
Array.isArray((value as UploadConfigShape).errors)
);
}
function missingUpload(name: string) {
const files = ref<UploadEntry[]>([]);
const errors = computed(() => []);
const progress = computed(() => 0);
const warn = () => {
console.warn(
`[alva/useAlvaUpload] Missing LiveView upload config for "${name}". ` +
`Pass the matching upload prop to the LiveVue component.`
);
};
return {
name, entries: files, files, errors, progress,
showFilePicker: warn, cancel: () => {}, clear: () => {},
getFileReferences: () => [],
dispatch: async () => { warn(); throw new Error("Missing upload config"); },
};
}
"""
write_file!(composables_dir, "useAlvaUpload.ts", content)
end
defp generate_use_alva_assigns_ts(composables_dir) do
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { computed, type ComputedRef } from "vue";
import { useLiveVue } from "live_vue";
export function useAlvaAssigns<T extends Record<string, unknown> = Record<string, unknown>>(): ComputedRef<T> {
const live = useLiveVue();
return computed(() => {
const props = (live.vue?.props || {}) as Record<string, unknown>;
return (props.assigns || props) as T;
});
}
"""
write_file!(composables_dir, "useAlvaAssigns.ts", content)
end
defp generate_use_alva_paginated_query_ts(composables_dir) do
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { ref, watch } from "vue";
import { useAlvaQuery, type AlvaQueryOptions } from "./useAlvaQuery";
import type { AlvaEvents } from "../events";
export function useAlvaPaginatedQuery<K extends keyof AlvaEvents>(
eventName: K,
inputGetter: () => AlvaEvents[K]["input"],
options?: AlvaQueryOptions & { pageSize?: number }
) {
const page = ref(1);
const items = ref<any[]>([]);
const hasMore = ref(true);
const loadingMore = ref(false);
const pageSize = options?.pageSize || 10;
const queryInput = () => {
const baseInput = (inputGetter() || {}) as Record<string, any>;
return {
...baseInput,
page: { offset: (page.value - 1) * pageSize, limit: pageSize }
} as AlvaEvents[K]["input"];
};
const query = useAlvaQuery(eventName, queryInput as any, options);
watch(query.data, (newData) => {
if (Array.isArray(newData)) {
if (page.value === 1) {
items.value = newData;
} else {
items.value = [...items.value, ...newData];
}
hasMore.value = newData.length >= pageSize;
}
}, { immediate: true });
const loadNextPage = async () => {
if (!hasMore.value || loadingMore.value) return;
loadingMore.value = true;
page.value += 1;
await query.refetch();
loadingMore.value = false;
};
const reset = () => {
page.value = 1;
items.value = [];
hasMore.value = true;
query.refetch();
};
return {
...query,
items,
page,
hasMore,
loadingMore,
loadNextPage,
reset
};
}
"""
write_file!(composables_dir, "useAlvaPaginatedQuery.ts", content)
end
defp generate_testing_utils_ts(_events_map, output_dir) do
testing_dir = Path.join(output_dir, "testing")
File.mkdir_p!(testing_dir)
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
import { ref } from "vue";
import { vi } from "vitest";
export function createMockAlvaClient(initialData: Record<string, any> = {}) {
const createMockQuery = (key: string, defaultVal: any) => {
const data = ref(initialData[key] ?? defaultVal);
const loading = ref(false);
const error = ref(null);
return {
data,
loading,
error,
refetch: async () => {}
};
};
const createMockFn = (defaultReturn: any = { ok: true }) => {
return vi.fn((input: any) => Promise.resolve(defaultReturn));
};
return {
catalog: {
use_list_products_query: (getter: any) => createMockQuery("products", []),
use_get_product_by_id: (id: any) => createMockQuery("product", null),
adjust_stock: createMockFn(),
upload_media: createMockFn(),
},
sales: {
use_list_orders_query: (getter: any) => createMockQuery("orders", []),
create_order: createMockFn(),
begin_processing: createMockFn(),
fulfill: createMockFn(),
},
support: {
use_list_conversations_query: (getter: any) => createMockQuery("conversations", []),
use_list_messages_query: (getter: any) => createMockQuery("messages", []),
create: createMockFn(),
send_message: createMockFn(),
},
demo_chat: {
use_list_messages_query: (getter: any) => createMockQuery("chat_messages", []),
send_message: createMockFn(),
},
demo_notifications: {
send: createMockFn(),
}
};
}
"""
write_file!(testing_dir, "index.ts", content)
end
defp generate_index_ts(output_dir) do
content = """
// This file is auto-generated by Alva.
// Do not edit manually.
export { useAlva } from "./composables/useAlva";
export { useAlvaAssigns } from "./composables/useAlvaAssigns";
export { useAlvaPaginatedQuery } from "./composables/useAlvaPaginatedQuery";
export { createMockAlvaClient } from "./testing";
export type { AlvaConfig, AlvaError } from "./composables/useAlva";
export type { AlvaFormOptions } from "./composables/useAlvaForm";
export type { AlvaQueryOptions, AlvaQueryReturn } from "./composables/useAlvaQuery";
export type { AlvaUploadOptions, AlvaUploadDispatchContext, UploadEntry } from "./composables/useAlvaUpload";
export type { AlvaEvents } from "./events";
export type { AlvaSignals } from "./signals";
"""
write_file!(output_dir, "index.ts", content)
end
defp write_file!(output_dir, filename, content) do
file_path = Path.join(output_dir, filename)
File.write!(file_path, content)
end
defp generate_domain_events(events_map, domain, domain_prefix) do
events_map
|> Enum.filter(fn {event_name, _} ->
String.starts_with?(event_name, domain_prefix) or event_name == domain
end)
|> Enum.map_join(",\n", fn {event_name, {resource, event_def}} ->
action_name =
if String.starts_with?(event_name, domain_prefix),
do: String.replace_prefix(event_name, domain_prefix, ""),
else: event_name
is_read =
case Ash.Resource.Info.action(resource, event_def.action) do
%{type: :read} -> true
_ -> false
end
lookup_field = Map.get(event_def, :lookup) || :id
by_id_ts =
if is_read do
"""
,
use_#{action_name}_by_#{lookup_field}: (
idGetter: import("vue").WatchSource<string | null | undefined>,
options?: import("./useAlvaQuery").AlvaQueryOptions
) => {
const inputGetter = () => {
const id = typeof idGetter === "function" ? idGetter() : (idGetter as any)?.value;
return id ? ({ #{lookup_field}: id } as any) : null;
};
return useAlvaQuery("#{event_name}", inputGetter, options);
}
"""
else
""
end
query_ts =
if is_read do
"""
,
use_#{action_name}_query: (
inputGetter: () => AlvaEvents["#{event_name}"]["input"],
options?: import("./useAlvaQuery").AlvaQueryOptions
) => {
return useAlvaQuery("#{event_name}", inputGetter, options);
}#{by_id_ts}
"""
else
""
end
"""
#{action_name}: (payload: AlvaEvents["#{event_name}"]["input"]): Promise<AlvaEvents["#{event_name}"]["output"]> => {
return new Promise((resolve) => {
const startTime = performance.now();
safePushEvent("#{event_name}", payload, (reply: AlvaEvents["#{event_name}"]["output"]) => {
const durationMs = Math.round(performance.now() - startTime);
if (typeof window !== "undefined" && (window as any).__ALVA_RECORD_TELEMETRY__) {
(window as any).__ALVA_RECORD_TELEMETRY__("#{event_name}", durationMs, reply?.ok);
}
if (reply?.ok) {
config?.onSuccess?.(reply.data, "#{event_name}");
} else {
config?.onError?.(reply?.error, "#{event_name}");
}
resolve(reply);
});
});
},
use_#{action_name}_form: (
options: Extract<AlvaEvents["#{event_name}"]["input"], object> extends never ? never : AlvaFormOptions<Extract<AlvaEvents["#{event_name}"]["input"], object>, keyof AlvaEvents>
) => {
return useAlvaForm("#{event_name}", options);
},
use_#{action_name}_upload: (options?: import("./useAlvaUpload").AlvaUploadOptions) => {
return useAlvaUpload("#{event_name}", options);
}#{query_ts}
"""
end)
end
defp generate_domain_signals(signal_map, domain, domain_prefix) do
signal_map
|> Enum.filter(fn {_k, {_, sig}} ->
String.starts_with?(sig.name, domain_prefix) or sig.name == domain
end)
|> Enum.map_join(",\n", fn {_k, {_, sig}} ->
signal_name =
if String.starts_with?(sig.name, domain_prefix),
do: String.replace_prefix(sig.name, domain_prefix, ""),
else: sig.name
"""
on_#{signal_name}: (
input: AlvaSignals["#{sig.name}"] extends { input: infer I } ? I : Record<string, never>,
callback: (payload: AlvaSignals["#{sig.name}"]["payload"]) => void
) => {
if (live) live.pushEvent("alva:subscribe_signal", { name: "#{sig.name}", input }, () => {});
useLiveEvent("#{sig.name}", callback);
onUnmounted(() => {
if (live) live.pushEvent("alva:unsubscribe_signal", { name: "#{sig.name}", input }, () => {});
});
},
use_#{signal_name}_state: (
input: AlvaSignals["#{sig.name}"] extends { input: infer I } ? I : Record<string, never> = {} as any
) => {
const data = ref<AlvaSignals["#{sig.name}"]["payload"][]>([]);
const latest = ref<AlvaSignals["#{sig.name}"]["payload"] | null>(null);
const count = computed(() => data.value.length);
const clear = () => { data.value = []; latest.value = null; };
if (live) live.pushEvent("alva:subscribe_signal", { name: "#{sig.name}", input }, () => {});
useLiveEvent("#{sig.name}", (payload: any) => {
latest.value = payload;
data.value.unshift(payload);
});
onUnmounted(() => {
if (live) live.pushEvent("alva:unsubscribe_signal", { name: "#{sig.name}", input }, () => {});
});
return { data, latest, count, clear };
}
"""
end)
end
end