unfazed 0.1.0 → 0.2.0
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.
- package/README.md +92 -6
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -1
- package/dist/generated/gql.d.ts +1 -1
- package/dist/generated/gql.js +1 -1
- package/dist/generated/gql.js.map +1 -1
- package/dist/generated/graphql.d.ts +1 -0
- package/dist/generated/graphql.js +4 -2
- package/dist/generated/graphql.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts +10 -0
- package/dist/operations.js +11 -2
- package/dist/operations.js.map +1 -1
- package/dist/unfazed.d.ts +219 -11
- package/dist/unfazed.js +246 -18
- package/dist/unfazed.js.map +1 -1
- package/package.json +1 -1
package/dist/unfazed.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UnfazedClient } from "./client.js";
|
|
2
|
-
import { UnfazedConflictError, UnfazedDeployError, UnfazedGraphQLError, UnfazedNotFoundError, looksLikeCASConflict, } from "./errors.js";
|
|
2
|
+
import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedGraphQLError, UnfazedNotFoundError, WORKSPACE_NOT_FOUND, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, looksLikeCASConflict, } from "./errors.js";
|
|
3
3
|
import * as ops from "./operations.js";
|
|
4
4
|
import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
|
|
5
5
|
/**
|
|
@@ -46,10 +46,77 @@ export class Unfazed {
|
|
|
46
46
|
/**
|
|
47
47
|
* Deletes a workspace and everything anchored to it — deployments, env, and
|
|
48
48
|
* a BOX's persistent data disk. Not recoverable.
|
|
49
|
+
*
|
|
50
|
+
* **The cascade is opt-out, not opt-in.** The server refuses to delete a
|
|
51
|
+
* workspace that has ANY deployment history — including FAILED records, and
|
|
52
|
+
* including deployments whose alias was long since taken over by something
|
|
53
|
+
* else — unless the mutation carries `withDeployments`. Since almost every
|
|
54
|
+
* workspace an agent creates gets deployed at least once, defaulting to
|
|
55
|
+
* `false` would make this method fail on nearly every real workspace, so it
|
|
56
|
+
* defaults to **`true`**: the behaviour this docstring has always promised.
|
|
57
|
+
*
|
|
58
|
+
* Pass `withDeployments: false` for the strict variant, which refuses rather
|
|
59
|
+
* than cascading and is the safer choice when you are deleting a workspace
|
|
60
|
+
* you did not create:
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* await unfazed.deleteWorkspace(ws.id); // cascade
|
|
64
|
+
* await unfazed.deleteWorkspace(ws.id, { withDeployments: false }); // refuse if deployed
|
|
65
|
+
* await unfazed.deleteWorkspace(id, { ignoreNotFound: true }); // false, not a throw
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* With `ignoreNotFound` a workspace that is absent (or belongs to someone
|
|
69
|
+
* else — the server returns one indistinguishable sentinel for both, on
|
|
70
|
+
* purpose) resolves to `false` instead of throwing, which makes teardown in
|
|
71
|
+
* a `finally` block idempotent.
|
|
49
72
|
*/
|
|
50
|
-
async deleteWorkspace(workspaceId, options) {
|
|
51
|
-
const
|
|
52
|
-
|
|
73
|
+
async deleteWorkspace(workspaceId, options = {}) {
|
|
74
|
+
const withDeployments = options.withDeployments ?? true;
|
|
75
|
+
try {
|
|
76
|
+
const data = await this.client.mutate(ops.DeleteWorkspaceDocument, { workspaceID: workspaceId, withDeployments }, options);
|
|
77
|
+
return data.deleteWorkspace.ok;
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
if (options.ignoreNotFound && isNotFound(e, WORKSPACE_NOT_FOUND))
|
|
81
|
+
return false;
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Returns the workspace named `name`, creating it only if it does not exist.
|
|
87
|
+
*
|
|
88
|
+
* Workspace names are unique per owner, case-insensitively — a second
|
|
89
|
+
* `createWorkspace("Hello")` after a `createWorkspace("hello")` is an error,
|
|
90
|
+
* not a second workspace. That makes the bare create a poor fit for any
|
|
91
|
+
* script that might run twice; this is the re-runnable form.
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* const ws = await unfazed.ensureWorkspace("greeter"); // safe to re-run
|
|
95
|
+
* ```
|
|
96
|
+
*
|
|
97
|
+
* Costs one list, plus one create only on a miss. The create is still
|
|
98
|
+
* guarded: if a concurrent caller wins the race, the duplicate-name error is
|
|
99
|
+
* caught and the now-existing workspace is returned instead of thrown.
|
|
100
|
+
*/
|
|
101
|
+
async ensureWorkspace(name, options) {
|
|
102
|
+
const existing = await this.#findWorkspaceByName(name, options);
|
|
103
|
+
if (existing)
|
|
104
|
+
return existing;
|
|
105
|
+
try {
|
|
106
|
+
return await this.createWorkspace(name, options);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
// Lost a create/create race, or the server's case folding is stricter
|
|
110
|
+
// than ours. Either way the workspace exists now — re-read rather than
|
|
111
|
+
// failing a script whose intent was "make sure this exists".
|
|
112
|
+
if (!isDuplicateWorkspaceName(e))
|
|
113
|
+
throw e;
|
|
114
|
+
const raced = await this.#findWorkspaceByName(name, options);
|
|
115
|
+
if (raced)
|
|
116
|
+
return raced;
|
|
117
|
+
// The name is taken by something we cannot see. Surface the real error.
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
53
120
|
}
|
|
54
121
|
/* ------------------------------------------------------------------- files */
|
|
55
122
|
/**
|
|
@@ -106,17 +173,36 @@ export class Unfazed {
|
|
|
106
173
|
const data = await this.client.query(ops.ReadFileDocument, { input: { workspaceID: workspaceId, path, ...input } }, options);
|
|
107
174
|
return data.readWorkspaceFile;
|
|
108
175
|
}
|
|
109
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Deletes a workspace file.
|
|
178
|
+
*
|
|
179
|
+
* Returns `false` when there was nothing to delete — an absent path is not
|
|
180
|
+
* an error. See the `ok: false` convention on {@link deleteEnv}.
|
|
181
|
+
*/
|
|
110
182
|
async deleteFile(workspaceId, path, options) {
|
|
111
183
|
const data = await this.client.mutate(ops.DeleteFileDocument, { workspaceID: workspaceId, path }, options);
|
|
112
184
|
return data.deleteWorkspaceFile.ok;
|
|
113
185
|
}
|
|
114
|
-
/* ----------------------------------------------------------------- deploys
|
|
186
|
+
/* ----------------------------------------------------------------- deploys
|
|
187
|
+
*
|
|
188
|
+
* Every deploy method here MINTS A NEW RELEASE on every call. There is no
|
|
189
|
+
* content dedupe: deploying byte-identical files twice produces two distinct
|
|
190
|
+
* releases with two ids, both real, both counting against retention (which
|
|
191
|
+
* keeps only the most recent few per alias and prunes the rest). Deploying
|
|
192
|
+
* in a retry loop is therefore not free — it churns the release history and
|
|
193
|
+
* can push a release you still wanted to roll back to out of retention.
|
|
194
|
+
* Check {@link getDeployment} or {@link listReleases} before redeploying if
|
|
195
|
+
* you only meant to converge.
|
|
196
|
+
*/
|
|
115
197
|
/**
|
|
116
198
|
* Deploys the workspace as a SERVICE (an HTTP server).
|
|
117
199
|
*
|
|
118
200
|
* With `wait: true` this resolves only once the build, VM boot and health
|
|
119
201
|
* probe have all passed.
|
|
202
|
+
*
|
|
203
|
+
* Passing the same `alias` again redeploys that alias — a new release that
|
|
204
|
+
* takes over the alias when it goes READY. Omitting `alias` lets the server
|
|
205
|
+
* generate one, which means a different app on every call.
|
|
120
206
|
*/
|
|
121
207
|
deployService(options) {
|
|
122
208
|
const { workspaceId, alias, runtime, serviceMode, healthPath, cronSchedule, cronPath } = options;
|
|
@@ -145,6 +231,25 @@ export class Unfazed {
|
|
|
145
231
|
* Deploys a BOX: a persistent always-on Linux VM with a data disk that
|
|
146
232
|
* survives stops and redeploys. A dev machine, not an HTTP service. The
|
|
147
233
|
* result's `sshCommand` is how you get in.
|
|
234
|
+
*
|
|
235
|
+
* ⚠️ **Omitting `alias` creates a NEW BOX every single call.** There is no
|
|
236
|
+
* "the workspace's box" to update: with no alias the server generates a
|
|
237
|
+
* fresh random one, so a script run twice leaves you with two box records,
|
|
238
|
+
* not one redeployed box.
|
|
239
|
+
*
|
|
240
|
+
* The data disk, meanwhile, is keyed by WORKSPACE — one disk, shared by
|
|
241
|
+
* every box in the workspace, and it is the box's real identity. Since only
|
|
242
|
+
* one VM can hold that disk read-write, the second box cannot even start
|
|
243
|
+
* while the first is running (`box data disk for workspace ... is attached
|
|
244
|
+
* to VM ...; stop it first`). The result is a pile of dead box records
|
|
245
|
+
* fighting over one disk.
|
|
246
|
+
*
|
|
247
|
+
* **Always pass an explicit `alias` in anything re-runnable.** Redeploying
|
|
248
|
+
* the same alias is the in-place update you actually want.
|
|
249
|
+
*
|
|
250
|
+
* ```ts
|
|
251
|
+
* await unfazed.deployBox({ workspaceId: ws.id, alias: "dev" }); // re-runnable
|
|
252
|
+
* ```
|
|
148
253
|
*/
|
|
149
254
|
deployBox(options) {
|
|
150
255
|
const { workspaceId, alias, resources } = options;
|
|
@@ -250,10 +355,23 @@ export class Unfazed {
|
|
|
250
355
|
const data = await this.client.mutate(ops.StopServiceDocument, { deploymentID: deploymentId }, options);
|
|
251
356
|
return data.stopService;
|
|
252
357
|
}
|
|
253
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Deletes a deployment: stops its VM, releases its alias, drops its release.
|
|
360
|
+
*
|
|
361
|
+
* With `ignoreNotFound` a deployment that is absent — or belongs to another
|
|
362
|
+
* tenant, which the server reports identically so ids cannot be probed —
|
|
363
|
+
* resolves to `false` instead of throwing, making cleanup loops idempotent.
|
|
364
|
+
*/
|
|
365
|
+
async deleteDeployment(deploymentId, options = {}) {
|
|
366
|
+
try {
|
|
367
|
+
const data = await this.client.mutate(ops.DeleteDeploymentDocument, { deploymentID: deploymentId }, options);
|
|
368
|
+
return data.deleteDeployment.ok;
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
if (options.ignoreNotFound && isNotFound(e, DEPLOYMENT_NOT_FOUND))
|
|
372
|
+
return false;
|
|
373
|
+
throw e;
|
|
374
|
+
}
|
|
257
375
|
}
|
|
258
376
|
/* ------------------------------------------------------------ env & secrets
|
|
259
377
|
*
|
|
@@ -262,12 +380,25 @@ export class Unfazed {
|
|
|
262
380
|
* deploy time — changing either does not affect a running app until you
|
|
263
381
|
* redeploy that alias. Old releases are immutable, and STATIC gets no env.
|
|
264
382
|
*/
|
|
265
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* Sets a workspace-default env var. Redeploy the alias to apply it.
|
|
385
|
+
*
|
|
386
|
+
* A name is either plain or secret, never both: this DELETES a workspace
|
|
387
|
+
* secret of the same name. {@link setSecret} displaces a plain var the same
|
|
388
|
+
* way. Last write wins, and the kind flips silently — so re-running a setup
|
|
389
|
+
* script that calls `setSecret("API_KEY", ...)` after one that called
|
|
390
|
+
* `setEnv("API_KEY", ...)` quietly converts it, and vice versa.
|
|
391
|
+
*/
|
|
266
392
|
async setEnv(workspaceId, name, value, options) {
|
|
267
393
|
const data = await this.client.mutate(ops.SetWorkspaceEnvDocument, { workspaceID: workspaceId, name, value }, options);
|
|
268
394
|
return data.setWorkspaceEnv.env;
|
|
269
395
|
}
|
|
270
|
-
/**
|
|
396
|
+
/**
|
|
397
|
+
* Sets a workspace-default secret. The value is never readable again.
|
|
398
|
+
*
|
|
399
|
+
* Mutually exclusive with {@link setEnv}: this DELETES a plain workspace var
|
|
400
|
+
* of the same name. See {@link setEnv} for the displacement rule.
|
|
401
|
+
*/
|
|
271
402
|
async setSecret(workspaceId, name, value, options) {
|
|
272
403
|
const data = await this.client.mutate(ops.SetWorkspaceSecretDocument, { workspaceID: workspaceId, name, value }, options);
|
|
273
404
|
return data.setWorkspaceSecret.env;
|
|
@@ -277,6 +408,16 @@ export class Unfazed {
|
|
|
277
408
|
* the same batching trick as {@link writeFiles}.
|
|
278
409
|
*
|
|
279
410
|
* Pass `{ secret: true }` to route them all to `setWorkspaceSecret`.
|
|
411
|
+
*
|
|
412
|
+
* It inherits {@link writeFiles}' failure semantics too: the batch is NOT
|
|
413
|
+
* atomic. GraphQL resolves each aliased field independently, so a rejected
|
|
414
|
+
* name (over the limit, malformed, whatever) leaves the vars before it
|
|
415
|
+
* already committed. The thrown {@link UnfazedGraphQLError} carries `data`
|
|
416
|
+
* with the aliases that succeeded and `errors[].path` naming those that did
|
|
417
|
+
* not — do not assume "it threw" means "nothing was written".
|
|
418
|
+
*
|
|
419
|
+
* Each name still displaces its opposite kind, exactly as {@link setEnv} and
|
|
420
|
+
* {@link setSecret} do one at a time.
|
|
280
421
|
*/
|
|
281
422
|
async setEnvMany(workspaceId, vars, options = {}) {
|
|
282
423
|
const entries = Object.entries(vars);
|
|
@@ -296,7 +437,16 @@ export class Unfazed {
|
|
|
296
437
|
const data = await this.client.mutate(document, variables, options.signal ? { signal: options.signal } : {});
|
|
297
438
|
return entries.map((_, i) => data[`e${i}`].env);
|
|
298
439
|
}
|
|
299
|
-
/**
|
|
440
|
+
/**
|
|
441
|
+
* Deletes a workspace-default env var (plain or secret).
|
|
442
|
+
*
|
|
443
|
+
* **The `ok: false` convention.** A `false` here does NOT mean failure — it
|
|
444
|
+
* means "nothing to do, the world is already in the state you asked for".
|
|
445
|
+
* The var did not exist. Every `ok`-returning mutation in this SDK follows
|
|
446
|
+
* the rule ({@link deleteFile}, {@link inheritAppEnv}, {@link removeAppEnv}),
|
|
447
|
+
* so the idempotent-cleanup pattern is simply to ignore the boolean; a real
|
|
448
|
+
* failure throws.
|
|
449
|
+
*/
|
|
300
450
|
async deleteEnv(workspaceId, name, options) {
|
|
301
451
|
const data = await this.client.mutate(ops.DeleteWorkspaceEnvDocument, { workspaceID: workspaceId, name }, options);
|
|
302
452
|
return data.deleteWorkspaceEnv.ok;
|
|
@@ -306,12 +456,24 @@ export class Unfazed {
|
|
|
306
456
|
const data = await this.client.query(ops.ListWorkspaceEnvDocument, { workspaceID: workspaceId }, options);
|
|
307
457
|
return data.workspaceEnv;
|
|
308
458
|
}
|
|
309
|
-
/**
|
|
459
|
+
/**
|
|
460
|
+
* Sets an env var for ONE alias, overriding the workspace default.
|
|
461
|
+
*
|
|
462
|
+
* Displaces both of the other states this name could be in for the app: it
|
|
463
|
+
* deletes an app SECRET of the same name, and it CLEARS a REMOVE mask left
|
|
464
|
+
* by {@link removeAppEnv}. So setting a value is also the way to un-block a
|
|
465
|
+
* masked name, and a name is only ever one of plain / secret / masked.
|
|
466
|
+
*/
|
|
310
467
|
async setAppEnv(workspaceId, alias, name, value, options) {
|
|
311
468
|
const data = await this.client.mutate(ops.SetAppEnvDocument, { workspaceID: workspaceId, alias, name, value }, options);
|
|
312
469
|
return data.setAppEnv.env;
|
|
313
470
|
}
|
|
314
|
-
/**
|
|
471
|
+
/**
|
|
472
|
+
* Sets a secret for ONE alias, overriding the workspace default.
|
|
473
|
+
*
|
|
474
|
+
* Like {@link setAppEnv}, this deletes a plain app var of the same name and
|
|
475
|
+
* clears a REMOVE mask for it.
|
|
476
|
+
*/
|
|
315
477
|
async setAppSecret(workspaceId, alias, name, value, options) {
|
|
316
478
|
const data = await this.client.mutate(ops.SetAppSecretDocument, { workspaceID: workspaceId, alias, name, value }, options);
|
|
317
479
|
return data.setAppSecret.env;
|
|
@@ -320,12 +482,36 @@ export class Unfazed {
|
|
|
320
482
|
* REMOVE: blocks this name for the app entirely — the app override, the
|
|
321
483
|
* workspace default, and integration enrichment. Not the same as deleting an
|
|
322
484
|
* override; use {@link inheritAppEnv} for that.
|
|
485
|
+
*
|
|
486
|
+
* This WRITES A MASK, and the mask is a real, persistent row. Two things
|
|
487
|
+
* follow that surprise people:
|
|
488
|
+
*
|
|
489
|
+
* - It is written even when nothing was inherited. Masking a name that no
|
|
490
|
+
* workspace default and no integration ever provided is not a no-op; it
|
|
491
|
+
* leaves a tombstone that keeps blocking the name if a default appears
|
|
492
|
+
* later. Clear it with {@link inheritAppEnv}.
|
|
493
|
+
* - It COSTS A SLOT. A workspace gets 128 env entries total — that budget
|
|
494
|
+
* is shared across the workspace defaults AND every app scope combined,
|
|
495
|
+
* not 128 per scope — and masks count toward it just like values do.
|
|
496
|
+
* Masking names in a loop can exhaust the workspace's env budget.
|
|
497
|
+
*
|
|
498
|
+
* Returns `false` when the exact mask was already present (a second call for
|
|
499
|
+
* the same name), per the {@link deleteEnv} `ok: false` convention. It
|
|
500
|
+
* returns `true` when it had something to change — including converting an
|
|
501
|
+
* existing override into a mask.
|
|
323
502
|
*/
|
|
324
503
|
async removeAppEnv(workspaceId, alias, name, options) {
|
|
325
504
|
const data = await this.client.mutate(ops.RemoveAppEnvDocument, { workspaceID: workspaceId, alias, name }, options);
|
|
326
505
|
return data.removeAppEnv.ok;
|
|
327
506
|
}
|
|
328
|
-
/**
|
|
507
|
+
/**
|
|
508
|
+
* INHERIT: clears the app override/mask so workspace and integration values
|
|
509
|
+
* apply again. This is the undo for both {@link setAppEnv} and
|
|
510
|
+
* {@link removeAppEnv}.
|
|
511
|
+
*
|
|
512
|
+
* Returns `false` when there was no override and no mask to clear — nothing
|
|
513
|
+
* to do, not a failure. See {@link deleteEnv}.
|
|
514
|
+
*/
|
|
329
515
|
async inheritAppEnv(workspaceId, alias, name, options) {
|
|
330
516
|
const data = await this.client.mutate(ops.InheritAppEnvDocument, { workspaceID: workspaceId, alias, name }, options);
|
|
331
517
|
return data.inheritAppEnv.ok;
|
|
@@ -365,11 +551,31 @@ export class Unfazed {
|
|
|
365
551
|
* ```ts
|
|
366
552
|
* await unfazed.rollback({ alias: "my-app" }); // undo last deploy
|
|
367
553
|
* await unfazed.rollback({ alias: "my-app", to: relId }); // to a specific release
|
|
554
|
+
* await unfazed.rollback({ alias: "my-app", to: relId, expectedCurrentId: liveId });
|
|
368
555
|
* ```
|
|
556
|
+
*
|
|
557
|
+
* ⚠️ **A bare `rollback({ alias })` is a TOGGLE, not an idempotent step.**
|
|
558
|
+
* It means "restore the most recent READY release that is not the live one",
|
|
559
|
+
* which is a different release each time the alias moves. Run the same
|
|
560
|
+
* script twice and the second run rolls back the rollback — A → B → A → B,
|
|
561
|
+
* forever, with each hop a real alias swap. Nothing errors, because every
|
|
562
|
+
* individual call did exactly what it was asked.
|
|
563
|
+
*
|
|
564
|
+
* In anything re-runnable — a retry loop, a CI job, an agent that might redo
|
|
565
|
+
* a step — pin the outcome instead:
|
|
566
|
+
*
|
|
567
|
+
* - `to:` names the release you want live, so re-running is a no-op-ish
|
|
568
|
+
* convergence rather than a flip (a second run then reports the target
|
|
569
|
+
* already holds the alias instead of moving it);
|
|
570
|
+
* - `expectedCurrentId:` states what you expect to be live now, so a second
|
|
571
|
+
* run fails loudly instead of silently undoing the first.
|
|
369
572
|
*/
|
|
370
573
|
async rollback(options) {
|
|
371
|
-
const { alias, signal } = options;
|
|
372
|
-
|
|
574
|
+
const { alias, signal, expectedCurrentId } = options;
|
|
575
|
+
// A caller-supplied precondition and the CAS retry are mutually exclusive:
|
|
576
|
+
// retrying re-guards against whatever holds the alias NOW, which is the
|
|
577
|
+
// one thing `expectedCurrentId` exists to refuse.
|
|
578
|
+
const retries = expectedCurrentId === undefined ? (options.retries ?? 1) : 0;
|
|
373
579
|
const requestOptions = signal ? { signal } : {};
|
|
374
580
|
let targetId = options.to;
|
|
375
581
|
for (let attempt = 0;; attempt++) {
|
|
@@ -381,6 +587,13 @@ export class Unfazed {
|
|
|
381
587
|
if (!current) {
|
|
382
588
|
throw new UnfazedNotFoundError(`unfazed: alias "${alias}" has no current release holding the alias`);
|
|
383
589
|
}
|
|
590
|
+
// The caller's precondition, checked before anything is sent. Failing
|
|
591
|
+
// here rather than at the server keeps a violated expectation from
|
|
592
|
+
// costing an alias swap, and reports the holder we actually observed.
|
|
593
|
+
if (expectedCurrentId !== undefined && current.id !== expectedCurrentId) {
|
|
594
|
+
throw new UnfazedConflictError(`unfazed: alias "${alias}" is held by ${current.id}, not the expected ` +
|
|
595
|
+
`${expectedCurrentId} — it moved since you read it; re-read and decide again`, []);
|
|
596
|
+
}
|
|
384
597
|
// Pin the target on the first pass so a retry refreshes only the guard.
|
|
385
598
|
if (targetId === undefined) {
|
|
386
599
|
const previous = releases.find((r) => r.id !== current.id && r.status === "READY");
|
|
@@ -422,6 +635,21 @@ export class Unfazed {
|
|
|
422
635
|
}
|
|
423
636
|
}
|
|
424
637
|
/* -------------------------------------------------------------- internals */
|
|
638
|
+
/**
|
|
639
|
+
* Finds a visible workspace by name, the way the server compares names.
|
|
640
|
+
*
|
|
641
|
+
* The server rejects duplicates with Go's `strings.EqualFold`, so matching
|
|
642
|
+
* must be case-insensitive here too or `ensureWorkspace` would try to create
|
|
643
|
+
* a "new" workspace the server considers a duplicate. An exact-case hit wins
|
|
644
|
+
* over a folded one, which only matters if a name ever slipped past the
|
|
645
|
+
* uniqueness check.
|
|
646
|
+
*/
|
|
647
|
+
async #findWorkspaceByName(name, options) {
|
|
648
|
+
const workspaces = await this.listWorkspaces(options);
|
|
649
|
+
const folded = name.toLowerCase();
|
|
650
|
+
return (workspaces.find((w) => w.name === name) ??
|
|
651
|
+
workspaces.find((w) => w.name.toLowerCase() === folded));
|
|
652
|
+
}
|
|
425
653
|
async #writeBatch(workspaceId, batch, signal) {
|
|
426
654
|
const varDefs = ["$workspaceID: ID!"];
|
|
427
655
|
const fields = [];
|