1import * as s3tables from "@distilled.cloud/aws/s3tables";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import type { TableBucketArn } from "./TableBucket.ts";
10
11export interface NamespaceProps {
12 /**
13 * ARN of the table bucket that owns the namespace. Changing it replaces
14 * the namespace.
15 */
16 tableBucket: TableBucketArn | string;
17 /**
18 * Name of the namespace. Must be 1-255 characters of lowercase letters,
19 * numbers, and underscores, beginning with a letter or number. Changing
20 * the name replaces the namespace.
21 * @default a deterministic name derived from the app, stage, and logical ID
22 */
23 namespace?: string;
24}
25
26export interface Namespace extends Resource<
27 "AWS.S3Tables.Namespace",
28 NamespaceProps,
29 {
30 tableBucketArn: string;
31 namespace: string;
32 namespaceId: string | undefined;
33 createdAt: Date;
34 createdBy: string;
35 ownerAccountId: string;
36 },
37 never,
38 Providers
39> {}
40
41/**
42 * A namespace within an Amazon S3 Tables {@link TableBucket} — a logical
43 * grouping of {@link Table}s, equivalent to a database in an Iceberg catalog.
44 * ### Creating Namespaces
45 * **Example:** Basic Namespace
46 * ```typescript
47 * import * as S3Tables from "alchemy/AWS/S3Tables";
48 *
49 * const bucket = yield* S3Tables.TableBucket("Analytics");
50 * const ns = yield* S3Tables.Namespace("Events", {
51 * tableBucket: bucket.tableBucketArn,
52 * });
53 * ```
54 *
55 * **Example:** Named Namespace
56 * ```typescript
57 * const ns = yield* S3Tables.Namespace("Events", {
58 * tableBucket: bucket.tableBucketArn,
59 * namespace: "raw_events",
60 * });
61 * ```
62 *
63 * @resource
64 */
65export const Namespace = Resource<Namespace>("AWS.S3Tables.Namespace");
66
67const createNamespaceName = (
68 id: string,
69 props: { namespace?: string | undefined },
70) =>
71 Effect.gen(function* () {
72 if (props.namespace) {
73 return props.namespace;
74 }
75 // Namespace names allow lowercase letters, numbers, and underscores only —
76 // no hyphens — so translate the DNS-style physical name. Namespace names
77 // must not start with the reserved prefix `aws`.
78 const base = yield* createPhysicalName({
79 id,
80 maxLength: 60,
81 lowercase: true,
82 forbiddenPrefixes: ["aws"],
83 });
84 return base.replaceAll("-", "_");
85 });
86
87export const NamespaceProvider = () =>
88 Provider.succeed(Namespace, {
89 stables: ["tableBucketArn", "namespace"],
90 // Namespaces are scoped to a parent table bucket; there is no ambient
91 // enumeration without a bucket ARN, so the engine drives lifecycle from
92 // state alone.
93 list: () => Effect.succeed([]),
94 read: Effect.fn(function* ({ id, olds, output }) {
95 const tableBucketArn = output?.tableBucketArn ?? olds?.tableBucket;
96 if (typeof tableBucketArn !== "string") return undefined;
97 const namespace =
98 output?.namespace ?? (yield* createNamespaceName(id, olds ?? {}));
99 return yield* s3tables
100 .getNamespace({ tableBucketARN: tableBucketArn, namespace })
101 .pipe(
102 Effect.map((n): Namespace["Attributes"] => ({
103 tableBucketArn,
104 namespace: n.namespace[0] ?? namespace,
105 namespaceId: n.namespaceId,
106 createdAt: n.createdAt,
107 createdBy: n.createdBy,
108 ownerAccountId: n.ownerAccountId,
109 })),
110 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
111 );
112 }),
113 diff: Effect.fn(function* ({ id, news, olds }) {
114 if (!isResolved(news)) return;
115 if (news.tableBucket !== olds?.tableBucket) {
116 return { action: "replace" } as const;
117 }
118 const oldName = yield* createNamespaceName(id, olds ?? {});
119 const newName = yield* createNamespaceName(id, news);
120 if (oldName !== newName) {
121 return { action: "replace" } as const;
122 }
123 }),
124 reconcile: Effect.fn(function* ({ id, news, output, session }) {
125 const tableBucketArn = news.tableBucket as string;
126 const namespace =
127 output?.namespace ?? (yield* createNamespaceName(id, news));
128
129 // Observe — read live state; the namespace may have been removed
130 // out-of-band even if `output` cached it.
131 let ns = yield* s3tables
132 .getNamespace({ tableBucketARN: tableBucketArn, namespace })
133 .pipe(
134 Effect.map((n) => n),
135 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
136 );
137
138 // Ensure — create if missing, tolerating a concurrent create.
139 if (ns === undefined) {
140 yield* s3tables
141 .createNamespace({
142 tableBucketARN: tableBucketArn,
143 namespace: [namespace],
144 })
145 .pipe(
146 Effect.asVoid,
147 Effect.catchTag("ConflictException", () => Effect.void),
148 );
149 // Eventual consistency: getNamespace can briefly 404 a namespace that
150 // createNamespace just returned.
151 ns = yield* s3tables
152 .getNamespace({ tableBucketARN: tableBucketArn, namespace })
153 .pipe(
154 Effect.retry({
155 while: (e) => e._tag === "NotFoundException",
156 schedule: Schedule.max([
157 Schedule.exponential(500),
158 Schedule.recurs(8),
159 ]),
160 }),
161 );
162 }
163
164 yield* session.note(namespace);
165 return {
166 tableBucketArn,
167 namespace: ns.namespace[0] ?? namespace,
168 namespaceId: ns.namespaceId,
169 createdAt: ns.createdAt,
170 createdBy: ns.createdBy,
171 ownerAccountId: ns.ownerAccountId,
172 };
173 }),
174 delete: Effect.fn(function* ({ output }) {
175 yield* s3tables
176 .deleteNamespace({
177 tableBucketARN: output.tableBucketArn,
178 namespace: output.namespace,
179 })
180 .pipe(
181 // Table deletes are eventually consistent; a namespace delete that
182 // races them reports ConflictException — ride out the window.
183 Effect.retry({
184 while: (e) => e._tag === "ConflictException",
185 schedule: Schedule.max([
186 Schedule.exponential(500),
187 Schedule.recurs(8),
188 ]),
189 }),
190 Effect.catchTag("NotFoundException", () => Effect.void),
191 );
192 }),
193 });