1import * as schemas from "@distilled.cloud/aws/schemas";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
9import type { Providers } from "../Providers.ts";
10import { canonicalJson, syncSchemasTags } from "./internal.ts";
11
12export interface SchemaProps {
13 /**
14 * The name of the registry the schema belongs to. Changing it replaces the
15 * schema.
16 */
17 registryName: string;
18
19 /**
20 * Name of the schema. Must match `[a-zA-Z0-9-_.@]+` and be at most 385
21 * characters. If omitted, a unique name is generated. Changing it replaces
22 * the schema.
23 */
24 schemaName?: string;
25
26 /**
27 * The type of the schema document. Changing it replaces the schema.
28 * @default "OpenApi3"
29 */
30 type?: "OpenApi3" | "JSONSchemaDraft4";
31
32 /**
33 * The schema document as a JSON string (an OpenAPI 3 document or a
34 * JSONSchema Draft 4 document, depending on `type`). Updating the content
35 * publishes a new schema version.
36 */
37 content: string;
38
39 /**
40 * A description of the schema.
41 */
42 description?: string;
43
44 /**
45 * User tags to attach to the schema.
46 */
47 tags?: Record<string, string>;
48}
49
50export interface Schema extends Resource<
51 "AWS.Schemas.Schema",
52 SchemaProps,
53 {
54 /** The name of the registry the schema belongs to. */
55 registryName: string;
56 /** The name of the schema. */
57 schemaName: string;
58 /** The ARN of the schema. */
59 schemaArn: string;
60 /** The version of the schema currently described by `content`. */
61 schemaVersion: string;
62 /** The type of the schema document. */
63 type: string;
64 },
65 never,
66 Providers
67> {}
68
69/**
70 * An event schema in an EventBridge Schema Registry — a versioned OpenAPI 3
71 * or JSONSchema Draft 4 document describing the structure of an event.
72 * Updating the content publishes a new schema version; previous versions are
73 * retained by the registry.
74 *
75 * ### Creating a Schema
76 * **Example:** OpenAPI 3 Schema
77 * ```typescript
78 * const registry = yield* AWS.Schemas.Registry("app-events", {});
79 *
80 * const schema = yield* AWS.Schemas.Schema("OrderCreated", {
81 * registryName: registry.registryName,
82 * type: "OpenApi3",
83 * content: JSON.stringify({
84 * openapi: "3.0.0",
85 * info: { version: "1.0.0", title: "OrderCreated" },
86 * paths: {},
87 * components: {
88 * schemas: {
89 * OrderCreated: {
90 * type: "object",
91 * properties: { orderId: { type: "string" } },
92 * },
93 * },
94 * },
95 * }),
96 * });
97 * ```
98 *
99 * **Example:** JSONSchema Draft 4 Schema
100 * ```typescript
101 * const schema = yield* AWS.Schemas.Schema("UserSignedUp", {
102 * registryName: registry.registryName,
103 * type: "JSONSchemaDraft4",
104 * content: JSON.stringify({
105 * $schema: "http://json-schema.org/draft-04/schema#",
106 * type: "object",
107 * properties: { userId: { type: "string" } },
108 * }),
109 * description: "Emitted when a user completes sign-up",
110 * });
111 * ```
112 *
113 * @resource
114 */
115export const Schema = Resource<Schema>("AWS.Schemas.Schema");
116
117export const SchemaProvider = () =>
118 Provider.effect(
119 Schema,
120 Effect.gen(function* () {
121 const createName = Effect.fn(function* (id: string, props: SchemaProps) {
122 return (
123 props.schemaName ??
124 (yield* createPhysicalName({ id, maxLength: 385 }))
125 );
126 });
127
128 const describe = (registryName: string, schemaName: string) =>
129 schemas
130 .describeSchema({
131 RegistryName: registryName,
132 SchemaName: schemaName,
133 })
134 .pipe(
135 Effect.catchTag("NotFoundException", () =>
136 Effect.succeed(undefined),
137 ),
138 );
139
140 return Schema.Provider.of({
141 stables: ["registryName", "schemaName", "schemaArn"],
142
143 // Sub-resource keyed by its parent registry — listSchemas requires a
144 // RegistryName, so there is no account-wide enumeration.
145 list: () => Effect.succeed([]),
146
147 read: Effect.fn(function* ({ id, olds, output }) {
148 const registryName = output?.registryName ?? olds?.registryName;
149 if (registryName === undefined) return undefined;
150 const schemaName =
151 output?.schemaName ??
152 (yield* createName(id, olds ?? { registryName, content: "" }));
153 const found = yield* describe(registryName, schemaName);
154 if (!found) return undefined;
155 const attrs = {
156 registryName,
157 schemaName,
158 schemaArn: found.SchemaArn!,
159 schemaVersion: found.SchemaVersion!,
160 type: found.Type ?? "OpenApi3",
161 };
162 return (yield* hasAlchemyTags(id, found.Tags))
163 ? attrs
164 : Unowned(attrs);
165 }),
166
167 diff: Effect.fn(function* ({ id, news, olds }) {
168 if (!isResolved(news)) return undefined;
169 if (news.registryName !== olds.registryName) {
170 return { action: "replace" } as const;
171 }
172 const oldName = yield* createName(id, olds);
173 const newName = yield* createName(id, news);
174 if (oldName !== newName) return { action: "replace" } as const;
175 if ((news.type ?? "OpenApi3") !== (olds.type ?? "OpenApi3")) {
176 return { action: "replace" } as const;
177 }
178 return undefined;
179 }),
180
181 reconcile: Effect.fn(function* ({ id, news, output, session }) {
182 const registryName = news.registryName;
183 const schemaName =
184 output?.schemaName ?? (yield* createName(id, news));
185 const internalTags = yield* createInternalTags(id);
186 const desiredType = news.type ?? "OpenApi3";
187
188 // OBSERVE
189 let live = yield* describe(registryName, schemaName);
190
191 // ENSURE
192 if (live === undefined) {
193 yield* schemas.createSchema({
194 RegistryName: registryName,
195 SchemaName: schemaName,
196 Type: desiredType,
197 Content: news.content,
198 Description: news.description,
199 Tags: { ...news.tags, ...internalTags },
200 });
201 live = yield* schemas.describeSchema({
202 RegistryName: registryName,
203 SchemaName: schemaName,
204 });
205 }
206
207 // SYNC content / type / description — diff observed against desired.
208 // Content is compared canonically (sorted keys, no whitespace) so a
209 // reformatted-but-equal document does not publish a new version.
210 const contentChanged =
211 canonicalJson(news.content) !== canonicalJson(live.Content ?? "") ||
212 desiredType !== (live.Type ?? "OpenApi3");
213 const descriptionChanged =
214 (news.description ?? "") !== (live.Description ?? "");
215 if (contentChanged || descriptionChanged) {
216 yield* schemas.updateSchema({
217 RegistryName: registryName,
218 SchemaName: schemaName,
219 ...(contentChanged
220 ? { Content: news.content, Type: desiredType }
221 : {}),
222 ...(descriptionChanged
223 ? { Description: news.description ?? "" }
224 : {}),
225 });
226 live = yield* schemas.describeSchema({
227 RegistryName: registryName,
228 SchemaName: schemaName,
229 });
230 }
231
232 // SYNC tags — diff against observed cloud tags.
233 yield* syncSchemasTags(live.SchemaArn!, id, news.tags);
234
235 yield* session.note(`${registryName}/${schemaName}`);
236 return {
237 registryName,
238 schemaName,
239 schemaArn: live.SchemaArn!,
240 schemaVersion: live.SchemaVersion!,
241 type: live.Type ?? desiredType,
242 };
243 }),
244
245 delete: Effect.fn(function* ({ output }) {
246 yield* schemas
247 .deleteSchema({
248 RegistryName: output.registryName,
249 SchemaName: output.schemaName,
250 })
251 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
252 }),
253 });
254 }),
255 );