1import * as datalabeling from "@distilled.cloud/gcp/datalabeling_v1beta1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeOwnership,
13 findOwned,
14 hasOwnershipMarker,
15 ignoreGone,
16 listDatasets,
17 noRetryLayer,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 projectParent,
22 replaceOnIdentity,
23 retryDelete,
24 retryTransient,
25 sameText,
26 toDisplayName,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type DatasetProps = {
31 /**
32 * Dataset id (the `{dataset}` segment of
33 * `projects/{project}/datasets/{dataset}`). Server-assigned on create.
34 * Immutable — changing it replaces the dataset.
35 */
36 datasetId?: string;
37 /**
38 * Display name. Maximum 64 characters. Required by the API; Alchemy
39 * falls back to a generated name. Immutable — changing it replaces
40 * the dataset.
41 */
42 displayName?: string;
43 /**
44 * Human-readable description. Datasets have no labels field, so
45 * Alchemy stamps ownership into this field. Immutable — changing it
46 * replaces the dataset.
47 */
48 description?: string;
49};
50
51export type Dataset = Resource<
52 "GCP.DataLabeling.Dataset",
53 DatasetProps,
54 {
55 /** Full resource name `projects/{project}/datasets/{dataset}`. */
56 name: string;
57 /** Dataset id (last path segment). */
58 datasetId: string;
59 /** Project id. */
60 project: string;
61 /** Display name. */
62 displayName: string | undefined;
63 /** User description with the Alchemy ownership prefix stripped. */
64 description: string | undefined;
65 /** Number of data items in the dataset. */
66 dataItemCount: string | undefined;
67 /** Original import configs, if data has been imported. */
68 inputConfigs:
69 | datalabeling.GoogleCloudDatalabelingV1beta1InputConfigList
70 | undefined;
71 /** RFC3339 creation timestamp. */
72 createTime: string | undefined;
73 /** Last migration time to Vertex AI, if any. */
74 lastMigrateTime: string | undefined;
75 /** Related resources blocking changes. */
76 blockingResources: string[] | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * A Data Labeling dataset — a container for data items and annotated
84 * datasets produced by labeling tasks.
85 *
86 * Dataset ids are server-assigned. Display name and description are
87 * immutable after create. There is no labels API, so Alchemy stamps
88 * ownership into `description` so `list` / nuke can find them.
89 *
90 * ### Creating a Dataset
91 * **Example:** Generated display name
92 * ```typescript
93 * const dataset = yield* GCP.DataLabeling.Dataset("Images", {});
94 * ```
95 *
96 * **Example:** Named dataset with a description
97 * ```typescript
98 * const dataset = yield* GCP.DataLabeling.Dataset("Images", {
99 * displayName: "product-photos",
100 * description: "sku images for classification",
101 * });
102 * ```
103 *
104 * @resource
105 * @category DataLabeling
106 */
107export const Dataset = Resource<Dataset>("GCP.DataLabeling.Dataset");
108
109export class DatasetNotResolved extends Data.TaggedError(
110 "GCP.DataLabeling.DatasetNotResolved",
111)<{
112 name: string;
113}> {}
114
115const resourceName = (project: string, datasetId: string) =>
116 `${projectParent(project)}/datasets/${datasetId}`;
117
118const toAttrs = (
119 dataset: datalabeling.GoogleCloudDatalabelingV1beta1Dataset,
120 project: string,
121) => {
122 const name = dataset.name ?? "";
123 const parsed = parseResourceName(name, "datasets");
124 return {
125 name,
126 datasetId: parsed.id,
127 project: parsed.project || project,
128 displayName: dataset.displayName,
129 description: parseOwnership(dataset.description).text,
130 dataItemCount: dataset.dataItemCount,
131 inputConfigs: dataset.inputConfigs,
132 createTime: dataset.createTime,
133 lastMigrateTime: dataset.lastMigrateTime,
134 blockingResources: dataset.blockingResources,
135 };
136};
137
138const getByName = (name: string) =>
139 name.length === 0
140 ? Effect.succeed(undefined)
141 : datalabeling.getProjectsDatasets({ name }).pipe(
142 Effect.provide(noRetryLayer),
143 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
144 Effect.catchTag("BadGateway", () => Effect.succeed(undefined)),
145 );
146
147const findByOwnership = (id: string, project: string) =>
148 Effect.gen(function* () {
149 const rows = yield* listDatasets(projectParent(project));
150 return yield* findOwned(id, rows, (row) => row.description);
151 });
152
153export const DatasetProvider = () =>
154 Provider.succeed(Dataset, {
155 stables: ["name", "datasetId", "project", "createTime"],
156
157 diff: Effect.fn(function* ({ news, olds, output }) {
158 if (!isResolved(news)) return undefined;
159 const extra =
160 (news.displayName !== undefined &&
161 output?.displayName !== undefined &&
162 !sameText(news.displayName, output.displayName)) ||
163 (olds !== undefined &&
164 !sameText(news.description, output?.description));
165 return replaceOnIdentity({
166 previousId: olds?.datasetId ?? output?.datasetId,
167 nextId: news.datasetId,
168 extra,
169 });
170 }),
171
172 read: Effect.fn(function* ({ id, olds, output }) {
173 const env = yield* GcpEnvironment.current;
174 const datasetId =
175 olds?.datasetId ??
176 output?.datasetId ??
177 (output?.name ? parseResourceName(output.name, "datasets").id : "");
178 const name =
179 output?.name ??
180 (datasetId.length > 0 ? resourceName(env.project, datasetId) : "");
181 const existing =
182 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
183 if (existing === undefined) return undefined;
184 const attrs = toAttrs(existing, env.project);
185 return (yield* ownedByAlchemy(id, existing.description))
186 ? attrs
187 : Unowned(attrs);
188 }),
189
190 list: () =>
191 Effect.gen(function* () {
192 const env = yield* GcpEnvironment.current;
193 const rows = yield* listDatasets(projectParent(env.project));
194 return rows
195 .filter((row) => hasOwnershipMarker(row.description))
196 .map((row) => toAttrs(row, env.project));
197 }),
198
199 reconcile: Effect.fn(function* ({ id, news, output }) {
200 const env = yield* GcpEnvironment.current;
201 const datasetId = news.datasetId ?? output?.datasetId;
202 const name =
203 output?.name ??
204 (datasetId !== undefined ? resourceName(env.project, datasetId) : "");
205 const ownership = yield* createInternalLabels(id);
206 const displayName = yield* toDisplayName(
207 id,
208 news.displayName,
209 output?.displayName,
210 );
211 const description = encodeOwnership(ownership, news.description);
212
213 let current =
214 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
215
216 if (current === undefined) {
217 const created = yield* retryTransient(
218 datalabeling.createProjectsDatasets({
219 parent: projectParent(env.project),
220 body: {
221 dataset: {
222 displayName,
223 description,
224 },
225 },
226 }),
227 ).pipe(
228 Effect.catchTag("Conflict", () => findByOwnership(id, env.project)),
229 );
230 current = created ?? undefined;
231 }
232
233 if (current === undefined) {
234 return yield* new DatasetNotResolved({
235 name: name || projectParent(env.project),
236 });
237 }
238
239 return toAttrs(current, env.project);
240 }),
241
242 delete: Effect.fn(function* ({ output }) {
243 if (!output.name) return;
244 yield* ignoreGone(
245 retryDelete(datalabeling.deleteProjectsDatasets({ name: output.name })),
246 );
247 yield* waitUntilGone(getByName(output.name));
248 }),
249 });