1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import * as Provider from "./Provider.ts";
4import { Resource } from "./Resource.ts";
5
6export interface RandomProps {
7 /**
8 * Number of random bytes to generate before hex encoding.
9 * @default 32
10 */
11 bytes?: number;
12}
13
14export type Random = Resource<
15 "Alchemy.Random",
16 RandomProps,
17 {
18 text: Redacted.Redacted<string>;
19 }
20>;
21
22export const makeRandom = (id: string, props?: RandomProps) =>
23 Random(id, props).pipe(Effect.map((rand) => rand.text));
24
25/**
26 * A deterministic-in-state random secret generator.
27 *
28 * The value is generated once on create and then persisted in state so
29 * subsequent deploys keep the same secret unless the resource is replaced.
30 */
31export const Random = Resource<Random>("Alchemy.Random");
32
33export const RandomProvider = () =>
34 Provider.succeed(Random, {
35 reconcile: ({ news = {}, output }) =>
36 Effect.sync(() => {
37 // Observe — there is no remote state. The cached `output.text` is
38 // the authoritative current value; once minted it is preserved
39 // across reconciles to keep the secret stable.
40 if (output?.text) {
41 return output;
42 }
43
44 // Ensure — no observed value: mint a fresh random secret and
45 // return it. The next reconcile will see this in `output` and
46 // short-circuit above.
47 const byteLength = news.bytes ?? 32;
48 const bytes = new Uint8Array(byteLength);
49 crypto.getRandomValues(bytes);
50 return {
51 text: Redacted.make(
52 Array.from(bytes)
53 .map((b) => b.toString(16).padStart(2, "0"))
54 .join(""),
55 ),
56 };
57 }),
58 delete: () => Effect.void,
59 read: ({ output }) => Effect.succeed(output),
60 // Non-listable: the value is generated client-side with
61 // `crypto.getRandomValues` and lives only in alchemy state. There is no
62 // remote service to enumerate, so listing yields nothing.
63 list: () => Effect.succeed([]),
64 });