1import * as finspace from "@distilled.cloud/aws/finspace";
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 {
9 createInternalTags,
10 diffTags,
11 hasAlchemyTags,
12 type Tags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15
16export interface KxDatabaseProps {
17 /**
18 * Identifier of the kdb environment the database lives in. Changing it
19 * replaces the database.
20 */
21 environmentId: string;
22 /**
23 * Name of the kdb database. Changing it replaces the database.
24 * @default ${app}-${id}-${stage}-${suffix}
25 */
26 databaseName?: string;
27 /**
28 * A description of the database.
29 */
30 description?: string;
31 /**
32 * Tags to associate with the database.
33 */
34 tags?: Record<string, string>;
35}
36
37export interface KxDatabase extends Resource<
38 "AWS.FinSpace.KxDatabase",
39 KxDatabaseProps,
40 {
41 /**
42 * Identifier of the kdb environment the database lives in.
43 */
44 environmentId: string;
45 /**
46 * The database's name.
47 */
48 databaseName: string;
49 /**
50 * ARN of the database.
51 */
52 databaseArn: string;
53 /**
54 * The database's description.
55 */
56 description: string | undefined;
57 /**
58 * Current tags reported for the database.
59 */
60 tags: Record<string, string>;
61 },
62 never,
63 Providers
64> {}
65
66/**
67 * A kdb database inside an Amazon FinSpace Managed kdb environment — the
68 * versioned, changeset-based store that kdb clusters mount and query.
69 *
70 * ### Creating kdb Databases
71 * **Example:** Basic kdb Database
72 * ```typescript
73 * import * as AWS from "alchemy/AWS";
74 *
75 * const env = yield* AWS.FinSpace.KxEnvironment("Kdb", { kmsKeyId });
76 * const db = yield* AWS.FinSpace.KxDatabase("Ticks", {
77 * environmentId: env.environmentId,
78 * description: "tick data",
79 * });
80 * ```
81 *
82 * @resource
83 */
84export const KxDatabase = Resource<KxDatabase>("AWS.FinSpace.KxDatabase");
85
86const createDatabaseName = (
87 id: string,
88 props: { databaseName?: string | undefined },
89) =>
90 props.databaseName
91 ? Effect.succeed(props.databaseName)
92 : createPhysicalName({ id, maxLength: 63 });
93
94const fetchDatabaseTags = Effect.fn(function* (arn: string) {
95 const response = yield* finspace
96 .listTagsForResource({ resourceArn: arn })
97 .pipe(
98 Effect.catchTag(
99 ["ResourceNotFoundException", "InvalidRequestException"],
100 () => Effect.succeed(undefined),
101 ),
102 );
103 return Object.fromEntries(
104 Object.entries(response?.tags ?? {}).flatMap(([key, value]) =>
105 value === undefined ? [] : [[key, value] as const],
106 ),
107 );
108});
109
110const readDatabase = Effect.fn(function* (
111 environmentId: string,
112 databaseName: string,
113) {
114 const response = yield* finspace
115 .getKxDatabase({ environmentId, databaseName })
116 .pipe(
117 Effect.catchTag("ResourceNotFoundException", () =>
118 Effect.succeed(undefined),
119 ),
120 );
121 if (!response) return undefined;
122 const databaseArn = response.databaseArn ?? "";
123 const attrs: KxDatabase["Attributes"] = {
124 environmentId: response.environmentId ?? environmentId,
125 databaseName: response.databaseName ?? databaseName,
126 databaseArn,
127 description: response.description,
128 tags: databaseArn ? yield* fetchDatabaseTags(databaseArn) : {},
129 };
130 return attrs;
131});
132
133export const KxDatabaseProvider = () =>
134 Provider.effect(
135 KxDatabase,
136 Effect.gen(function* () {
137 return {
138 stables: ["environmentId", "databaseName", "databaseArn"],
139 // Databases are keyed by their parent kdb environment — there is no
140 // account-wide enumeration without an environment id.
141 list: () => Effect.succeed([]),
142 read: Effect.fn(function* ({ id, olds, output }) {
143 const environmentId = output?.environmentId ?? olds?.environmentId;
144 if (environmentId === undefined) return undefined;
145 const databaseName =
146 output?.databaseName ?? (yield* createDatabaseName(id, olds ?? {}));
147 const attrs = yield* readDatabase(environmentId, databaseName);
148 if (!attrs) return undefined;
149 return (yield* hasAlchemyTags(id, attrs.tags as Tags))
150 ? attrs
151 : Unowned(attrs);
152 }),
153 diff: Effect.fn(function* ({ id, news, olds }) {
154 if (!isResolved(news)) return;
155 if (olds === undefined) return;
156 const oldName = yield* createDatabaseName(id, olds);
157 const newName = yield* createDatabaseName(id, news);
158 if (
159 olds.environmentId !== news.environmentId ||
160 oldName !== newName
161 ) {
162 return { action: "replace" } as const;
163 }
164 }),
165 reconcile: Effect.fn(function* ({ id, news, output, session }) {
166 if (!news) {
167 return yield* Effect.fail(
168 new Error("FinSpace KxDatabase requires props"),
169 );
170 }
171 const environmentId = news.environmentId;
172 const databaseName =
173 output?.databaseName ?? (yield* createDatabaseName(id, news));
174 const internalTags = yield* createInternalTags(id);
175 const desiredTags = { ...internalTags, ...news.tags };
176 // One idempotency token per reconcile, reused across create+update.
177 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
178
179 // Observe — cloud state is authoritative.
180 let attrs = yield* readDatabase(environmentId, databaseName);
181
182 // Ensure — create if missing; tolerate an AlreadyExists race.
183 if (attrs === undefined) {
184 yield* finspace
185 .createKxDatabase({
186 environmentId,
187 databaseName,
188 description: news.description,
189 tags: desiredTags,
190 clientToken,
191 })
192 .pipe(
193 Effect.catchTag("ResourceAlreadyExistsException", () =>
194 Effect.succeed(undefined),
195 ),
196 );
197 yield* session.note(`Created kdb database ${databaseName}`);
198 attrs = yield* readDatabase(environmentId, databaseName);
199 if (attrs === undefined) {
200 return yield* Effect.fail(
201 new Error(
202 `failed to read created kdb database ${databaseName}`,
203 ),
204 );
205 }
206 }
207
208 // Sync description — only call UpdateKxDatabase on drift.
209 if (
210 news.description !== undefined &&
211 news.description !== attrs.description
212 ) {
213 yield* finspace.updateKxDatabase({
214 environmentId,
215 databaseName,
216 description: news.description,
217 clientToken,
218 });
219 yield* session.note(`Updated kdb database ${databaseName}`);
220 }
221
222 // Sync tags — diff against observed cloud tags.
223 if (attrs.databaseArn) {
224 const { removed, upsert } = diffTags(attrs.tags, desiredTags);
225 if (removed.length > 0) {
226 yield* finspace.untagResource({
227 resourceArn: attrs.databaseArn,
228 tagKeys: removed,
229 });
230 }
231 if (upsert.length > 0) {
232 yield* finspace.tagResource({
233 resourceArn: attrs.databaseArn,
234 tags: Object.fromEntries(
235 upsert.map(({ Key, Value }) => [Key, Value]),
236 ),
237 });
238 }
239 }
240
241 yield* session.note(attrs.databaseArn);
242
243 const final = yield* readDatabase(environmentId, databaseName);
244 if (!final) {
245 return yield* Effect.fail(
246 new Error(
247 `failed to read reconciled kdb database ${databaseName}`,
248 ),
249 );
250 }
251 return final;
252 }),
253 delete: Effect.fn(function* ({ output }) {
254 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
255 yield* finspace
256 .deleteKxDatabase({
257 environmentId: output.environmentId,
258 databaseName: output.databaseName,
259 clientToken,
260 })
261 .pipe(
262 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
263 );
264 }),
265 };
266 }),
267 );