@molecule/api-code-sandbox-flyio 1.3.5 → 1.3.8

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 CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-26T04:22:26.020Z
6
+ Generated: 2026-11-01T09:52:42.927Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-code-sandbox-flyio
@@ -16,6 +16,10 @@ SCALE-TO-ZERO: `sleep()` maps to Fly's `suspend`, which snapshots the microVM's
16
16
  memory to disk, and a suspended Machine bills for storage only. Waking is a
17
17
  resume from that snapshot rather than a cold boot.
18
18
 
19
+ `FLY_REGION` is the API Machine's own location and is IGNORED for tenant
20
+ placement. Set the provider's `region` default, or pass one create's
21
+ `SandboxConfig.region` for a project whose placement was recorded at creation.
22
+
19
23
  > **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
20
24
  > It is written to be read by coding agents as much as by people, and is generated from this
21
25
  > package's source — edit `src/index.ts` JSDoc, not this file.
@@ -87,13 +91,15 @@ npm install @molecule/api-code-sandbox-flyio @aws-sdk/client-s3 @aws-sdk/s3-requ
87
91
  Options for a single Fly API request.
88
92
 
89
93
  ```typescript
90
- interface FlyRequestOptions {
94
+ interface FlyRequestOptions<T = unknown> {
91
95
  /** HTTP method. Defaults to `GET`. */
92
96
  method?: string
93
97
  /** JSON request body. Omitted entirely when undefined. */
94
98
  body?: unknown
95
99
  /** Per-request timeout in ms. Defaults to the client's configured timeout. */
96
100
  timeoutMs?: number
101
+ /** Cancels in-flight provisioning when the owning lifecycle lock is lost. */
102
+ signal?: AbortSignal
97
103
  /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
98
104
  attempts?: number
99
105
  /**
@@ -108,6 +114,19 @@ interface FlyRequestOptions {
108
114
  * under exec-burst load, not a real absence, so it is worth repeating.
109
115
  */
110
116
  retryStatuses?: number[]
117
+ /**
118
+ * Restricts retries opted into by `retryStatuses` using the response error.
119
+ * Return false to surface that response immediately. The client's standard
120
+ * transport, 429 and 5xx retries are unaffected.
121
+ */
122
+ retryWhen?: (error: FlyApiError) => boolean
123
+ /**
124
+ * Reconciles a retryable failure before the client backs off or throws.
125
+ * Returning a value completes the request; `undefined` keeps the normal
126
+ * retry/exhaustion path. This lets an ambiguous write look for the resource
127
+ * it may have created without adding a second POST retry loop.
128
+ */
129
+ reconcile?: (error: FlyApiError) => Promise<T | undefined>
111
130
  }
112
131
  ```
113
132
 
@@ -176,6 +195,183 @@ interface EgressProbeContext {
176
195
  }
177
196
  ```
178
197
 
198
+ #### `FlyCreateVolumeIntent`
199
+
200
+ A volume name generated for one attempt, or an existing durable workspace.
201
+
202
+ ```typescript
203
+ interface FlyCreateVolumeIntent {
204
+ /** Fly's resource ID. Once known, this—not the name—is the ownership key. */
205
+ id?: string
206
+ /** The current Fly name, unique for a newly-created attempt volume. */
207
+ name: string
208
+ /** Caller-level workspace identity, stable across create attempts. */
209
+ logicalName?: string
210
+ /** Once bound, only explicit destruction or a reference-checked failed-create sweep may delete it. */
211
+ workspace: boolean
212
+ /** False until an ambiguous create is adopted or definitely rejected. */
213
+ resolved?: boolean
214
+ /** Recorded before this attempt's create POST; automatic deletion also requires a resolved Fly ID. */
215
+ createdByAttemptId?: string
216
+ /** Recorded when this attempt adopted an existing/listed volume; never automatic-delete authority. */
217
+ adoptedByAttemptId?: string
218
+ }
219
+ ```
220
+
221
+ #### `FlyCreateAddressIntent`
222
+
223
+ An address on another Fly app which must be released before forgetting intent.
224
+
225
+ ```typescript
226
+ interface FlyCreateAddressIntent {
227
+ targetApp: string
228
+ network: string
229
+ ip?: string
230
+ /** True only after Fly returned this allocation to this attempt. */
231
+ owned: boolean
232
+ /** False while a lost response leaves allocation ownership unknown. */
233
+ resolved: boolean
234
+ }
235
+ ```
236
+
237
+ #### `FlyCreateAttempt`
238
+
239
+ The complete durable responsibility for one create attempt.
240
+
241
+ ```typescript
242
+ interface FlyCreateAttempt {
243
+ attemptId: string
244
+ projectId: string
245
+ appName: string
246
+ state: FlyCreateAttemptState
247
+ /** Monotonic fencing token, incremented whenever lifecycle ownership is reacquired. */
248
+ generation?: number
249
+ sandboxId?: string
250
+ /** Fly definitively rejected the Machine POST; an unknown outcome is never marked. */
251
+ machineCreateFailed?: boolean
252
+ /**
253
+ * Durable Machine POST lifecycle. New attempts start at not-started; pending is
254
+ * written before dispatch. NULL is a legacy unknown, never proof of no POST.
255
+ */
256
+ machineCreateOutcome?: 'not-started' | 'pending' | 'rejected' | 'unknown' | 'accepted'
257
+ /** The exact Machine ID returned by this attempt's accepted POST response, or an observation without delete provenance. */
258
+ machineId?: string
259
+ /** Accepted-POST provenance; name, metadata, sandboxId and adoption never supply it. */
260
+ machineCreatedByAttemptId?: string
261
+ /** This attempt's app-create intent, recorded before the POST. */
262
+ appCreatedByAttemptId?: string
263
+ /** True only when the app POST itself was accepted; a pending/unknown create cannot be deleted. */
264
+ appCreateResolved?: boolean
265
+ /** True when explicit destruction identity-validated and adopted a pre-journal Machine. */
266
+ machineAdopted?: boolean
267
+ volumes: FlyCreateVolumeIntent[]
268
+ addresses: FlyCreateAddressIntent[]
269
+ }
270
+ ```
271
+
272
+ #### `FlyCreateAttemptJournal`
273
+
274
+ Application-supplied durable journal used by create and its reconciler.
275
+ A journal-aware writer that ignores provenance must never run beside this
276
+ contract; {@link assertCreateAllowed} is the durable rollout boundary.
277
+
278
+ ```typescript
279
+ interface FlyCreateAttemptJournal {
280
+ begin(
281
+ attempt: Pick<FlyCreateAttempt, 'attemptId' | 'projectId' | 'appName'> & {
282
+ ownerProjectId?: string
283
+ /** Legacy adoption persists its observed serving state and identity in the INSERT. */
284
+ state?: FlyCreateAttemptState
285
+ sandboxId?: string
286
+ machineAdopted?: boolean
287
+ },
288
+ ): Promise<void>
289
+ /** Refuse provisioning until pre-journal and provenance-ignoring writers have drained. */
290
+ assertCreateAllowed(): Promise<void>
291
+ /** Re-read one attempt, including after a lifecycle lock has been acquired. */
292
+ get(attemptId: string): Promise<FlyCreateAttempt | null>
293
+ /** Re-read every durable attempt for an app, including creating/serving attempts. */
294
+ forApp(appName: string): Promise<FlyCreateAttempt[]>
295
+ /** Find the durable workspace binding for a logical caller workspace. */
296
+ findWorkspace(projectId: string, logicalName: string): Promise<FlyCreateVolumeIntent | null>
297
+ /** Record app-create intent/acceptance, or clear it when the app was adopted. */
298
+ recordAppProvenance(
299
+ attemptId: string,
300
+ generation: number,
301
+ outcome: 'pending' | 'created' | 'adopted',
302
+ ): Promise<void>
303
+ /** Record an intent or transfer a Fly-ID binding to this attempt. */
304
+ recordVolume(attemptId: string, volume: FlyCreateVolumeIntent): Promise<void>
305
+ markVolumeWorkspace(attemptId: string, name: string, workspace?: boolean): Promise<void>
306
+ recordAddress(attemptId: string, address: FlyCreateAddressIntent): Promise<void>
307
+ markState(attemptId: string, state: FlyCreateAttemptState, sandboxId?: string): Promise<void>
308
+ /** Record that explicit destruction adopted this pre-journal Machine. */
309
+ markMachineAdopted(attemptId: string): Promise<void>
310
+ /** Record POST intent before dispatch and an ambiguous outcome before recovery reads. */
311
+ recordMachineCreateOutcome(
312
+ attemptId: string,
313
+ generation: number,
314
+ outcome: 'pending' | 'unknown',
315
+ ): Promise<void>
316
+ /** Persist a recovered exact ID as observation only, never accepted-POST authority. */
317
+ recordMachineObservation(attemptId: string, generation: number, machineId: string): Promise<void>
318
+ /** Record only the exact ID from this generation's accepted Machine POST response. */
319
+ recordMachineProvenance(attemptId: string, generation: number, machineId: string): Promise<void>
320
+ /** Record a definitive rejection only while this create generation still owns the attempt. */
321
+ markMachineCreateFailed(attemptId: string, generation: number): Promise<void>
322
+ /** Throw unless this generation is still the attempt's durable fencing token. */
323
+ assertGeneration(attemptId: string, generation: number): Promise<void>
324
+ /** Atomically retire this exact generation while its lifecycle lock session is live. */
325
+ retire(attemptId: string, generation: number): Promise<void>
326
+ /** Re-authorize one destructive request against the retired row on the lock session. */
327
+ authorizeDestruction(attemptId: string, generation: number): Promise<void>
328
+ /** Failed/abandoned attempts, plus creating attempts whose updatedAt is at/before cutoff. */
329
+ reclaimable(cutoff: Date): Promise<FlyCreateAttempt[]>
330
+ /** Acquire the same lifecycle lock the application uses for create/destroy. */
331
+ withLock?<T>(
332
+ attempt: FlyCreateAttempt,
333
+ body: (signal: AbortSignal, generation: number) => Promise<T>,
334
+ ): Promise<T>
335
+ forget(attemptId: string, generation?: number): Promise<void>
336
+ }
337
+ ```
338
+
339
+ #### `FlyCreateReconcileSummary`
340
+
341
+ Attempts forgotten after completed cleanup, and attempts retained after errors.
342
+
343
+ ```typescript
344
+ interface FlyCreateReconcileSummary {
345
+ deleted: number
346
+ failed: number
347
+ }
348
+ ```
349
+
350
+ #### `FlyCreateAttemptProvider`
351
+
352
+ Fly-specific lifecycle capabilities exposed by the provider.
353
+
354
+ ```typescript
355
+ interface FlyCreateAttemptProvider {
356
+ /** Explicit retirement is fenced by the caller's live lifecycle lock. */
357
+ destroy(id: string, signal: AbortSignal): Promise<void>
358
+ /** Deliberately retire exact recorded resources for one shared-app attempt. */
359
+ destroyAttempt(attemptId: string, signal: AbortSignal): Promise<void>
360
+ /** Per-project app retirement is fenced by the caller's live lifecycle lock. */
361
+ destroyApp(
362
+ projectId: string,
363
+ expectedAppName: string | undefined,
364
+ signal: AbortSignal,
365
+ ): Promise<void>
366
+ reconcileCreateAttempts(options?: {
367
+ now?: number
368
+ createTimeoutMs?: number
369
+ /** Restrict a pass to one durable attempt. Every attempt is still locked. */
370
+ attemptId?: string
371
+ }): Promise<FlyCreateReconcileSummary>
372
+ }
373
+ ```
374
+
179
375
  #### `StoredObject`
180
376
 
181
377
  One object as this bond needs to see it.
@@ -337,7 +533,11 @@ interface FlyioConfig {
337
533
  * creation.
338
534
  */
339
535
  network?: string
340
- /** Fly region for Machines and volumes (e.g. `iad`). Falls back to `FLY_REGION`, then `iad`. */
536
+ /**
537
+ * Default Fly region for Machines and volumes (e.g. `iad`). Defaults to
538
+ * `iad`; `SandboxConfig.region` overrides it for one recorded project create.
539
+ * The API Machine's own `FLY_REGION` is never a tenant-placement fallback.
540
+ */
341
541
  region?: string
342
542
  /**
343
543
  * OCI image every sandbox Machine runs. Must be pullable by Fly — a tag in
@@ -403,6 +603,8 @@ interface FlyioConfig {
403
603
  ipAssignmentType?: string
404
604
  /** Prefix for the Machine metadata keys this provider owns. Defaults to `molecule-sandbox`. */
405
605
  metadataPrefix?: string
606
+ /** Durable create-attempt ownership. Required for automatic failed-create reconciliation. */
607
+ createAttemptJournal?: FlyCreateAttemptJournal
406
608
  /** Timeout for a single Machines API request, in ms. Defaults to 30000. */
407
609
  requestTimeoutMs?: number
408
610
  /**
@@ -442,10 +644,6 @@ interface FlyioConfig {
442
644
  * Apps every sandbox must be able to reach ACROSS its per-project 6PN — the
443
645
  * tenant Postgres cluster and the control plane's egress proxy. For each one,
444
646
  * this provider allocates a **Flycast** private address into the project's own
445
- * network when the project's app is created, and releases it on `destroy()`.
446
- * Falls back to `FLY_SANDBOX_PRIVATE_SERVICES` (`<app>:<port>` pairs).
447
- *
448
- * Without this, a project with a database DOES NOT WORK on Fly: per-project
449
647
  // … truncated — the full declaration is in node_modules/@molecule/api-code-sandbox-flyio/dist/types.d.ts
450
648
  ```
451
649
 
@@ -470,7 +668,11 @@ interface ProcessEnv {
470
668
  FLY_SANDBOX_APP_PREFIX?: string
471
669
  /** `false` disables per-project apps (refused in production). */
472
670
  FLY_SANDBOX_APP_PER_PROJECT?: string
473
- /** Fly region for Machines and volumes (default `iad`). */
671
+ /**
672
+ * The API Machine's own region, not a tenant-placement setting; ignored.
673
+ *
674
+ * @deprecated Use `FlyioConfig.region` or one create's `SandboxConfig.region`.
675
+ */
474
676
  FLY_REGION?: string
475
677
  /** OCI image for sandbox Machines. */
476
678
  FLY_SANDBOX_IMAGE?: string
@@ -555,16 +757,20 @@ interface FlyPrivateService {
555
757
  An IP assignment, as returned by `POST`/`GET /v1/apps/{app}/ip_assignments`
556
758
  (`IPAssignment` in https://docs.machines.dev/openapi.json).
557
759
 
558
- Note what is NOT here: the assignment's **network**. Fly's own schema is
559
- `{created_at, ip, region, service_name, shared}`, so a listing cannot tell you
560
- which 6PN a private address serves — which is why this provider records the
561
- addresses it allocated in the sandbox Machine's metadata instead of
562
- rediscovering them.
760
+ Current Fly schema includes the assignment's network identity, allowing a
761
+ lost allocation response to be resolved by target app and 6PN. Durable
762
+ records remain authoritative for what this provider must release.
563
763
 
564
764
  ```typescript
565
765
  interface FlyIpAssignment {
566
766
  /** The allocated address. Required for a later `DELETE .../ip_assignments/{ip}`. */
567
767
  ip?: string
768
+ /**
769
+ * Private 6PN identity. GET returns `{name, org_slug}` (null for non-Flycast
770
+ * assignments); only the POST request body uses separate string fields.
771
+ * The string form remains accepted for older cached/test responses.
772
+ */
773
+ network?: string | { name?: string | null; org_slug?: string | null } | null
568
774
  /** Region the address was allocated in, when Fly reports one. */
569
775
  region?: string
570
776
  /** Service the address is bound to, when the assignment names one. */
@@ -776,6 +982,8 @@ interface FlyVolume {
776
982
  size_gb?: number
777
983
  region?: string
778
984
  attached_machine_id?: string
985
+ /** RFC 3339 creation time; required as proof before an old attempt is reaped. */
986
+ created_at?: string
779
987
  }
780
988
  ```
781
989
 
@@ -802,6 +1010,14 @@ Runs one exec call against a Machine. Injected so this module stays transport-fr
802
1010
  type RawExec = (command: string[], timeoutSeconds: number) => Promise<FlyExecResponse>
803
1011
  ```
804
1012
 
1013
+ #### `FlyCreateAttemptState`
1014
+
1015
+ Durable lifecycle state; only failed/abandoned attempts are automatically reclaimed.
1016
+
1017
+ ```typescript
1018
+ type FlyCreateAttemptState = 'creating' | 'serving' | 'failed' | 'abandoned'
1019
+ ```
1020
+
805
1021
  #### `FlyMachineState`
806
1022
 
807
1023
  A Fly Machine state, as documented at https://fly.io/docs/machines/machine-states/.
@@ -924,7 +1140,7 @@ class FlyApiClient {
924
1140
  * @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
925
1141
  * or when every retryable attempt is exhausted.
926
1142
  */
927
- async request<T>(path: string, options: FlyRequestOptions = {}): Promise<T | null> {
1143
+ async request<T>(path: string, options: FlyRequestOptions<T> = {}): Promise<T | null> {
928
1144
  const method = options.method ?? 'GET'
929
1145
  const attempts = options.attempts ?? DEFAULT_ATTEMPTS
930
1146
  const token = this.token()
@@ -939,9 +1155,13 @@ class FlyApiClient {
939
1155
 
940
1156
  let lastError: FlyApiError | undefined
941
1157
  for (let attempt = 1; attempt <= attempts; attempt++) {
1158
+ if (options.signal?.aborted) throw options.signal.reason
942
1159
  // Pace EVERY network call (including retries) through the process-wide
943
1160
  // limiter so a boot's burst of exec calls never trips Fly's account 429.
944
1161
  await paceFlyRequest(this.sleep)
1162
+ // Pacing is cancellable too: ownership protection starts only when this
1163
+ // request really enters fetch, never while it is still waiting for a slot.
1164
+ if (options.signal?.aborted) throw options.signal.reason
945
1165
  const controller = new AbortController()
946
1166
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs)
947
1167
  // Initialized to 0 ("no answer received"), which is the value that
@@ -949,18 +1169,22 @@ class FlyApiClient {
949
1169
  let status = 0
950
1170
  let retryAfter: string | null = null
951
1171
  try {
952
- const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
1172
+ const pendingResponse = this.fetchImpl(`${this.baseUrl}${path}`, {
953
1173
  method,
954
1174
  headers: {
955
1175
  Authorization: `Bearer ${token}`,
956
1176
  ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
957
1177
  },
958
1178
  ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
959
- signal: controller.signal,
1179
+ signal: options.signal
1180
+ ? AbortSignal.any([controller.signal, options.signal])
1181
+ : controller.signal,
960
1182
  })
1183
+ const response = await pendingResponse
961
1184
  status = response.status
962
1185
  retryAfter = response.headers?.get?.('retry-after') ?? null
963
1186
  const text = await response.text()
1187
+ if (options.signal?.aborted) throw options.signal.reason
964
1188
 
965
1189
  if (status >= 200 && status < 300) {
966
1190
  if (!text.trim()) return null
@@ -976,6 +1200,7 @@ class FlyApiClient {
976
1200
  if (options.nullOn?.includes(status)) return null
977
1201
  lastError = new FlyApiError(method, path, status, text.slice(0, 500))
978
1202
  } catch (error) {
1203
+ if (options.signal?.aborted) throw options.signal.reason
979
1204
  if (error instanceof FlyApiError) throw error
980
1205
  // Transport-level failure (abort/timeout, DNS, connection reset) — no
981
1206
  // status was ever received, so it is modelled as status 0 and retried.
@@ -990,8 +1215,16 @@ class FlyApiClient {
990
1215
  clearTimeout(timer)
991
1216
  }
992
1217
 
993
- const retryable =
994
- isRetryableStatus(status) || (options.retryStatuses?.includes(status) ?? false)
1218
+ const extraRetry = Boolean(
1219
+ options.retryStatuses?.includes(status) &&
1220
+ lastError &&
1221
+ (options.retryWhen?.(lastError) ?? true),
1222
+ )
1223
+ const retryable = isRetryableStatus(status) || extraRetry
1224
+ if (retryable && lastError && options.reconcile) {
1225
+ const recovered = await options.reconcile(lastError)
1226
+ if (recovered !== undefined) return recovered
1227
+ }
995
1228
  if (!retryable || attempt === attempts) break
996
1229
  const delay = retryDelayMs(attempt, retryAfter)
997
1230
  logger.warn(`Fly API transient failure on ${method} ${path} — retrying`, {
@@ -1465,24 +1698,30 @@ function parseSandboxId(id: string): { app: string; machineId: string }
1465
1698
 
1466
1699
  **Returns:** The app name and Machine id.
1467
1700
 
1468
- #### `appNameForProject(prefix, projectId)`
1701
+ #### `appNameNamespace(prefix)`
1702
+
1703
+ The one namespace mapping shared by app naming and fleet discovery.
1704
+
1705
+ ```typescript
1706
+ function appNameNamespace(prefix: string): {
1707
+ prefix: string
1708
+ hashedPrefix: string
1709
+ searchPrefixes: string[]
1710
+ }
1711
+ ```
1469
1712
 
1470
- Derives a Fly app name for a project.
1713
+ #### `appNameForProject(prefix, projectId)`
1471
1714
 
1472
- Fly app names are globally unique DNS labels: lowercase alphanumerics and
1473
- hyphens, no leading/trailing hyphen, at most 63 characters (they become
1474
- `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
1475
- to spare; longer ids are truncated rather than rejected, and the trailing
1476
- hyphen that truncation can leave is stripped.
1715
+ Derive the collision-free app name for a complete prefix/project identity.
1477
1716
 
1478
1717
  ```typescript
1479
1718
  function appNameForProject(prefix: string, projectId: string): string
1480
1719
  ```
1481
1720
 
1482
1721
  - `prefix` — Configured app-name prefix.
1483
- - `projectId` — The project id from `SandboxConfig`.
1722
+ - `projectId` — Complete project or deploy identity.
1484
1723
 
1485
- **Returns:** A syntactically valid Fly app name.
1724
+ **Returns:** A DNS-safe app name no longer than 63 characters.
1486
1725
 
1487
1726
  #### `mapMachineState(state)`
1488
1727
 
@@ -1588,7 +1827,7 @@ function createProvider(
1588
1827
  config?: FlyioConfig,
1589
1828
  client?: FlyApiClient,
1590
1829
  store?: ObjectStore | null,
1591
- ): SandboxProvider
1830
+ ): SandboxProvider & FlyCreateAttemptProvider
1592
1831
  ```
1593
1832
 
1594
1833
  - `config` — Optional Fly configuration: API token/URL, org slug, app naming and isolation mode, region, base image, guest sizing, preview service, and the preview URL template. Every field has an env fallback.
@@ -2252,6 +2491,21 @@ refused, a leading `/` stripped, a write through a symlink refused, and
2252
2491
  setuid/setgid dropped by `--no-same-permissions` — were verified by running
2253
2492
  GNU tar 1.35 against crafted archives rather than inferred from its manual.
2254
2493
 
2494
+ **Lifecycle ownership.** Create NEVER deletes a Machine, volume, app or
2495
+ address. Configure `createAttemptJournal` to record every intended resource
2496
+ before its Fly write. Workspace ownership is durable immediately: an
2497
+ ambiguous create retains it for a later create or an explicit sweep, never
2498
+ infers deletion from a missing listing or an uncertain dispatch. The
2499
+ reconciler settles only retired attempt generations and skips serving
2500
+ Machines, attached volumes and bound workspaces. The one automatic exception
2501
+ is a durably recorded, definitive Machine-create rejection: under the same
2502
+ lifecycle lock, a fresh listing must show no Machine reference (and no
2503
+ Machines or untracked volumes in a per-project app) before the fenced sweep
2504
+ deletes the failed attempt's volume. The empty app remains KEEP until
2505
+ deliberate retirement through `destroyApp()`. Ambiguous outcomes and
2506
+ referenced resources stay KEPT and are reported. Deployed callers must
2507
+ serialize lifecycle operations per app through their lifecycle-lock adapter.
2508
+
2255
2509
  **Absence vs failure: `get()` only answers `null` for a REAL absence.**
2256
2510
  `get()` (and each read shaped like it — `list()`, `find()`, `getTemplate()`)
2257
2511
  reserves `null`/`[]` for a positive not-found answer — Fly answered 404 for
@@ -2395,10 +2649,19 @@ deliberately NOT implemented: they receive only a name, while a Fly volume
2395
2649
  needs an app, a region and a size, and a volume created in the wrong app can
2396
2650
  never be mounted. So passing `SandboxConfig.volumeName` provisions the volume
2397
2651
  inside the project's app, mounted at `/workspace`; `resources.diskMB` is
2398
- rounded UP to whole GB. Fly documents that "a volume can be attached to only
2399
- one Machine", which matches one sandbox per project. Fly does not document the
2400
- legal character set for volume names, so names are conservatively reduced to
2401
- `[A-Za-z0-9_]` — an assumption, flagged as such.
2652
+ rounded UP to whole GB. Creation sends the Machine's exact guest as Fly's
2653
+ `compute` placement hint, so Fly can choose a volume host that can run that
2654
+ Machine. If Machine creation still answers `412 volume_placement_capacity`,
2655
+ the provider preserves the volume and propagates the error for the caller's
2656
+ bounded retry, which adopts it by its durable Fly ID. Create itself never
2657
+ deletes its app or volume; only explicit retirement or the reference-checked
2658
+ definitive-failure sweep above can do so. Volume creates retain transport and
2659
+ 429 backoff. After an ambiguous failure, bounded lookups by name adopt a
2660
+ volume whose response was lost before any POST replay, preventing duplicates.
2661
+ Fly documents that "a volume can be attached to only one Machine", which
2662
+ matches one sandbox per project. Fly does
2663
+ not document the legal character set for volume names, so names are
2664
+ conservatively reduced to `[A-Za-z0-9_]` — an assumption, flagged as such.
2402
2665
 
2403
2666
  **Warm start: templates are tar archives in object storage.** Fly cannot
2404
2667
  `commit` a running Machine — it PULLS images from a registry — and the
package/dist/api.d.ts CHANGED
@@ -43,13 +43,15 @@ export declare class FlyApiError extends Error {
43
43
  });
44
44
  }
45
45
  /** Options for a single Fly API request. */
46
- export interface FlyRequestOptions {
46
+ export interface FlyRequestOptions<T = unknown> {
47
47
  /** HTTP method. Defaults to `GET`. */
48
48
  method?: string;
49
49
  /** JSON request body. Omitted entirely when undefined. */
50
50
  body?: unknown;
51
51
  /** Per-request timeout in ms. Defaults to the client's configured timeout. */
52
52
  timeoutMs?: number;
53
+ /** Cancels in-flight provisioning when the owning lifecycle lock is lost. */
54
+ signal?: AbortSignal;
53
55
  /** Max attempts for a retryable failure. Defaults to 4. Pass 1 to disable retries. */
54
56
  attempts?: number;
55
57
  /**
@@ -64,6 +66,19 @@ export interface FlyRequestOptions {
64
66
  * under exec-burst load, not a real absence, so it is worth repeating.
65
67
  */
66
68
  retryStatuses?: number[];
69
+ /**
70
+ * Restricts retries opted into by `retryStatuses` using the response error.
71
+ * Return false to surface that response immediately. The client's standard
72
+ * transport, 429 and 5xx retries are unaffected.
73
+ */
74
+ retryWhen?: (error: FlyApiError) => boolean;
75
+ /**
76
+ * Reconciles a retryable failure before the client backs off or throws.
77
+ * Returning a value completes the request; `undefined` keeps the normal
78
+ * retry/exhaustion path. This lets an ambiguous write look for the resource
79
+ * it may have created without adding a second POST retry loop.
80
+ */
81
+ reconcile?: (error: FlyApiError) => Promise<T | undefined>;
67
82
  }
68
83
  /**
69
84
  * Decides whether a failed attempt is worth repeating.
@@ -147,6 +162,6 @@ export declare class FlyApiClient {
147
162
  * @throws {FlyApiError} On a non-2xx status that is not listed in `nullOn`,
148
163
  * or when every retryable attempt is exhausted.
149
164
  */
150
- request<T>(path: string, options?: FlyRequestOptions): Promise<T | null>;
165
+ request<T>(path: string, options?: FlyRequestOptions<T>): Promise<T | null>;
151
166
  }
152
167
  //# sourceMappingURL=api.d.ts.map
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAgDD,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAE7C;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CAiFnF"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -216,9 +216,15 @@ export class FlyApiClient {
216
216
  }
217
217
  let lastError;
218
218
  for (let attempt = 1; attempt <= attempts; attempt++) {
219
+ if (options.signal?.aborted)
220
+ throw options.signal.reason;
219
221
  // Pace EVERY network call (including retries) through the process-wide
220
222
  // limiter so a boot's burst of exec calls never trips Fly's account 429.
221
223
  await paceFlyRequest(this.sleep);
224
+ // Pacing is cancellable too: ownership protection starts only when this
225
+ // request really enters fetch, never while it is still waiting for a slot.
226
+ if (options.signal?.aborted)
227
+ throw options.signal.reason;
222
228
  const controller = new AbortController();
223
229
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs);
224
230
  // Initialized to 0 ("no answer received"), which is the value that
@@ -226,18 +232,23 @@ export class FlyApiClient {
226
232
  let status = 0;
227
233
  let retryAfter = null;
228
234
  try {
229
- const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
235
+ const pendingResponse = this.fetchImpl(`${this.baseUrl}${path}`, {
230
236
  method,
231
237
  headers: {
232
238
  Authorization: `Bearer ${token}`,
233
239
  ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
234
240
  },
235
241
  ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
236
- signal: controller.signal,
242
+ signal: options.signal
243
+ ? AbortSignal.any([controller.signal, options.signal])
244
+ : controller.signal,
237
245
  });
246
+ const response = await pendingResponse;
238
247
  status = response.status;
239
248
  retryAfter = response.headers?.get?.('retry-after') ?? null;
240
249
  const text = await response.text();
250
+ if (options.signal?.aborted)
251
+ throw options.signal.reason;
241
252
  if (status >= 200 && status < 300) {
242
253
  if (!text.trim())
243
254
  return null;
@@ -256,6 +267,8 @@ export class FlyApiClient {
256
267
  lastError = new FlyApiError(method, path, status, text.slice(0, 500));
257
268
  }
258
269
  catch (error) {
270
+ if (options.signal?.aborted)
271
+ throw options.signal.reason;
259
272
  if (error instanceof FlyApiError)
260
273
  throw error;
261
274
  // Transport-level failure (abort/timeout, DNS, connection reset) — no
@@ -265,7 +278,15 @@ export class FlyApiClient {
265
278
  finally {
266
279
  clearTimeout(timer);
267
280
  }
268
- const retryable = isRetryableStatus(status) || (options.retryStatuses?.includes(status) ?? false);
281
+ const extraRetry = Boolean(options.retryStatuses?.includes(status) &&
282
+ lastError &&
283
+ (options.retryWhen?.(lastError) ?? true));
284
+ const retryable = isRetryableStatus(status) || extraRetry;
285
+ if (retryable && lastError && options.reconcile) {
286
+ const recovered = await options.reconcile(lastError);
287
+ if (recovered !== undefined)
288
+ return recovered;
289
+ }
269
290
  if (!retryable || attempt === attempts)
270
291
  break;
271
292
  const delay = retryDelayMs(attempt, retryAfter);
package/dist/ids.d.ts CHANGED
@@ -31,18 +31,17 @@ export declare function parseSandboxId(id: string): {
31
31
  app: string;
32
32
  machineId: string;
33
33
  };
34
+ /** The one namespace mapping shared by app naming and fleet discovery. */
35
+ export declare function appNameNamespace(prefix: string): {
36
+ prefix: string;
37
+ hashedPrefix: string;
38
+ searchPrefixes: string[];
39
+ };
34
40
  /**
35
- * Derives a Fly app name for a project.
36
- *
37
- * Fly app names are globally unique DNS labels: lowercase alphanumerics and
38
- * hyphens, no leading/trailing hyphen, at most 63 characters (they become
39
- * `<app>.fly.dev`). Project ids are uuids, so `<prefix>-<uuid>` fits with room
40
- * to spare; longer ids are truncated rather than rejected, and the trailing
41
- * hyphen that truncation can leave is stripped.
41
+ * Derive the collision-free app name for a complete prefix/project identity.
42
42
  * @param prefix - Configured app-name prefix.
43
- * @param projectId - The project id from `SandboxConfig`.
44
- * @returns A syntactically valid Fly app name.
45
- * @throws {Error} When `projectId` has no characters usable in a DNS label.
43
+ * @param projectId - Complete project or deploy identity.
44
+ * @returns A DNS-safe app name no longer than 63 characters.
46
45
  */
47
46
  export declare function appNameForProject(prefix: string, projectId: string): string;
48
47
  /**
package/dist/ids.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../src/ids.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAgB7E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAqB3E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,eAAe,GACrB,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAiBjD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAE7D"}
1
+ {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../src/ids.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAKjD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAgB7E;AAWD,0EAA0E;AAC1E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,YAAY,EAAE,MAAM,CAAA;IACpB,cAAc,EAAE,MAAM,EAAE,CAAA;CACzB,CAUA;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAmB3E;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,eAAe,GACrB,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAiBjD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAE7D"}