1import * as neptunegraph from "@distilled.cloud/aws/neptune-graph";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface GraphProps {
15 /**
16 * Name of the graph (lowercase letters, numbers, and hyphens; must start
17 * with a letter). If omitted, a deterministic name is generated.
18 * Changing it forces replacement.
19 */
20 graphName?: string;
21 /**
22 * Provisioned memory-optimized Neptune Capacity Units (m-NCUs) for the
23 * graph, e.g. `16`, `32`, `64`. In-place modify.
24 */
25 provisionedMemory: number;
26 /**
27 * Whether the graph endpoint is reachable from the public internet
28 * (requests are still IAM-authenticated). In-place modify.
29 * @default false
30 */
31 publicConnectivity?: boolean;
32 /**
33 * Number of replicas in other AZs. Replicas incur additional cost.
34 * Immutable — forces replacement.
35 * @default 1
36 */
37 replicaCount?: number;
38 /**
39 * KMS key used to encrypt data in the graph. Immutable — forces
40 * replacement.
41 */
42 kmsKeyIdentifier?: string;
43 /**
44 * Vector-search configuration (dimension of vectors, 1-65535). Immutable —
45 * forces replacement.
46 */
47 vectorSearchConfiguration?: {
48 /**
49 * Number of dimensions per vector.
50 */
51 dimension: number;
52 };
53 /**
54 * Block accidental deletion. In-place modify.
55 * @default true
56 */
57 deletionProtection?: boolean;
58 /**
59 * User-defined tags.
60 */
61 tags?: Record<string, string>;
62}
63
64export interface Graph extends Resource<
65 "AWS.NeptuneGraph.Graph",
66 GraphProps,
67 {
68 /** Server-assigned unique id of the graph (e.g. `g-abc123`). */
69 graphId: string;
70 /** Name of the graph. */
71 graphName: string;
72 /** ARN of the graph. */
73 graphArn: string;
74 /** HTTPS query endpoint of the graph. */
75 endpoint: string | undefined;
76 /** Current lifecycle status (e.g. `CREATING`, `AVAILABLE`). */
77 status: string | undefined;
78 /** Provisioned memory in m-NCUs. */
79 provisionedMemory: number | undefined;
80 /** Whether the endpoint is reachable from the public internet. */
81 publicConnectivity: boolean | undefined;
82 /** Number of read replicas. */
83 replicaCount: number | undefined;
84 /** KMS key encrypting the graph, if customer-managed. */
85 kmsKeyIdentifier: string | undefined;
86 /** Vector search embedding dimension, if enabled. */
87 vectorSearchDimension: number | undefined;
88 /** Whether deletion protection is enabled. */
89 deletionProtection: boolean | undefined;
90 /** Creation time of the graph (ISO 8601). */
91 createTime: string | undefined;
92 /** Tags on the graph (user + internal Alchemy tags). */
93 tags: Record<string, string>;
94 },
95 never,
96 Providers
97> {}
98
99/**
100 * An Amazon Neptune Analytics graph — a serverless, memory-optimized graph
101 * that serves openCypher queries over an IAM-authenticated HTTPS endpoint.
102 * Unlike classic Neptune, a graph with `publicConnectivity: true` needs no
103 * VPC plumbing at all. Provisioning takes several minutes and the graph
104 * bills per m-NCU-hour while it exists.
105 *
106 * Mutable fields (`provisionedMemory`, `publicConnectivity`,
107 * `deletionProtection`) are reconciled in place; immutable fields
108 * (`replicaCount`, `kmsKeyIdentifier`, `vectorSearchConfiguration`) force a
109 * replacement.
110 * ### Creating a Graph
111 * **Example:** Publicly reachable analytics graph
112 * ```typescript
113 * const graph = yield* Graph("Knowledge", {
114 * provisionedMemory: 16,
115 * publicConnectivity: true,
116 * replicaCount: 0,
117 * deletionProtection: false,
118 * });
119 * ```
120 *
121 * ### Vector Search
122 * **Example:** Graph with vector search enabled
123 * ```typescript
124 * const graph = yield* Graph("Embeddings", {
125 * provisionedMemory: 16,
126 * vectorSearchConfiguration: { dimension: 1536 },
127 * });
128 * ```
129 *
130 * ### Querying
131 * **Example:** Query from a Lambda function via the ExecuteQuery binding
132 * ```typescript
133 * const executeQuery = yield* AWS.NeptuneGraph.ExecuteQuery(graph);
134 * const result = yield* executeQuery({
135 * queryString: "MATCH (n) RETURN count(n) AS n",
136 * language: "OPEN_CYPHER",
137 * });
138 * ```
139 *
140 * @resource
141 */
142export const Graph = Resource<Graph>("AWS.NeptuneGraph.Graph");
143
144type GraphOutput = neptunegraph.GetGraphOutput;
145
146/**
147 * The graph entered the terminal `FAILED` state during provisioning or a
148 * modification. Not retried.
149 */
150export class GraphProvisioningFailed extends Data.TaggedError(
151 "GraphProvisioningFailed",
152)<{
153 readonly graphId: string;
154 readonly reason: string;
155}> {}
156
157class GraphNotReady extends Data.TaggedError("GraphNotReady")<{
158 readonly graphId: string;
159 readonly status: string;
160}> {}
161
162const toTagRecord = (
163 tags: { [key: string]: string | undefined } | undefined,
164): Record<string, string> =>
165 Object.fromEntries(
166 Object.entries(tags ?? {}).filter(
167 (entry): entry is [string, string] => typeof entry[1] === "string",
168 ),
169 );
170
171const toAttrs = ({
172 graph,
173 tags,
174}: {
175 graph: GraphOutput;
176 tags: Record<string, string>;
177}): Graph["Attributes"] => ({
178 graphId: graph.id,
179 graphName: graph.name,
180 graphArn: graph.arn,
181 endpoint: graph.endpoint,
182 status: graph.status,
183 provisionedMemory: graph.provisionedMemory,
184 publicConnectivity: graph.publicConnectivity,
185 replicaCount: graph.replicaCount,
186 kmsKeyIdentifier: graph.kmsKeyIdentifier,
187 vectorSearchDimension: graph.vectorSearchConfiguration?.dimension,
188 deletionProtection: graph.deletionProtection,
189 createTime: graph.createTime?.toISOString(),
190 tags,
191});
192
193export const GraphProvider = () =>
194 Provider.effect(
195 Graph,
196 Effect.gen(function* () {
197 const toName = (id: string, props: GraphProps) =>
198 props.graphName
199 ? Effect.succeed(props.graphName)
200 : createPhysicalName({ id, maxLength: 63, lowercase: true });
201
202 const getGraph = Effect.fn(function* (graphId: string) {
203 return yield* neptunegraph
204 .getGraph({ graphIdentifier: graphId })
205 .pipe(
206 Effect.catchTag("ResourceNotFoundException", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210 });
211
212 // Graph names are unique per account/region — resolve a graph by name
213 // when no id is cached (state-persistence failure recovery).
214 const findGraphByName = Effect.fn(function* (name: string) {
215 const summary = yield* neptunegraph.listGraphs.items({}).pipe(
216 Stream.filter((graph) => graph.name === name),
217 Stream.runHead,
218 Effect.map(Option.getOrUndefined),
219 );
220 if (summary === undefined) return undefined;
221 return yield* getGraph(summary.id);
222 });
223
224 const observeGraph = Effect.fn(function* (
225 graphId: string | undefined,
226 name: string,
227 ) {
228 if (graphId !== undefined) {
229 const graph = yield* getGraph(graphId);
230 if (graph !== undefined) return graph;
231 }
232 return yield* findGraphByName(name);
233 });
234
235 const readTags = Effect.fn(function* (arn: string) {
236 const response = yield* neptunegraph
237 .listTagsForResource({ resourceArn: arn })
238 .pipe(
239 Effect.catchTag("ResourceNotFoundException", () =>
240 Effect.succeed(undefined),
241 ),
242 );
243 return toTagRecord(response?.tags);
244 });
245
246 // Explicitly-typed pipeable retry helper. Inlining `Effect.retry` in a
247 // provider lifecycle op leaks `Retry.Return`'s conditional into
248 // declaration emit and widens the provider layer to `unknown` R for
249 // every consumer of `AWS.providers()`.
250 const retryWhileGraphNotReady = <
251 A,
252 E extends { readonly _tag: string },
253 R,
254 >(
255 self: Effect.Effect<A, E, R>,
256 ): Effect.Effect<A, E, R> =>
257 Effect.retry(self, {
258 while: (e) => e._tag === "GraphNotReady",
259 schedule: Schedule.max([
260 Schedule.fixed("10 seconds"),
261 Schedule.recurs(90),
262 ]),
263 });
264
265 // Bounded readiness wait: graph creation/modification takes several
266 // minutes. Budgets ~15 min (90 * 10s). Fails fast on FAILED.
267 const waitForGraph = Effect.fn(function* (graphId: string) {
268 return yield* retryWhileGraphNotReady(
269 Effect.gen(function* () {
270 const graph = yield* getGraph(graphId);
271 if (graph === undefined) {
272 return yield* Effect.fail(
273 new GraphNotReady({ graphId, status: "missing" }),
274 );
275 }
276 if (graph.status === "FAILED") {
277 return yield* Effect.fail(
278 new GraphProvisioningFailed({
279 graphId,
280 reason: graph.statusReason ?? "unknown",
281 }),
282 );
283 }
284 if (graph.status !== "AVAILABLE") {
285 return yield* Effect.fail(
286 new GraphNotReady({
287 graphId,
288 status: graph.status ?? "unknown",
289 }),
290 );
291 }
292 return graph;
293 }),
294 );
295 });
296
297 return {
298 stables: ["graphId", "graphArn", "graphName"],
299 // AWS account/region collection: enumerate every Neptune Analytics
300 // graph via the paginated `listGraphs`. Summaries omit create time,
301 // vector-search config, and tags — emit undefined/empty for those
302 // rather than a per-item `getGraph`/`listTagsForResource` fan-out.
303 list: () =>
304 neptunegraph.listGraphs.items({}).pipe(
305 Stream.runCollect,
306 Effect.map((chunk) =>
307 Array.from(chunk).map((graph) => ({
308 graphId: graph.id,
309 graphName: graph.name,
310 graphArn: graph.arn,
311 endpoint: graph.endpoint,
312 status: graph.status,
313 provisionedMemory: graph.provisionedMemory,
314 publicConnectivity: graph.publicConnectivity,
315 replicaCount: graph.replicaCount,
316 kmsKeyIdentifier: graph.kmsKeyIdentifier,
317 vectorSearchDimension: undefined,
318 deletionProtection: graph.deletionProtection,
319 createTime: undefined,
320 tags: {} as Record<string, string>,
321 })),
322 ),
323 ),
324 diff: Effect.fn(function* ({ id, olds, news }) {
325 if (!isResolved(news)) return undefined;
326 if (
327 (yield* toName(id, olds ?? { provisionedMemory: 0 })) !==
328 (yield* toName(id, news))
329 ) {
330 return { action: "replace" } as const;
331 }
332 // Immutable props — any change forces a fresh graph.
333 if (
334 olds !== undefined &&
335 (olds.replicaCount !== news.replicaCount ||
336 olds.kmsKeyIdentifier !== news.kmsKeyIdentifier ||
337 olds.vectorSearchConfiguration?.dimension !==
338 news.vectorSearchConfiguration?.dimension)
339 ) {
340 return { action: "replace" } as const;
341 }
342 }),
343 read: Effect.fn(function* ({ id, olds, output }) {
344 const name =
345 output?.graphName ??
346 (yield* toName(
347 id,
348 olds ?? ({ provisionedMemory: 0 } as GraphProps),
349 ));
350 const graph = yield* observeGraph(output?.graphId, name);
351 if (graph === undefined) {
352 return undefined;
353 }
354 const tags = yield* readTags(graph.arn);
355 return toAttrs({ graph, tags });
356 }),
357 reconcile: Effect.fn(function* ({ id, news, output, session }) {
358 const name = output?.graphName ?? (yield* toName(id, news));
359 const internalTags = yield* createInternalTags(id);
360 const desiredTags = { ...internalTags, ...news.tags };
361
362 // Observe — fetch live graph state (by cached id, else by name).
363 let observed = yield* observeGraph(output?.graphId, name);
364
365 // Ensure — create if missing. A ConflictException here means a
366 // peer reconciler won the create race — re-resolve by name.
367 if (observed === undefined) {
368 const created = yield* neptunegraph
369 .createGraph({
370 graphName: name,
371 provisionedMemory: news.provisionedMemory,
372 publicConnectivity: news.publicConnectivity,
373 replicaCount: news.replicaCount,
374 kmsKeyIdentifier: news.kmsKeyIdentifier,
375 vectorSearchConfiguration: news.vectorSearchConfiguration,
376 deletionProtection: news.deletionProtection,
377 tags: desiredTags,
378 })
379 .pipe(
380 Effect.catchTag("ConflictException", () =>
381 Effect.succeed(undefined),
382 ),
383 );
384 const graphId = created?.id ?? (yield* findGraphByName(name))?.id;
385 if (graphId === undefined) {
386 return yield* Effect.fail(
387 new Error(`Failed to create graph '${name}'`),
388 );
389 }
390 observed = yield* waitForGraph(graphId);
391 } else {
392 // Wait for the graph to settle before any modify so the call
393 // doesn't hit ConflictException(CONCURRENT_MODIFICATION).
394 observed = yield* waitForGraph(observed.id);
395
396 // Sync — single `updateGraph` carrying only the in-place fields
397 // whose desired value differs from the observed cloud state.
398 const update: Omit<
399 neptunegraph.UpdateGraphInput,
400 "graphIdentifier"
401 > = {};
402 let dirty = false;
403 if (
404 news.provisionedMemory !== undefined &&
405 news.provisionedMemory !== observed.provisionedMemory
406 ) {
407 update.provisionedMemory = news.provisionedMemory;
408 dirty = true;
409 }
410 if (
411 news.publicConnectivity !== undefined &&
412 news.publicConnectivity !== observed.publicConnectivity
413 ) {
414 update.publicConnectivity = news.publicConnectivity;
415 dirty = true;
416 }
417 if (
418 news.deletionProtection !== undefined &&
419 news.deletionProtection !== observed.deletionProtection
420 ) {
421 update.deletionProtection = news.deletionProtection;
422 dirty = true;
423 }
424 if (dirty) {
425 yield* neptunegraph.updateGraph({
426 graphIdentifier: observed.id,
427 ...update,
428 });
429 observed = yield* waitForGraph(observed.id);
430 }
431 }
432
433 // Sync tags — diff observed cloud tags against desired.
434 const observedTags = yield* readTags(observed.arn);
435 const { removed, upsert } = diffTags(observedTags, desiredTags);
436 if (upsert.length > 0) {
437 yield* neptunegraph.tagResource({
438 resourceArn: observed.arn,
439 tags: Object.fromEntries(
440 upsert.map(({ Key, Value }) => [Key, Value]),
441 ),
442 });
443 }
444 if (removed.length > 0) {
445 yield* neptunegraph.untagResource({
446 resourceArn: observed.arn,
447 tagKeys: removed,
448 });
449 }
450
451 yield* session.note(observed.arn);
452 return toAttrs({ graph: observed, tags: desiredTags });
453 }),
454 delete: Effect.fn(function* ({ output }) {
455 // Deletion is blocked while delete-protection is on — disable it
456 // first if the live graph still has it enabled.
457 const observed = yield* getGraph(output.graphId);
458 if (observed === undefined) return;
459 if (observed.deletionProtection === true) {
460 yield* neptunegraph
461 .updateGraph({
462 graphIdentifier: output.graphId,
463 deletionProtection: false,
464 })
465 .pipe(
466 Effect.catchTag("ResourceNotFoundException", () =>
467 Effect.succeed(undefined),
468 ),
469 );
470 }
471 yield* neptunegraph
472 .deleteGraph({
473 graphIdentifier: output.graphId,
474 skipSnapshot: true,
475 })
476 .pipe(
477 Effect.catchTag("ResourceNotFoundException", () =>
478 Effect.succeed(undefined),
479 ),
480 );
481 // Block until the graph is fully gone — deletion is async and the
482 // graph bills until it disappears.
483 yield* Effect.repeat(
484 neptunegraph.getGraph({ graphIdentifier: output.graphId }).pipe(
485 Effect.as(true),
486 Effect.catchTag("ResourceNotFoundException", () =>
487 Effect.succeed(false),
488 ),
489 ),
490 {
491 schedule: Schedule.max([
492 Schedule.fixed("10 seconds"),
493 Schedule.recurs(60),
494 ]),
495 until: (exists) => exists === false,
496 },
497 ).pipe(Effect.catch(() => Effect.void));
498 }),
499 };
500 }),
501 );