@microagi/alchemy-gcp 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +88 -0
  3. package/lib/Auth/AuthProvider.d.ts +48 -0
  4. package/lib/Auth/AuthProvider.d.ts.map +1 -0
  5. package/lib/Auth/AuthProvider.js +84 -0
  6. package/lib/Auth/AuthProvider.js.map +1 -0
  7. package/lib/Auth/Credentials.d.ts +28 -0
  8. package/lib/Auth/Credentials.d.ts.map +1 -0
  9. package/lib/Auth/Credentials.js +41 -0
  10. package/lib/Auth/Credentials.js.map +1 -0
  11. package/lib/CloudResourceManager/Project.d.ts +143 -0
  12. package/lib/CloudResourceManager/Project.d.ts.map +1 -0
  13. package/lib/CloudResourceManager/Project.js +272 -0
  14. package/lib/CloudResourceManager/Project.js.map +1 -0
  15. package/lib/CloudResourceManager/index.d.ts +3 -0
  16. package/lib/CloudResourceManager/index.d.ts.map +1 -0
  17. package/lib/CloudResourceManager/index.js +2 -0
  18. package/lib/CloudResourceManager/index.js.map +1 -0
  19. package/lib/Compute/GlobalAddress.d.ts +101 -0
  20. package/lib/Compute/GlobalAddress.d.ts.map +1 -0
  21. package/lib/Compute/GlobalAddress.js +134 -0
  22. package/lib/Compute/GlobalAddress.js.map +1 -0
  23. package/lib/Compute/Network.d.ts +114 -0
  24. package/lib/Compute/Network.d.ts.map +1 -0
  25. package/lib/Compute/Network.js +145 -0
  26. package/lib/Compute/Network.js.map +1 -0
  27. package/lib/Compute/Operations.d.ts +34 -0
  28. package/lib/Compute/Operations.d.ts.map +1 -0
  29. package/lib/Compute/Operations.js +62 -0
  30. package/lib/Compute/Operations.js.map +1 -0
  31. package/lib/Compute/SharedVpcHost.d.ts +44 -0
  32. package/lib/Compute/SharedVpcHost.d.ts.map +1 -0
  33. package/lib/Compute/SharedVpcHost.js +71 -0
  34. package/lib/Compute/SharedVpcHost.js.map +1 -0
  35. package/lib/Compute/SharedVpcServiceProject.d.ts +46 -0
  36. package/lib/Compute/SharedVpcServiceProject.d.ts.map +1 -0
  37. package/lib/Compute/SharedVpcServiceProject.js +96 -0
  38. package/lib/Compute/SharedVpcServiceProject.js.map +1 -0
  39. package/lib/Compute/Subnetwork.d.ts +108 -0
  40. package/lib/Compute/Subnetwork.d.ts.map +1 -0
  41. package/lib/Compute/Subnetwork.js +201 -0
  42. package/lib/Compute/Subnetwork.js.map +1 -0
  43. package/lib/Compute/index.d.ts +6 -0
  44. package/lib/Compute/index.d.ts.map +1 -0
  45. package/lib/Compute/index.js +6 -0
  46. package/lib/Compute/index.js.map +1 -0
  47. package/lib/Container/Cluster.d.ts +216 -0
  48. package/lib/Container/Cluster.d.ts.map +1 -0
  49. package/lib/Container/Cluster.js +326 -0
  50. package/lib/Container/Cluster.js.map +1 -0
  51. package/lib/Container/NodePool.d.ts +214 -0
  52. package/lib/Container/NodePool.d.ts.map +1 -0
  53. package/lib/Container/NodePool.js +344 -0
  54. package/lib/Container/NodePool.js.map +1 -0
  55. package/lib/Container/Operations.d.ts +50 -0
  56. package/lib/Container/Operations.d.ts.map +1 -0
  57. package/lib/Container/Operations.js +51 -0
  58. package/lib/Container/Operations.js.map +1 -0
  59. package/lib/Container/index.d.ts +5 -0
  60. package/lib/Container/index.d.ts.map +1 -0
  61. package/lib/Container/index.js +3 -0
  62. package/lib/Container/index.js.map +1 -0
  63. package/lib/Parallelstore/Instance.d.ts +119 -0
  64. package/lib/Parallelstore/Instance.d.ts.map +1 -0
  65. package/lib/Parallelstore/Instance.js +180 -0
  66. package/lib/Parallelstore/Instance.js.map +1 -0
  67. package/lib/Parallelstore/index.d.ts +2 -0
  68. package/lib/Parallelstore/index.d.ts.map +1 -0
  69. package/lib/Parallelstore/index.js +2 -0
  70. package/lib/Parallelstore/index.js.map +1 -0
  71. package/lib/Providers.d.ts +24 -0
  72. package/lib/Providers.d.ts.map +1 -0
  73. package/lib/Providers.js +51 -0
  74. package/lib/Providers.js.map +1 -0
  75. package/lib/ServiceNetworking/PsaConnection.d.ts +84 -0
  76. package/lib/ServiceNetworking/PsaConnection.d.ts.map +1 -0
  77. package/lib/ServiceNetworking/PsaConnection.js +130 -0
  78. package/lib/ServiceNetworking/PsaConnection.js.map +1 -0
  79. package/lib/ServiceNetworking/index.d.ts +2 -0
  80. package/lib/ServiceNetworking/index.d.ts.map +1 -0
  81. package/lib/ServiceNetworking/index.js +2 -0
  82. package/lib/ServiceNetworking/index.js.map +1 -0
  83. package/lib/ServiceUsage/ApiEnable.d.ts +78 -0
  84. package/lib/ServiceUsage/ApiEnable.d.ts.map +1 -0
  85. package/lib/ServiceUsage/ApiEnable.js +132 -0
  86. package/lib/ServiceUsage/ApiEnable.js.map +1 -0
  87. package/lib/ServiceUsage/index.d.ts +3 -0
  88. package/lib/ServiceUsage/index.d.ts.map +1 -0
  89. package/lib/ServiceUsage/index.js +2 -0
  90. package/lib/ServiceUsage/index.js.map +1 -0
  91. package/lib/Tags.d.ts +57 -0
  92. package/lib/Tags.d.ts.map +1 -0
  93. package/lib/Tags.js +106 -0
  94. package/lib/Tags.js.map +1 -0
  95. package/lib/index.d.ts +11 -0
  96. package/lib/index.d.ts.map +1 -0
  97. package/lib/index.js +10 -0
  98. package/lib/index.js.map +1 -0
  99. package/package.json +70 -0
  100. package/src/Auth/AuthProvider.ts +180 -0
  101. package/src/Auth/Credentials.ts +69 -0
  102. package/src/CloudResourceManager/Project.ts +517 -0
  103. package/src/CloudResourceManager/index.ts +2 -0
  104. package/src/Compute/GlobalAddress.ts +283 -0
  105. package/src/Compute/Network.ts +306 -0
  106. package/src/Compute/Operations.ts +108 -0
  107. package/src/Compute/SharedVpcHost.ts +141 -0
  108. package/src/Compute/SharedVpcServiceProject.ts +185 -0
  109. package/src/Compute/Subnetwork.ts +375 -0
  110. package/src/Compute/index.ts +5 -0
  111. package/src/Container/Cluster.ts +599 -0
  112. package/src/Container/NodePool.ts +620 -0
  113. package/src/Container/Operations.ts +88 -0
  114. package/src/Container/index.ts +8 -0
  115. package/src/Parallelstore/Instance.ts +371 -0
  116. package/src/Parallelstore/index.ts +1 -0
  117. package/src/Providers.ts +84 -0
  118. package/src/ServiceNetworking/PsaConnection.ts +269 -0
  119. package/src/ServiceNetworking/index.ts +1 -0
  120. package/src/ServiceUsage/ApiEnable.ts +267 -0
  121. package/src/ServiceUsage/index.ts +2 -0
  122. package/src/Tags.ts +130 -0
  123. package/src/index.ts +16 -0
@@ -0,0 +1,517 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as billing from "@distilled.cloud/gcp/cloudbilling-v1";
3
+ import * as crm from "@distilled.cloud/gcp/cloudresourcemanager-v3";
4
+ import { Resource } from "alchemy";
5
+ import { Unowned } from "alchemy/AdoptPolicy";
6
+ import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli";
7
+ import { isResolved } from "alchemy/Diff";
8
+ import { createPhysicalName } from "alchemy/PhysicalName";
9
+ import * as Provider from "alchemy/Provider";
10
+ import { diffTags } from "alchemy/Tags";
11
+ import * as Duration from "effect/Duration";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Schedule from "effect/Schedule";
14
+ import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
15
+ import type * as GCP from "../Providers.ts";
16
+
17
+ /**
18
+ * Parent for a GCP project — either a folder or an organization.
19
+ */
20
+ export type ProjectParent =
21
+ | { type: "folder"; id: string }
22
+ | { type: "organization"; id: string };
23
+
24
+ export type ProjectProps = {
25
+ /**
26
+ * Globally-unique project ID. 6-30 lowercase letters/digits/hyphens,
27
+ * must start with a letter, no trailing hyphens. If omitted, derived
28
+ * deterministically from the stack/stage/logical-id via
29
+ * `createPhysicalName`.
30
+ *
31
+ * Project IDs are immutable — changing this triggers a replacement.
32
+ */
33
+ projectId?: string;
34
+ /**
35
+ * User-visible display name. 4-30 chars, mutable.
36
+ */
37
+ displayName?: string;
38
+ /**
39
+ * The folder or organization that owns this project.
40
+ *
41
+ * Moving projects between parents is a separate API; treat as
42
+ * replace-only for now.
43
+ */
44
+ parent: ProjectParent;
45
+ /**
46
+ * User-supplied labels. Alchemy-internal labels (`alchemy_app`,
47
+ * `alchemy_stage`, `alchemy_id`) are merged in automatically and used
48
+ * to gate `read`-time adoption — do not set those keys yourself.
49
+ */
50
+ labels?: Record<string, string>;
51
+ /**
52
+ * Billing account to attach to the project. Must be the
53
+ * fully-qualified resource name `billingAccounts/{id}` (e.g.
54
+ * `billingAccounts/01A860-D25444-D4B584`) — that's what
55
+ * `cloudbilling.projects.updateBillingInfo` accepts in
56
+ * `billingAccountName`; passing the bare id returns `400 Request
57
+ * contains an invalid argument`. The prefix is enforced at the
58
+ * type level here so the wrong shape fails at compile time.
59
+ *
60
+ * Required for any paid API (GKE, Compute, etc.) — fresh projects
61
+ * have no billing account by default and refuse to enable paid
62
+ * services until one is attached.
63
+ *
64
+ * Mutable via `cloudbilling.projects.updateBillingInfo`. Set to
65
+ * `undefined` (omit) on a project that previously had billing
66
+ * attached and the resource will detach billing on next reconcile,
67
+ * which stops paid services on the project — be deliberate.
68
+ *
69
+ * The `cloudbilling.googleapis.com` API must be enabled on the
70
+ * caller's ADC quota project (typically the user's primary
71
+ * project), not on this target project. This is the same
72
+ * precondition as `cloudresourcemanager.googleapis.com` for project
73
+ * creation, so if you can create projects you can attach billing.
74
+ */
75
+ billingAccount?: BillingAccountName;
76
+ };
77
+
78
+ /**
79
+ * Fully-qualified billing-account resource name. The
80
+ * `billingAccounts/` prefix is the form GCP's APIs return and accept;
81
+ * encoding it at the type level rules out the bare-id mistake at
82
+ * compile time.
83
+ */
84
+ export type BillingAccountName = `billingAccounts/${string}`;
85
+
86
+ /**
87
+ * A GCP project under an organization or folder.
88
+ *
89
+ * Project creation is asynchronous: `createProjects` returns a
90
+ * long-running `Operation` which we poll until `done`. The same applies
91
+ * to `delete` and `patch`. Delete is soft (30-day window) on the
92
+ * server side; we don't undelete.
93
+ *
94
+ * Adoption: a `read` returning a project that lacks our `alchemy_*`
95
+ * labels is wrapped in `Unowned(attrs)`, so the engine fails the plan
96
+ * unless the user passed `--adopt`. This matters more for GCP than for
97
+ * R2 — projects carry billing.
98
+ *
99
+ * @section Creating a Project
100
+ * @example Project under a folder
101
+ * ```typescript
102
+ * const project = yield* GCP.Project("Research", {
103
+ * parent: { type: "folder", id: "264228230413" },
104
+ * displayName: "Research Cluster",
105
+ * });
106
+ * ```
107
+ *
108
+ * @example Project with an explicit ID
109
+ * ```typescript
110
+ * const project = yield* GCP.Project("Research", {
111
+ * projectId: "microagi-research-001",
112
+ * parent: { type: "folder", id: "264228230413" },
113
+ * });
114
+ * ```
115
+ */
116
+ export type Project = Resource<
117
+ "GCP.Project",
118
+ ProjectProps,
119
+ {
120
+ /** Globally-unique user-assigned id (e.g. `microagi-research-001`). */
121
+ projectId: string;
122
+ /**
123
+ * Server-assigned numeric id (e.g. `415104041262`). String at
124
+ * runtime — GCP encodes int64s as JSON strings to preserve
125
+ * precision — but typed as `` `${number}` `` so callers can
126
+ * compose project-number-form references (`PsaConnection`,
127
+ * `ParallelstoreInstance`, `networkRef`) without manual coercion.
128
+ */
129
+ projectNumber: `${number}`;
130
+ /** Resource name in the form `projects/{projectNumber}`. */
131
+ name: string;
132
+ /** Display name — may be `undefined` if never set. */
133
+ displayName: string | undefined;
134
+ /** Lifecycle state — `ACTIVE` for normal projects. */
135
+ state: string;
136
+ /** Parent resource string in the form `folders/123` / `organizations/456`. */
137
+ parent: string;
138
+ /** RFC3339 timestamp from the server. */
139
+ createTime: string | undefined;
140
+ /** Labels currently set on the project, including `alchemy_*` internals. */
141
+ labels: Record<string, string>;
142
+ /**
143
+ * Billing account currently attached to the project (e.g.
144
+ * `billingAccounts/XXXXXX-YYYYYY-ZZZZZZ`), or `undefined` when none
145
+ * is attached.
146
+ */
147
+ billingAccount: BillingAccountName | undefined;
148
+ /**
149
+ * Whether the project is associated with an *open* billing account.
150
+ * `false` when no billing account is attached or the attached
151
+ * account is closed; `true` once an open account is attached and
152
+ * paid services can be enabled.
153
+ */
154
+ billingEnabled: boolean;
155
+ },
156
+ never,
157
+ GCP.Providers
158
+ >;
159
+
160
+ export const Project = Resource<Project>("GCP.Project");
161
+
162
+ const parentToString = (parent: ProjectParent): string =>
163
+ parent.type === "folder" ? `folders/${parent.id}` : `organizations/${parent.id}`;
164
+
165
+ const createProjectId = (id: string, override: string | undefined) =>
166
+ Effect.gen(function* () {
167
+ if (override) return override;
168
+ // GCP project IDs: 6-30 chars, lowercase letters/digits/hyphens,
169
+ // must start with a letter. `createPhysicalName` already prepends
170
+ // the stack name (which conventionally starts with a letter), so
171
+ // the first-letter rule is satisfied by construction.
172
+ return (yield* createPhysicalName({ id, maxLength: 30 })).toLowerCase();
173
+ });
174
+
175
+ const toAttributes = (
176
+ p: crm.Project,
177
+ billingInfo: billing.ProjectBillingInfo | undefined,
178
+ ): Project["Attributes"] => ({
179
+ projectId: p.projectId!,
180
+ // GCP returns the project number as a digit-only string. The cast
181
+ // safely narrows from `string` to `` `${number}` `` — server-side
182
+ // contract guarantees the shape (the empty-string fallback is a
183
+ // belt-and-braces guard for malformed responses; downstream code
184
+ // observes a project state of "ACTIVE" before consuming this).
185
+ projectNumber: (p.name?.replace(/^projects\//, "") ?? "0") as `${number}`,
186
+ name: p.name ?? `projects/${p.projectId}`,
187
+ displayName: p.displayName,
188
+ state: p.state ?? "ACTIVE",
189
+ parent: p.parent ?? "",
190
+ createTime: p.createTime,
191
+ labels: { ...(p.labels ?? {}) },
192
+ // GCP returns `billingAccountName` as a plain string but its format
193
+ // is always `billingAccounts/{id}` — narrow at the boundary.
194
+ billingAccount: (billingInfo?.billingAccountName || undefined) as
195
+ | BillingAccountName
196
+ | undefined,
197
+ billingEnabled: billingInfo?.billingEnabled ?? false,
198
+ });
199
+
200
+ export const ProjectProvider = () =>
201
+ Provider.effect(
202
+ Project,
203
+ Effect.gen(function* () {
204
+ // Acquire SDK clients ONCE — Credentials and HttpClient are
205
+ // resolved at provider construction, not on every reconcile.
206
+ const getProjects = yield* crm.getProjects;
207
+ const createProjects = yield* crm.createProjects;
208
+ const patchProjects = yield* crm.patchProjects;
209
+ const deleteProjects = yield* crm.deleteProjects;
210
+ const getOperations = yield* crm.getOperations;
211
+ const getBillingInfo = yield* billing.getBillingInfoProjects;
212
+ const updateBillingInfo = yield* billing.updateBillingInfoProjects;
213
+
214
+ const awaitOperation = Effect.fn(function* (
215
+ operationName: string,
216
+ session: ScopedPlanStatusSession,
217
+ ) {
218
+ const op = yield* getOperations({ name: operationName }).pipe(
219
+ Effect.flatMap((current) =>
220
+ current.done === true
221
+ ? Effect.succeed(current)
222
+ : Effect.fail({ _tag: "OperationPending" as const }),
223
+ ),
224
+ Effect.retry({
225
+ while: (e: { _tag?: string }) => e?._tag === "OperationPending",
226
+ schedule: Schedule.exponential(Duration.seconds(1), 1.5).pipe(
227
+ Schedule.both(Schedule.recurs(60)),
228
+ Schedule.tapOutput(() =>
229
+ session.note(`Waiting for GCP operation ${operationName}…`),
230
+ ),
231
+ ),
232
+ }),
233
+ );
234
+
235
+ if (op.error) {
236
+ return yield* new ConfigError({
237
+ message: `GCP operation ${operationName} failed: ${
238
+ op.error.message ?? JSON.stringify(op.error)
239
+ }`,
240
+ });
241
+ }
242
+ return op;
243
+ });
244
+
245
+ const observeProject = (projectId: string) =>
246
+ getProjects({ name: `projects/${projectId}` }).pipe(
247
+ // GCP returns 403 Forbidden (not 404) when a project ID is
248
+ // unknown or invisible to the caller — a deliberate choice to
249
+ // avoid leaking project-ID existence across tenants. From this
250
+ // provider's perspective both shapes mean "not present", so
251
+ // observation should treat them identically.
252
+ Effect.catchTag("NotFound", () =>
253
+ Effect.succeed(undefined as crm.Project | undefined),
254
+ ),
255
+ Effect.catchTag("Forbidden", () =>
256
+ Effect.succeed(undefined as crm.Project | undefined),
257
+ ),
258
+ // A project marked for deletion is still readable but in
259
+ // `DELETE_REQUESTED` state. Treat as missing so the create
260
+ // path runs; if the projectId is still reserved server-side
261
+ // the create will fail with Conflict and surface a real
262
+ // error.
263
+ Effect.map((p) =>
264
+ p && p.state && p.state !== "ACTIVE" ? undefined : p,
265
+ ),
266
+ );
267
+
268
+ const requireProject = (projectId: string, context: string) =>
269
+ observeProject(projectId).pipe(
270
+ Effect.flatMap((p) =>
271
+ p
272
+ ? Effect.succeed(p)
273
+ : Effect.fail(
274
+ new ConfigError({
275
+ message: `Project ${projectId} ${context}.`,
276
+ }),
277
+ ),
278
+ ),
279
+ );
280
+
281
+ // Read current billing association. Returns `undefined` when the
282
+ // project is unknown to billing (NotFound) or invisible to the
283
+ // caller (Forbidden) — same 403→missing collapse as the project
284
+ // observe path.
285
+ const observeBilling = (projectId: string) =>
286
+ getBillingInfo({ name: `projects/${projectId}` }).pipe(
287
+ Effect.catchTag("NotFound", () =>
288
+ Effect.succeed(undefined as billing.ProjectBillingInfo | undefined),
289
+ ),
290
+ Effect.catchTag("Forbidden", () =>
291
+ Effect.succeed(undefined as billing.ProjectBillingInfo | undefined),
292
+ ),
293
+ );
294
+
295
+ // Attach / detach a billing account on the project. Pass empty
296
+ // string to detach (GCP's documented contract — see the long
297
+ // disclaimer on `updateBillingInfoProjects` warning that this
298
+ // disables paid services on the project). Synchronous PUT, no
299
+ // LRO involved.
300
+ const syncBilling = Effect.fn(function* (
301
+ projectId: string,
302
+ observed: billing.ProjectBillingInfo | undefined,
303
+ desired: string | undefined,
304
+ ) {
305
+ const observedAccount = observed?.billingAccountName ?? "";
306
+ const desiredAccount = desired ?? "";
307
+ if (observedAccount === desiredAccount) {
308
+ return observed;
309
+ }
310
+ return yield* updateBillingInfo({
311
+ name: `projects/${projectId}`,
312
+ body: { billingAccountName: desiredAccount },
313
+ }).pipe(
314
+ // Surface the most common BadRequest mode — the billing
315
+ // account's per-account project-attach quota — with a
316
+ // remediation pointer. Soft-deleted projects continue to
317
+ // hold quota for 30 days, so the typical fix is a quota
318
+ // increase rather than waiting it out. Other 400s
319
+ // (malformed account name, closed account, permission
320
+ // mismatch) propagate untouched so the underlying API
321
+ // message is preserved.
322
+ // Surface the most common BadRequest mode with a clear
323
+ // remediation pointer, and re-shape every BadRequest into
324
+ // ConfigError so callers see one error class regardless of
325
+ // the underlying GCP-side detail. The two patterns:
326
+ //
327
+ // 1. Billing-account project-attach quota exhausted —
328
+ // every soft-deleted project still holds quota for
329
+ // 30 days. Real fix is a quota increase request.
330
+ // 2. Other 400s (malformed account name, closed
331
+ // account, IAM mismatch on `billing.resourceAssociations.create`)
332
+ // — surface the underlying GCP message.
333
+ Effect.catchTag("BadRequest", (e) => {
334
+ // GCP returns the high-level message in `e.message` and
335
+ // stuffs the structured detail (e.g. `QuotaFailure`) into
336
+ // the response body's `details[]` array — which distilled
337
+ // doesn't surface (only `message` is parsed in
338
+ // `vendor/distilled/packages/gcp/src/client/api.ts:32-35`).
339
+ //
340
+ // The single most common 4xx on `updateBillingInfo` is
341
+ // FAILED_PRECONDITION ("Precondition check failed.") for
342
+ // the billing account's per-account project-attach quota.
343
+ // Each successful attach — including soft-deleted projects
344
+ // within their 30-day retention window — counts toward
345
+ // this quota. There is no way to detect this case with
346
+ // certainty from the surfaced error, so we always include
347
+ // the quota-remediation hint alongside the underlying
348
+ // message; users who hit a different 400 will still see
349
+ // GCP's real reason in the prefix.
350
+ const underlying = e.message ?? "unknown 400 from cloudbilling.updateBillingInfo";
351
+ const isLikelyQuota = /precondition|FAILED_PRECONDITION/i.test(underlying);
352
+ const hint = isLikelyQuota
353
+ ? " This is most often the billing account's project-attach quota — request an increase at https://support.google.com/code/contact/billing_quota_increase, or use a different billing account."
354
+ : "";
355
+ return Effect.fail(
356
+ new ConfigError({
357
+ message: `Cannot attach billing account ${desiredAccount} to project ${projectId}: ${underlying}.${hint}`,
358
+ }),
359
+ );
360
+ }),
361
+ );
362
+ });
363
+
364
+ const syncMutable = Effect.fn(function* (
365
+ observed: crm.Project,
366
+ desired: { displayName: string | undefined; labels: Record<string, string> },
367
+ session: ScopedPlanStatusSession,
368
+ ) {
369
+ const observedLabels = { ...(observed.labels ?? {}) };
370
+ const updateMaskFields: string[] = [];
371
+ if (desired.displayName !== observed.displayName) {
372
+ updateMaskFields.push("display_name");
373
+ }
374
+ const labelDiff = diffTags(observedLabels, desired.labels);
375
+ if (labelDiff.removed.length > 0 || labelDiff.upsert.length > 0) {
376
+ updateMaskFields.push("labels");
377
+ }
378
+
379
+ if (updateMaskFields.length === 0) return observed;
380
+
381
+ const op = yield* patchProjects({
382
+ name: observed.name!,
383
+ updateMask: updateMaskFields.join(","),
384
+ body: {
385
+ displayName: desired.displayName,
386
+ labels: desired.labels,
387
+ },
388
+ });
389
+ if (op.name) yield* awaitOperation(op.name, session);
390
+ return yield* requireProject(
391
+ observed.projectId!,
392
+ "disappeared after patch",
393
+ );
394
+ });
395
+
396
+ return {
397
+ stables: ["projectId", "projectNumber", "name"],
398
+ diff: Effect.fn(function* ({ id, news, olds = {}, output }) {
399
+ if (!isResolved(news)) return undefined;
400
+ const desiredId = yield* createProjectId(id, news.projectId);
401
+ const currentId =
402
+ output?.projectId ??
403
+ (yield* createProjectId(id, olds.projectId));
404
+ const desiredParent = parentToString(news.parent);
405
+ const observedParent =
406
+ output?.parent ??
407
+ (olds.parent ? parentToString(olds.parent) : desiredParent);
408
+
409
+ if (desiredId !== currentId || observedParent !== desiredParent) {
410
+ return { action: "replace" } as const;
411
+ }
412
+ }),
413
+ reconcile: Effect.fn(function* ({ id, news, session }) {
414
+ const projectId = yield* createProjectId(id, news.projectId);
415
+ const parent = parentToString(news.parent);
416
+ const internalLabels = yield* gcpInternalLabels(id);
417
+ const desiredLabels: Record<string, string> = {
418
+ ...(news.labels ?? {}),
419
+ ...internalLabels, // alchemy keys reserved — overwrite user values
420
+ };
421
+
422
+ // 1. Observe — cloud state is authoritative; `output`/`olds`
423
+ // are caches and may be stale or missing.
424
+ let observed = yield* observeProject(projectId);
425
+
426
+ // 2. Ensure — create if missing. `createProjects` returns an
427
+ // LRO; poll until done. `Conflict` covers two cases: a
428
+ // peer reconciler raced us, or a prior reconcile created
429
+ // the project but state-persistence failed. In both, the
430
+ // project exists — re-observe and converge.
431
+ if (!observed) {
432
+ const operation = yield* createProjects({
433
+ body: {
434
+ projectId,
435
+ parent,
436
+ displayName: news.displayName,
437
+ labels: desiredLabels,
438
+ },
439
+ }).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
440
+
441
+ if (operation?.name) yield* awaitOperation(operation.name, session);
442
+
443
+ observed = yield* requireProject(
444
+ projectId,
445
+ "did not appear after create",
446
+ );
447
+ }
448
+
449
+ // 3. Sync — displayName + labels are mutable. Diff against
450
+ // OBSERVED state (not `olds`) so adoption converges
451
+ // correctly: if a foreign label set is on the project,
452
+ // the patch will replace it with our desired set
453
+ // (including alchemy internals).
454
+ const synced = yield* syncMutable(
455
+ observed,
456
+ {
457
+ displayName: news.displayName ?? observed.displayName,
458
+ labels: desiredLabels,
459
+ },
460
+ session,
461
+ );
462
+
463
+ // 4. Sync billing association. Done after the project patch
464
+ // so the project resource is fully present before we
465
+ // attach billing (CRM project create is the LRO; billing
466
+ // update is synchronous).
467
+ const observedBilling = yield* observeBilling(synced.projectId!);
468
+ const finalBilling = yield* syncBilling(
469
+ synced.projectId!,
470
+ observedBilling,
471
+ news.billingAccount,
472
+ );
473
+
474
+ return toAttributes(synced, finalBilling);
475
+ }),
476
+ delete: Effect.fn(function* ({ output, session }) {
477
+ yield* deleteProjects({ name: `projects/${output.projectId}` }).pipe(
478
+ Effect.flatMap((op) =>
479
+ op.name ? awaitOperation(op.name, session) : Effect.succeed(op),
480
+ ),
481
+ // Already gone.
482
+ Effect.catchTag("NotFound", () => Effect.void),
483
+ // GCP returns 400 with "Project ... is already in
484
+ // DELETE_REQUESTED" when delete is invoked on an already-
485
+ // soft-deleted project. Treat that as idempotent success.
486
+ // TODO(distilled): tag this state via patch — see finding 12.
487
+ // The GCP `matchError` (vendor/distilled/packages/gcp/src/client/api.ts)
488
+ // currently dispatches purely on HTTP status and ignores the
489
+ // `message.includes` matcher in the patch JSON, so a per-operation
490
+ // patch alone can't disambiguate this case from a real 400 without
491
+ // also extending `matchError` to consult the matchers.
492
+ Effect.catchTag("BadRequest", (e) =>
493
+ /already.*delet|DELETE_REQUESTED/i.test(e.message ?? "")
494
+ ? Effect.void
495
+ : Effect.fail(e),
496
+ ),
497
+ );
498
+ }),
499
+ read: Effect.fn(function* ({ id, output, olds }) {
500
+ const projectId =
501
+ output?.projectId ??
502
+ (yield* createProjectId(id, olds?.projectId));
503
+ const observed = yield* observeProject(projectId);
504
+ if (!observed) return undefined;
505
+ const observedBilling = yield* observeBilling(projectId);
506
+ const attrs = toAttributes(observed, observedBilling);
507
+ // Adoption gate: a project without our internal labels was
508
+ // created outside this stack/stage/id. The engine surfaces
509
+ // `Unowned(attrs)` to the user as "exists but is not ours" —
510
+ // adoption requires `--adopt` (or `adopt(true)`).
511
+ return (yield* hasAlchemyLabels(id, observed.labels))
512
+ ? attrs
513
+ : Unowned(attrs);
514
+ }),
515
+ };
516
+ }),
517
+ );
@@ -0,0 +1,2 @@
1
+ export { Project, ProjectProvider } from "./Project.ts";
2
+ export type { ProjectParent, ProjectProps } from "./Project.ts";