1import * as Effect from "effect/Effect";
2import { Stack } from "../Stack.ts";
3import { createInternalTags, diffTags, hasTags, type Tags } from "../Tags.ts";
4
5/**
6 * Stripe metadata keys may only contain alphanumeric characters, dashes,
7 * and underscores (max 40 chars). Alchemy ownership tags use `alchemy::stack`
8 * / `alchemy::stage` / `alchemy::id` — map those onto `alchemy_stack` /
9 * `alchemy_stage` / `alchemy_id` so they survive the Stripe API.
10 */
11export const ALCHEMY_METADATA_PREFIX = "alchemy_";
12
13export const alchemyMetadataKeys = {
14 stack: "alchemy_stack",
15 stage: "alchemy_stage",
16 id: "alchemy_id",
17} as const;
18
19const TAG_PREFIX = "alchemy::";
20
21export const toMetadataKey = (tagKey: string): string =>
22 tagKey.startsWith(TAG_PREFIX)
23 ? `${ALCHEMY_METADATA_PREFIX}${tagKey.slice(TAG_PREFIX.length)}`
24 : tagKey;
25
26export const toTagKey = (metadataKey: string): string =>
27 metadataKey.startsWith(ALCHEMY_METADATA_PREFIX)
28 ? `${TAG_PREFIX}${metadataKey.slice(ALCHEMY_METADATA_PREFIX.length)}`
29 : metadataKey;
30
31/** Stripe metadata values are capped at 500 characters. */
32export const sanitizeMetadataValue = (value: string): string =>
33 value.slice(0, 500);
34
35export const toMetadata = (
36 tags: Record<string, string> | null | undefined,
37): Record<string, string> =>
38 Object.fromEntries(
39 Object.entries(tags ?? {}).map(([key, value]) => [
40 toMetadataKey(key).slice(0, 40),
41 sanitizeMetadataValue(value),
42 ]),
43 );
44
45export const fromMetadata = (
46 metadata: Record<string, string> | null | undefined,
47): Record<string, string> =>
48 Object.fromEntries(
49 Object.entries(metadata ?? {}).map(([key, value]) => [
50 toTagKey(key),
51 value,
52 ]),
53 );
54
55export const createInternalMetadata = Effect.fn(function* (id: string) {
56 return toMetadata(yield* createInternalTags(id));
57});
58
59export const stripInternalMetadata = (
60 metadata: Record<string, string> | null | undefined,
61): Record<string, string> =>
62 Object.fromEntries(
63 Object.entries(metadata ?? {}).filter(
64 ([key]) => !key.startsWith(ALCHEMY_METADATA_PREFIX),
65 ),
66 );
67
68export const hasAlchemyMetadata = Effect.fn(function* (
69 id: string,
70 metadata: Tags | undefined,
71) {
72 const expected = yield* createInternalMetadata(id);
73 return hasTags(expected, metadata);
74});
75
76/**
77 * Diff observed Stripe metadata against desired metadata. Always pass
78 * **observed** metadata as `oldTags` — never `olds.metadata` or
79 * `output.metadata` — so adoption converges.
80 */
81export const diffMetadata = diffTags;
82
83/**
84 * Stripe Search query matching Alchemy-owned objects on the current stack.
85 * Example: `metadata["alchemy_stack"]:"Nuke"`
86 *
87 * `list()` for nuke must still enumerate every object whose metadata has
88 * `alchemy_stack` (account-wide), not only the current stack name.
89 */
90export const alchemyStackSearchQuery = Effect.fn(function* () {
91 const stack = yield* Stack;
92 return `metadata["${alchemyMetadataKeys.stack}"]:"${stack.name}"`;
93});