1import { createHash } from "node:crypto";
2import * as Effect from "effect/Effect";
3import { base32 } from ".//Util/base32.ts";
4import { InstanceId } from "./InstanceId.ts";
5import { Stack } from "./Stack.ts";
6import { Stage } from "./Stage.ts";
7
8// 8 base32 chars = 40 bits of the sha256 of the full untruncated name,
9// appended when truncation occurs so names that differ only in the truncated
10// portion of the prefix stay distinct.
11const TRUNCATION_HASH_LENGTH = 8;
12
13export const createPhysicalName = Effect.fn(function* ({
14 id,
15 prefix: _prefix,
16 // 16 base32 characters = 80 bits of entropy = 4 × 10⁻⁷
17 instanceId,
18 suffixLength = 16,
19 maxLength = 64,
20 delimiter = "-",
21 lowercase = false,
22 forbiddenPrefixes = [],
23}: {
24 id: string;
25 /**
26 * Prefix to add to the physical name.
27 *
28 * @default ${app.name}-${sanitizedId}-${app.stage}-
29 */
30 prefix?: string;
31 /**
32 * Hex-encoded instance ID (16 random bytes)
33 *
34 * @default - the InstanceID set by the engine in Context
35 */
36 instanceId?: string;
37 suffixLength?: number;
38 /**
39 * Maximum length of the physical name.
40 *
41 * If the name exceeds this length, the human-friendly prefix is truncated
42 * and a stable hash of the full name is kept alongside the instance suffix
43 * so distinct names never collapse to the same truncated string.
44 */
45 maxLength?: number;
46 /** @default - "-" */
47 delimiter?: string;
48 /** Whether to lowercase the physical name. @default false */
49 lowercase?: boolean;
50 /**
51 * Service-reserved name prefixes (matched case-insensitively). When the
52 * generated prefix would start with one of these — e.g. a stack named
53 * `aws-*` colliding with S3 Tables' reserved `aws` bucket prefix — a safe
54 * `x${delimiter}` is prepended so the name stays valid. Names that don't
55 * collide are unaffected, so existing deployments never rename.
56 */
57 forbiddenPrefixes?: string[];
58}) {
59 // Always generate DNS-compatible names (letters, numbers, and hyphens only).
60 // This ensures physical names work across all services including S3 buckets.
61 const sanitize = (name: string) =>
62 (lowercase ? name.toLowerCase() : name).replaceAll(
63 lowercase ? /[^a-z0-9-]/g : /[^a-zA-Z0-9-]/g,
64 delimiter,
65 );
66 const stack = yield* Stack;
67 const stage = yield* Stage;
68 let prefix =
69 _prefix ?? `${stack.name}${delimiter}${id}${delimiter}${stage}${delimiter}`;
70 if (
71 forbiddenPrefixes.some((forbidden) =>
72 prefix.toLowerCase().startsWith(forbidden.toLowerCase()),
73 )
74 ) {
75 prefix = `x${delimiter}${prefix}`;
76 }
77 const randomId = base32(
78 Buffer.from(instanceId ?? (yield* InstanceId), "hex"),
79 );
80 const suffix = randomId.slice(0, suffixLength);
81 const name = `${prefix}${suffix}`;
82 if (maxLength && name.length > maxLength) {
83 // The instance suffix alone cannot disambiguate: every name derived from
84 // the same resource shares one InstanceId, so two prefixes that differ
85 // only in their truncated tail (e.g. `…-task-role-` vs `…-execution-role-`)
86 // would collapse to the same string. Keep a stable hash of the full name
87 // next to the suffix so truncated names remain unique.
88 const hash = yield* Effect.sync(() =>
89 base32(createHash("sha256").update(name).digest()).slice(
90 0,
91 TRUNCATION_HASH_LENGTH,
92 ),
93 );
94 // The hash is what keeps same-resource names distinct, so it always
95 // survives in full; when maxLength is tight (e.g. DAX's 20-char limit)
96 // the instance suffix shrinks instead — 12 base32 chars still carry 60
97 // bits of instance entropy.
98 const tail = `${hash}${suffix}`.slice(0, maxLength);
99 return sanitize(
100 `${prefix.slice(0, Math.max(0, maxLength - tail.length))}${tail}`,
101 );
102 }
103 return sanitize(name);
104});