@fabrika/provider-zerops 0.0.26 → 0.3.1

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 (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
package/src/api.ts CHANGED
@@ -1,1378 +1,29 @@
1
- // The ZEROPS driver's collaborator: a thin, typed client over the Zerops REST API. This is the whole
2
- // side-effect surface of a Zerops deploy — ADR-0003 says a deploy there is HTTP calls and nothing else, so
3
- // substituting this ONE interface is what makes the driver testable and dry-run-able (ADR-0009).
4
- //
5
- // PROVENANCE. Every request/response shape below was read off Zerops' own OpenAPI document, served at
6
- // `https://api.app-prg1.zerops.io/api/rest/public/swagger/openapi.yml` (linked from the REST API
7
- // reference). Members whose shape could NOT be read off that document are marked `UNVERIFIED:` in their
8
- // doc comment — there is exactly one such area (the log service behind `GET /project/{id}/log`), plus a
9
- // handful of BEHAVIOURS (as opposed to shapes) that no document states. Do not quietly promote one to
10
- // fact; check it against a real account first.
11
- //
12
- // SECURITY. The token is a Zerops PERSONAL ACCESS TOKEN and carries account-wide admin rights. It is
13
- // never logged, never included in an error message, and never returned from a method. There is exactly
14
- // one credential this module DOES hand back — the integration token `createIntegrationToken` mints, which
15
- // exists so that nothing has to reuse the personal one; it obeys the same no-log, no-error-message rule.
16
-
17
- import type { Sleeper } from './collaborators'
18
-
19
- /** Every status a Zerops application version can be in. VERIFIED: `OutDtoGetAppVersion.status` enum. */
20
- export type ZeropsAppVersionStatus =
21
- | 'UPLOADING'
22
- | 'WAITING_TO_BUILD'
23
- | 'BUILDING'
24
- | 'BUILD_FAILED'
25
- | 'BUILD_VALIDATION_FAILED'
26
- | 'WAITING_TO_DEPLOY'
27
- | 'DEPLOYING'
28
- | 'DEPLOY_FAILED'
29
- | 'PREPARING_RUNTIME'
30
- | 'PREPARING_RUNTIME_FAILED'
31
- | 'ACTIVE'
32
- | 'BACKUP'
33
- | 'CANCELLED'
34
-
35
- /**
36
- * `sensitive` on every user-data WRITE, create and update alike. Required by the platform — omitting it answers
37
- * `400 invalidUserInput` with `{"sensitive":["field is required"]}` (measured live, 2026-08-18) — and
38
- * `true` because most of what fabrika writes is a credential. It costs nothing to read: a sensitive
39
- * variable's `content` still comes back verbatim from `GET /service-stack/{id}/env`, also measured, so the
40
- * compare-before-write both `platform deploy` and the create-only credential slots depend on still works.
41
- */
42
- const SENSITIVE_USER_DATA = true
43
-
44
- /** The one status that means "this version is live". */
45
- export const ZEROPS_ACTIVE: ZeropsAppVersionStatus = 'ACTIVE'
46
-
47
- /**
48
- * Statuses a version never leaves. `BACKUP` is included deliberately: a version we triggered that has
49
- * become an archived one was superseded by another deploy, which is an outcome, not a step on the way.
50
- */
51
- export const ZEROPS_TERMINAL: ReadonlySet<ZeropsAppVersionStatus> = new Set<ZeropsAppVersionStatus>([
52
- 'ACTIVE',
53
- 'BACKUP',
54
- 'BUILD_FAILED',
55
- 'BUILD_VALIDATION_FAILED',
56
- 'DEPLOY_FAILED',
57
- 'PREPARING_RUNTIME_FAILED',
58
- 'CANCELLED',
59
- ])
60
-
61
- /** One application version, narrowed to what the driver reads. VERIFIED: `GET /app-version/{id}`. */
62
- export interface ZeropsAppVersion {
63
- id: string
64
- /** Absent on a response that omitted it; the poll loop treats that as "not yet known". */
65
- status?: ZeropsAppVersionStatus
66
- serviceStackId?: string
67
- projectId?: string
68
- /** Monotonic per service — how Zerops orders the 10 versions it retains. */
69
- sequence?: number
70
- /** Build-pipeline timestamps (ISO dateTime), when a build ran. */
71
- build?: { startDate?: string; endDate?: string; pipelineStart?: string; pipelineFinish?: string; pipelineFailed?: string }
72
- }
73
-
74
- /** The credential-bearing result of reserving one upload-backed application version. */
75
- export interface ZeropsAppVersionUpload {
76
- id: string
77
- /** Presigned object-storage destination. Never log it or include it in an error. */
78
- uploadUrl: string
79
- }
80
-
81
- /** An asynchronous platform operation. VERIFIED: `OutDtoProcess`. */
82
- export interface ZeropsProcess {
83
- id: string
84
- /** Free-form on the wire (`OutDtoProcess.status` is an untyped string defaulting to `PENDING`). */
85
- status?: string
86
- actionName?: string
87
- serviceStackId?: string
88
- appVersionId?: string
89
- }
90
-
91
- /**
92
- * The status a process that succeeded carries.
93
- *
94
- * VERIFIED AS A VALUE, not as an enum member: `docs/reference/zerops-platform.md` records
95
- * `stack.updateUserData`, `stack.enableSubdomainAccess` and an import's processes reaching exactly this
96
- * string live. `ResponseProcess.status` is an untyped string in the published document, so the platform
97
- * declares no set anywhere.
98
- */
99
- export const ZEROPS_PROCESS_FINISHED = 'FINISHED'
100
-
101
- /**
102
- * Statuses a process never leaves.
103
- *
104
- * UNVERIFIED AS A CLOSED SET — the OpenAPI document gives `ResponseProcess.status` no enum at all. These
105
- * are the values `zops process list --status` documents (`PENDING, RUNNING, FINISHED, FAILED, CANCELED`)
106
- * minus the two that mean "still going". Note the single `L` in `CANCELED`: an app VERSION spells the same
107
- * idea `CANCELLED`, and the two vocabularies are not the same one.
108
- *
109
- * `waitForProcess` treats everything not in here as "keep waiting", so a status this build has never seen
110
- * can only ever cost time — it can never be read as success.
111
- */
112
- export const ZEROPS_PROCESS_TERMINAL: ReadonlySet<string> = new Set(['FINISHED', 'FAILED', 'CANCELED'])
113
-
114
- /** One service created or updated by an import. VERIFIED: `OutDtoProjectImportServiceStack`. */
115
- export interface ZeropsImportedService {
116
- id: string
117
- /** The service's name as Zerops stores it (hostname, possibly suffixed by env). */
118
- name: string
119
- processes: ZeropsProcess[]
120
- }
121
-
122
- /** The result of applying an import document. VERIFIED: `ResponseProjectImport`. */
123
- export interface ZeropsImportResult {
124
- projectId: string
125
- projectName?: string
126
- services: ZeropsImportedService[]
127
- }
128
-
129
- /** Every documented project lifecycle state. VERIFIED: `ResponseProject.status`. */
130
- export type ZeropsProjectStatus = 'NEW' | 'CREATING' | 'ACTIVE' | 'DELETING' | 'FAILED' | 'STOPPING' | 'STOPPED' | 'STARTING'
131
-
132
- /** Project compute tier. The import schema calls the same choice `corePackage`. VERIFIED: `ResponseProject.mode`. */
133
- export type ZeropsProjectMode = 'LIGHT' | 'SERIOUS'
134
-
135
- /** Every documented service-stack lifecycle state. VERIFIED: `ResponseServiceStack.status`. */
136
- export type ZeropsServiceStatus =
137
- | 'NEW'
138
- | 'CREATING'
139
- | 'ACTIVE'
140
- | 'STOPPING'
141
- | 'STOPPED'
142
- | 'STARTING'
143
- | 'RESTARTING'
144
- | 'RELOADING'
145
- | 'DELETING'
146
- | 'DELETED'
147
- | 'FAILED'
148
- | 'ACTION_FAILED'
149
- | 'UPGRADING'
150
- | 'READY_TO_DEPLOY'
151
- | 'SERVICE_CREATING'
152
- | 'SERVICE_ACTIVE'
153
- | 'SERVICE_STOPPING'
154
- | 'SERVICE_STOPPED'
155
- | 'SERVICE_STARTING'
156
- | 'SERVICE_RESTARTING'
157
- | 'SERVICE_RELOADING'
158
- | 'SERVICE_DELETING'
159
- | 'SERVICE_DELETED'
160
- | 'SERVICE_FAILED'
161
- | 'SERVICE_ACTION_FAILED'
162
- | 'SERVICE_REPAIRING'
163
- | 'SERVICE_CONTAINER_FAILED'
164
- | 'SERVICE_MOVING_CONTAINER'
165
- | 'SERVICE_UPGRADING'
166
- | 'SERVICE_SCALING'
167
- | 'SERVICE_REPAIR_FAILED'
168
- | 'REPAIRING'
169
- | 'CONTAINER_FAILED'
170
- | 'MOVING_CONTAINER'
171
- | 'SCALING'
172
- | 'REPAIR_FAILED'
173
-
174
- /** One service. VERIFIED: `ResponseServiceStack` (narrowed to the fields fabrika reads). */
175
- export interface ZeropsService {
176
- id: string
177
- name: string
178
- projectId?: string
179
- status?: ZeropsServiceStatus
180
- /** Full service-type identifier, e.g. `alpine/bun@1.3`. VERIFIED: `ResponseServiceStack.base`. */
181
- base?: string
182
- activeAppVersionId?: string
183
- /** Whether Zerops' public subdomain routes this service. VERIFIED: `ResponseServiceStack.subdomainAccess`. */
184
- subdomainAccess?: boolean
185
- /** Provider profile identifier, e.g. `oltp-production`. VERIFIED: `ResponseServiceStack.autoscalingProfileId`. */
186
- autoscalingProfileId?: string
187
- }
188
-
189
- /** One project. VERIFIED: `ResponseProject` (narrowed). */
190
- export interface ZeropsProject {
191
- id: string
192
- name: string
193
- status?: ZeropsProjectStatus
194
- /** The wire calls the import schema's `corePackage` choice `mode`. */
195
- mode?: ZeropsProjectMode
196
- /** VERIFIED: `ResponseProject.description`. */
197
- description?: string
198
- /** VERIFIED: `ResponseProject.tagList`. */
199
- tagList?: string[]
200
- }
201
-
202
- /**
203
- * A Zerops access role. VERIFIED: the `roleCode` enum, shared by `RequestClientIntegrationToken` and
204
- * `ResponseClientIntegrationTokenRaw`.
205
- *
206
- * Tiered, and the schema's own description states the order: `OWNER > ADMIN > BASIC_USER > READ_ONLY >
207
- * NO_ACCESS`, with a PROJECT role overriding the client-wide one. That override is the whole mechanism
208
- * behind a scoped token — client `NO_ACCESS` plus one project `ADMIN` reaches exactly one project.
209
- */
210
- export type ZeropsRoleCode = 'OWNER' | 'ADMIN' | 'BASIC_USER' | 'READ_ONLY' | 'NO_ACCESS'
211
-
212
- /**
213
- * One project grant carried by an integration token. VERIFIED:
214
- * `ResponseClientIntegrationTokenRaw.projects[]` — `projectId` is required and `roleCode` is not, so a
215
- * grant read back can omit it (the schema documents `NO_ACCESS` as the default; this client does not
216
- * substitute one, because an assumed grant is worse than an absent one).
217
- */
218
- export interface ZeropsTokenProjectGrant {
219
- projectId: string
220
- roleCode?: ZeropsRoleCode
221
- }
222
-
223
- /**
224
- * A freshly minted integration token. VERIFIED: `ResponseClientIntegrationTokenRaw` — `id`, `name`,
225
- * `projects` and `token` are all in its `required` list.
226
- *
227
- * THE ONE SHAPE ON THIS CLIENT THAT CARRIES A CREDENTIAL, and the `Raw` in the schema's name is why: the
228
- * plaintext `token` appears in this one response and nowhere afterwards. Everything the module header says
229
- * about the personal access token applies to it — never logged, never in an error message — with the
230
- * single exception that it must reach whatever is going to store it.
231
- */
232
- export interface ZeropsIntegrationToken {
233
- id: string
234
- name: string
235
- /** The bearer value. NEVER log it. */
236
- token: string
237
- /** The grants the platform actually recorded, so a caller can check the scope it asked for. */
238
- projects: ZeropsTokenProjectGrant[]
239
- /** The client-wide role. Absent from the response's `required` list, so it can genuinely be missing. */
240
- roleCode?: ZeropsRoleCode
241
- }
242
-
243
- /**
244
- * ONE service-level environment variable. VERIFIED: `OutDtoUserData`.
245
- *
246
- * UNVERIFIED BEHAVIOUR: whether `content` carries the real value for a variable Zerops stores as a
247
- * SECRET, or a blurred placeholder. `docs/backlog/06-can-zerops-secrets-be-read-back.md` is open on
248
- * exactly this; the dashboard UX depends on the answer. Never log `content`.
249
- */
250
- export interface ZeropsServiceEnv {
251
- id: string
252
- key: string
253
- content: string
254
- serviceStackId?: string
255
- /** `READ_ONLY` | `EDITABLE` | `SECRET` | `INTERNAL` | `ENV` — VERIFIED as an enum, meaning inferred. */
256
- type?: string
257
- }
258
-
259
- /**
260
- * A short-lived grant for the project's LOG service. VERIFIED: `ResponseProjectLog` — the field names and
261
- * types are exactly these. What the URLs speak was measured separately; see `readBuildLog`.
262
- */
263
- export interface ZeropsLogAccess {
264
- url: string
265
- urlPlain: string
266
- urlInfo: string
267
- urlUi: string
268
- /** Bearer for the log service (a different credential from the personal access token). Never log it. */
269
- accessToken: string
270
- /** ISO dateTime after which the grant is dead and must be re-fetched. */
271
- expiration: string
272
- }
273
-
274
- /** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
275
- const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
276
-
277
- /** One relayed build/runtime log line. Shape is fabrika's, not the platform's. */
278
- export interface ZeropsLogLine {
279
- /** RFC3339 stamp Zerops put at the head of the line, when the line carried one. */
280
- timestamp?: string
281
- /** The line as the log service wrote it, timestamp and tag included. */
282
- message: string
283
- }
284
-
285
- /**
286
- * The Zerops REST surface fabrika depends on, as ONE injectable interface (ADR-0009's collaborator
287
- * bundle). Every method takes the run's `AbortSignal`: a driver that polls `/app-version` is precisely the
288
- * case cancellation was added for, and an abandoned run must not keep an HTTP request alive.
289
- *
290
- * WIDER THAN THE DRIVER, on purpose. The driver uses four of these; the rest are the CONTROL PLANE's
291
- * Zerops surface — the service-env methods for the edit-time secret writes that ADR-0004 says are not a
292
- * deploy step, `importProject` for creating a managed project, and `getAppVersion`/`latestAppVersion` for
293
- * the startup reconciliation of in-flight runs that ADR-0003 makes a requirement. One client, so there is
294
- * one place the Zerops contract is written down.
295
- *
296
- * NOTE WHAT IS ABSENT. There is no method that writes a PROJECT-level environment variable. That is not
297
- * an oversight and must not be "completed": project-level variables are injected into every service in the
298
- * project, and one project holds many apps, so a project-level write hands app A's credentials to app B
299
- * (ADR-0004). The invariant is enforced by the surface not existing — `POST /project/{id}/env` and
300
- * `PUT /project-env/{id}` are real endpoints that this client deliberately does not expose. `getProjectEnv`
301
- * reads one back (for drift reporting); nothing here can create or change one.
302
- */
303
- export interface ZeropsApi {
304
- // ── service-stack: applying the compiled import document ──────────────────────
305
-
306
- /**
307
- * Apply a `zerops-import.yaml` (its `services:` section) to an EXISTING project — fabrika's
308
- * provisioning step. VERIFIED: `POST /project/{id}/service-stack/import`, body `{ yaml }`.
309
- *
310
- * VERIFIED (2026-08-05): re-applying an unchanged document is a complete no-op — same ids, no
311
- * processes, `lastUpdate` unmoved. That is ADR-0003's idempotency claim, and it is ALL of it: a
312
- * changed `profile`/`maxContainers`/`objectStorageSize` is silently ignored too, so this reconciles
313
- * nothing. Change a live field through the API that owns it.
314
- */
315
- importServices(input: { projectId: string; yaml: string; signal: AbortSignal }): Promise<ZeropsImportResult>
316
-
317
- /**
318
- * Apply a full `zerops-import.yaml` INCLUDING its `project:` section, creating the project.
319
- * VERIFIED: `POST /client/{id}/project/import`, body `{ yaml }`.
320
- *
321
- * Needed because `envIsolation` is settable at project CREATION only — `RequestPutProject` has no such
322
- * field, so a project made without it cannot be corrected through the project API afterwards. The
323
- * driver compensates by also setting `envIsolation` per service, which the schema says overrides the
324
- * project-level value.
325
- */
326
- importProject(input: { clientId: string; yaml: string; signal: AbortSignal }): Promise<ZeropsImportResult>
327
-
328
- // ── app-version: build + deploy ───────────────────────────────────────────────
329
-
330
- /**
331
- * Trigger a build+deploy pipeline for a service. VERIFIED:
332
- * `PUT /service-stack/{id}/trigger-pipeline`, body `RequestPutStandardServiceStackTriggerPipeline`.
333
- *
334
- * With no `buildFromGit` the platform builds from the service's configured Git integration (the
335
- * private-repo path); with one it does a one-time build from that PUBLIC repository URL.
336
- *
337
- * UNVERIFIED BEHAVIOUR: the response is `{ process?: … }` with EVERY field optional, so whether the
338
- * new app-version id is reliably reachable at `process.appVersion.id` is a guess. The driver therefore
339
- * does not depend on it — it resolves the version it triggered via `latestAppVersion` instead.
340
- */
341
- triggerPipeline(input: {
342
- serviceId: string
343
- /** Public Git repository URL for a one-time build. Omit to use the service's Git integration. */
344
- buildFromGit?: string
345
- /** Inline `zerops.yaml`, overriding whatever is in the repo. */
346
- zeropsYaml?: string
347
- /** Which `setup:` block of the `zerops.yaml` to use. */
348
- zeropsSetup?: string
349
- signal: AbortSignal
350
- }): Promise<ZeropsProcess | null>
351
-
352
- /**
353
- * Reserve an upload-backed application version. VERIFIED: `POST /service-stack/{id}/app-version`,
354
- * optional body `{ name }`, response `ResponsePostAppVersion`.
355
- */
356
- createAppVersion(input: { serviceId: string; name?: string; signal: AbortSignal }): Promise<ZeropsAppVersionUpload>
357
-
358
- /**
359
- * Build and deploy an application version whose source archive has already been uploaded. VERIFIED:
360
- * `PUT /app-version/{id}/build-and-deploy`, required body `{ zeropsYaml }` and optional
361
- * `zeropsYamlSetup`.
362
- */
363
- buildAndDeployAppVersion(input: {
364
- appVersionId: string
365
- zeropsYaml: string
366
- zeropsYamlSetup?: string
367
- signal: AbortSignal
368
- }): Promise<ZeropsProcess>
369
-
370
- /** Delete an application version, including an untriggered upload reservation. VERIFIED: `DELETE /app-version/{id}`. */
371
- deleteAppVersion(input: { appVersionId: string; signal: AbortSignal }): Promise<void>
372
-
373
- /** One application version's current state — the poll target. VERIFIED: `GET /app-version/{id}`. */
374
- getAppVersion(input: { appVersionId: string; signal: AbortSignal }): Promise<ZeropsAppVersion>
375
-
376
- /**
377
- * The newest application version of a service. VERIFIED: `GET /service-stack/{id}/app-version`
378
- * (`{ list, totalCount }`, `limit`/`offset`/`statuses` query params).
379
- *
380
- * UNVERIFIED BEHAVIOUR: the list's ORDER. The driver does not rely on it — it picks the highest
381
- * `sequence`, which the schema documents as monotonic per service.
382
- */
383
- latestAppVersion(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsAppVersion | null>
384
-
385
- /** Cancel an in-flight build — what a cancelled run does with the work it started. VERIFIED: `PUT /app-version/{id}/cancel-build`. */
386
- cancelBuild(input: { appVersionId: string; signal: AbortSignal }): Promise<void>
387
-
388
- // ── process: the asynchronous half of almost every write ──────────────────────
389
-
390
- /**
391
- * One platform operation's current state. VERIFIED: `GET /process/{id}` (`GetProcessDetails`,
392
- * response `ResponseProcess`).
393
- *
394
- * WHAT IT IS FOR. Several writes here return a process INSTEAD of their result and complete afterwards:
395
- * an import hands back one per service it touched, and `POST /service-stack/{id}/user-data` answers with
396
- * a process rather than the record it created. A caller that imports services and then immediately
397
- * writes their environment needs to know the import's processes finished, and until this existed
398
- * nothing could ask.
399
- *
400
- * UNVERIFIED BEHAVIOUR: the status VOCABULARY (see `ZEROPS_PROCESS_TERMINAL`). The published document
401
- * types `status` as a bare string defaulting to `PENDING`, so it stays `string` here rather than being
402
- * narrowed to a set nobody publishes.
403
- */
404
- getProcess(input: { processId: string; signal: AbortSignal }): Promise<ZeropsProcess>
405
-
406
- // ── service-stack: public access ──────────────────────────────────────────────
407
-
408
- /**
409
- * Publish a service on Zerops' generated `*.zerops.app` subdomain. VERIFIED:
410
- * `PUT /service-stack/{id}/enable-subdomain-access`, no body, `ResponseProcess`.
411
- *
412
- * THE ONLY THING THAT ESTABLISHES ONE. `enableSubdomainAccess: true` in an import document is accepted
413
- * and then silently dropped — on CREATE as well as on `override` — so a service imported with it reads
414
- * back `subdomainAccess: false` and stays that way (`docs/reference/zerops-platform.md`).
415
- *
416
- * TWO ORDERING FACTS the caller must respect. It needs a DEPLOYED HTTP port: before the first deploy
417
- * the platform answers `400 serviceStackIsNotHttp`. And a 2xx here is NOT a success signal — calling it
418
- * on a service that already has a subdomain returns a process that then FAILS — so decide by reading
419
- * `getService().subdomainAccess` back, never by this call returning.
420
- */
421
- enableSubdomainAccess(input: { serviceId: string; signal: AbortSignal }): Promise<void>
422
-
423
- // ── service-stack / project: reading state ────────────────────────────────────
424
-
425
- /** One service by id. VERIFIED: `GET /service-stack/{id}`. */
426
- getService(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsService>
427
-
428
- /** One service by hostname within a project, or `null` if absent. VERIFIED: `GET /service-stack-by-name/{projectId}/{name}`. */
429
- findService(input: { projectId: string; hostname: string; signal: AbortSignal }): Promise<ZeropsService | null>
430
-
431
- /** One project by id. VERIFIED: `GET /project/{id}`. */
432
- getProject(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsProject>
433
-
434
- /**
435
- * Every project under a client. VERIFIED: `GET /client/{id}/project`.
436
- * The implementation follows the response's `totalCount` with explicit `limit`/`offset` pages.
437
- */
438
- listProjects(input: { clientId: string; signal: AbortSignal }): Promise<ZeropsProject[]>
439
-
440
- /**
441
- * Every exact-name match under a client. VERIFIED: `GET /client/{id}/projects-by-name/{name}`.
442
- * Zerops returns an array, so duplicate names remain visible to the caller.
443
- */
444
- findProjects(input: { clientId: string; name: string; signal: AbortSignal }): Promise<ZeropsProject[]>
445
-
446
- /**
447
- * Every service stack under a project. VERIFIED: `GET /project/{id}/service-stack`.
448
- * The implementation follows the response's `totalCount` with explicit `limit`/`offset` pages.
449
- */
450
- listProjectServices(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsService[]>
451
-
452
- // ── user-data: SERVICE-level environment variables (ADR-0004) ─────────────────
453
- //
454
- // EVERY WRITE HERE IS ASYNCHRONOUS. Create, replace and delete each answer with a PENDING
455
- // `stack.updateUserData` process rather than the record, and while one is running the platform
456
- // refuses the next operation on that service with `400 userDataSyncRunning`. So all three wait for
457
- // their own process before returning: a caller that writes a variable and then builds — which is
458
- // what every deploy does — must not have to know that.
459
-
460
- /**
461
- * Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
462
- * VERIFIED: `GET /service-stack/{id}/env` (`{ items }`).
463
- *
464
- * NOT `GET /service-stack/{id}/user-data`: it answers a list now (2026-08-21), but `/env` is the
465
- * endpoint whose record ids `PUT`/`DELETE /user-data/{id}` take, and the list's paging terms were
466
- * never verified — see `docs/reference/zerops-platform.md`. The `/env` reading omits
467
- * variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
468
- * The OpenAPI publishes no lookup/pagination terms and live probes found query terms ignored, so the
469
- * implementation reads one full response under a byte bound rather than imposing an entry-count cap.
470
- */
471
- listServiceEnv(input: { serviceId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv[]>
472
-
473
- /**
474
- * Create ONE service-level variable only when the key is absent.
475
- * VERIFIED: `POST /service-stack/{id}/user-data`, body `{ key, content }`.
476
- *
477
- * Unlike `putServiceEnv`, a duplicate is an error and is never followed by a read or update. Error
478
- * details are intentionally unavailable because both the request and an upstream response can contain
479
- * the secret value.
480
- */
481
- createServiceEnv(input: { serviceId: string; key: string; value: string; signal: AbortSignal }): Promise<void>
482
-
483
- /**
484
- * Create-or-update ONE service-level variable — the only way fabrika ever writes a secret, and it is
485
- * addressed BY SERVICE so ADR-0004's invariant cannot be violated by calling it.
486
- * VERIFIED: `POST /service-stack/{id}/user-data` creates and `PUT /user-data/{id}` replaces, both
487
- * `{ key, content }`; POSTing an existing key answers `400 userDataDuplicateKey` and changes nothing.
488
- *
489
- * WRITE FIRST, READ ONLY ON CONFLICT. A read before the write cannot be part of this: the create
490
- * must work on a service that has never been deployed, which is exactly ADR-0004's bring-up order.
491
- */
492
- putServiceEnv(input: { serviceId: string; key: string; value: string; signal: AbortSignal }): Promise<void>
493
-
494
- /** Delete one service-level variable by its record id. VERIFIED: `DELETE /user-data/{id}`. */
495
- deleteServiceEnv(input: { envId: string; signal: AbortSignal }): Promise<void>
496
-
497
- /**
498
- * Read ONE project-level variable back, by record id. VERIFIED: `GET /project-env/{id}`.
499
- * Read-only ON PURPOSE — see the note on this interface. Present so drift can be REPORTED.
500
- */
501
- getProjectEnv(input: { projectEnvId: string; signal: AbortSignal }): Promise<ZeropsServiceEnv>
502
-
503
- // ── client: minting a credential ──────────────────────────────────────────────
504
-
505
- /**
506
- * Mint a project-scoped INTEGRATION token. VERIFIED: `POST /client/{id}/integration-token`
507
- * (`createIntegrationToken`), body `RequestClientIntegrationToken` — `name` and `projects` are its two
508
- * required members — response `ResponseClientIntegrationTokenRaw`, whose `required` list contains
509
- * `token`.
510
- *
511
- * WHAT IT IS FOR. A control plane deployed on Zerops holds a Zerops credential, and it must not be the
512
- * personal access token an operator or a bootstrap authenticated with: that one carries account-wide
513
- * admin rights (module header). An integration token left at client `NO_ACCESS` with one project
514
- * granted `ADMIN` can do nothing outside that project.
515
- *
516
- * `roleCode` IS ALWAYS SENT, even when its value is the schema's own default, for the reason
517
- * `compile.ts` writes `envIsolation` instead of inheriting it: a security-relevant field that never
518
- * reaches the wire is a field nobody can review.
519
- *
520
- * `canCreateProjects` IS EXPOSED, because a control plane that provisions app namespaces cannot work
521
- * without it: every namespace preset creates a project, and `POST /client/{id}/project/import` answers
522
- * `403 insufficientPermissions` to a token without the flag (measured live, 2026-08-18; ADR-0034).
523
- * The cost is real and bounded — the token receives `OWNER` on what it creates, and nothing else:
524
- * `roleCode` stays `NO_ACCESS`, so a project it never created and was never granted stays unreachable.
525
- *
526
- * NOT EXPOSED, deliberately: `canViewFinances` / `canEditFinances`. Both default to `false`, and
527
- * fabrika has no use for either.
528
- *
529
- * UNVERIFIED BEHAVIOUR: EVERYTHING AT RUNTIME. No token has ever been minted against a real account —
530
- * doing so is a mutation, not an inspection — so the shapes below are the published document's and the
531
- * behaviours are unmeasured: whether a repeated `name` conflicts, whether client `NO_ACCESS` alongside
532
- * a project grant is accepted, and what the token's own prefix or length looks like.
533
- *
534
- * THE RESPONSE CARRIES A SECRET, so the implementation redacts the server's message from any error it
535
- * raises. This is the one endpoint whose error envelope could quote a token value back.
536
- */
537
- createIntegrationToken(input: {
538
- clientId: string
539
- /** The token's label in the Zerops UI. Not a secret and not unique as far as any document says. */
540
- name: string
541
- /** Per-project grants. `ADMIN` is what a control plane needs on a project it deploys into. */
542
- projects: readonly { projectId: string; roleCode: ZeropsRoleCode }[]
543
- /** The client-wide role. Defaults to `NO_ACCESS`, which is the entire point of a scoped token. */
544
- roleCode?: ZeropsRoleCode
545
- /** Let the token create projects, which it then OWNS. Required to provision app namespaces (ADR-0034). */
546
- canCreateProjects?: boolean
547
- signal: AbortSignal
548
- }): Promise<ZeropsIntegrationToken>
549
-
550
- // ── logs ──────────────────────────────────────────────────────────────────────
551
-
552
- /** A short-lived grant for the project's log service. VERIFIED: `GET /project/{id}/log`. */
553
- getLogAccess(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsLogAccess>
554
-
555
- /**
556
- * One window of the project's log, narrowed to ONE app version.
557
- *
558
- * VERIFIED 2026-08-21 by measurement, not by document: the OpenAPI file stops at the grant, so the
559
- * separate log service was read against a live project. `urlPlain` answers one
560
- * `<RFC3339 timestamp> <tag> <message>` line per record and accepts `serviceStackId`, `limit`, `tags`
561
- * and `desc`; `from` and unknown terms are accepted and silently ignored, so nothing may depend on them.
562
- *
563
- * ORDERING, also measured: `limit=N` without `desc` returns the NEWEST N records in ASCENDING timestamp
564
- * order (`limit=3` against a log starting 15:50 answered 17:00:00, 17:00:00, 17:15:00), and `desc=1`
565
- * returns the same newest N, newest first. So `desc` is needed neither for the `since` cut nor for a
566
- * build longer than the limit: each poll re-reads the newest window and the caller's dedupe streams it.
567
- *
568
- * A version's BUILD lines carry the tag `zbuilder@<appVersionId>` and are produced on a separate
569
- * builder stack, so they are selected by `tags` ALONE — combining that tag with the runtime
570
- * `serviceStackId` matches nothing. RUNTIME lines carry no version marker at all and the runtime
571
- * service's window mixes every version that ever ran, so they are selected by `serviceStackId` and cut
572
- * at `since` (the version's `build.pipelineStart`). Without `since` no runtime line is read: a line
573
- * from an earlier version is worse in a run log than a missing one. The cut is by TIME, not identity —
574
- * the outgoing container keeps logging until it is replaced, so its lines stamped after `since` still
575
- * pass, and only the earlier version's older history is excluded.
576
- *
577
- * The two windows are read INDEPENDENTLY: one failing must not discard what the other returned, and only
578
- * a failure of every window attempted is raised. The driver then treats that as non-fatal anyway — log
579
- * relay degrades to "no lines" and the deploy succeeds or fails on `/app-version` status alone. A log
580
- * service must never be able to fail a deploy.
581
- */
582
- readBuildLog(input: {
583
- access: ZeropsLogAccess
584
- serviceId: string
585
- /** Select this version's build lines by their `zbuilder@` tag; without it no build line is read. */
586
- appVersionId?: string
587
- /** RFC3339 lower bound for runtime lines, normally `build.pipelineStart`; without it none are read. */
588
- since?: string
589
- limit?: number
590
- signal: AbortSignal
591
- }): Promise<ZeropsLogLine[]>
592
- }
593
-
594
- // ── default (real) implementation ───────────────────────────────────────────────
595
-
596
- /** Base URL of the Zerops public REST API. */
597
- export const ZEROPS_API_BASE = 'https://api.app-prg1.zerops.io/api/rest/public'
598
-
599
- /**
600
- * Just enough of `fetch` for this client — narrower than `typeof fetch` so a test can supply a plain
601
- * function without also implementing the runtime's extras (`preconnect`).
602
- */
603
- export type FetchLike = (
604
- input: string,
605
- init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
606
- ) => Promise<Response>
607
-
608
- /** How the real client is built. `fetchImpl` exists so a test can drive the REAL client offline. */
609
- export interface ZeropsApiOptions {
610
- /** Zerops personal access token. Sent as `Authorization: Bearer`. NEVER logged. */
611
- token: string
612
- /** Override for a different region's API host. */
613
- baseUrl?: string
614
- fetchImpl?: FetchLike
615
- /** How the user-data writes wait for their platform process. Tests pass a sleeper that does not sleep. */
616
- sleep?: Sleeper
617
- }
618
-
619
- /** Read a property off an unknown value without asserting anything about the value's shape. */
620
- const prop = (
621
- value: unknown,
622
- key: string,
623
- ): unknown => (typeof value === 'object' && value !== null && key in value ? Reflect.get(value, key) : undefined)
624
-
625
- const str = (value: unknown, key: string): string | undefined => {
626
- const found = prop(value, key)
627
- return typeof found === 'string' ? found : undefined
628
- }
629
-
630
- const num = (value: unknown, key: string): number | undefined => {
631
- const found = prop(value, key)
632
- return typeof found === 'number' ? found : undefined
633
- }
634
-
635
- const bool = (value: unknown, key: string): boolean | undefined => {
636
- const found = prop(value, key)
637
- return typeof found === 'boolean' ? found : undefined
638
- }
639
-
640
- const arr = (value: unknown, key: string): unknown[] => {
641
- const found = prop(value, key)
642
- return Array.isArray(found) ? found : []
643
- }
644
-
645
- const stringList = (value: unknown, key: string): string[] | undefined => {
646
- const found = prop(value, key)
647
- if (!Array.isArray(found) || found.some((entry) => typeof entry !== 'string')) {
648
- return undefined
649
- }
650
- return found.filter((entry): entry is string => typeof entry === 'string')
651
- }
652
-
653
- /** Every valid `ZeropsAppVersionStatus`, in pipeline order. The array's element type keeps it exhaustive. */
654
- const STATUSES: readonly ZeropsAppVersionStatus[] = [
655
- 'UPLOADING',
656
- 'WAITING_TO_BUILD',
657
- 'BUILDING',
658
- 'BUILD_FAILED',
659
- 'BUILD_VALIDATION_FAILED',
660
- 'WAITING_TO_DEPLOY',
661
- 'DEPLOYING',
662
- 'DEPLOY_FAILED',
663
- 'PREPARING_RUNTIME',
664
- 'PREPARING_RUNTIME_FAILED',
665
- 'ACTIVE',
666
- 'BACKUP',
667
- 'CANCELLED',
668
- ]
669
-
670
- const PROJECT_STATUSES: readonly ZeropsProjectStatus[] = [
671
- 'NEW',
672
- 'CREATING',
673
- 'ACTIVE',
674
- 'DELETING',
675
- 'FAILED',
676
- 'STOPPING',
677
- 'STOPPED',
678
- 'STARTING',
679
- ]
680
-
681
- const PROJECT_MODES: readonly ZeropsProjectMode[] = ['LIGHT', 'SERIOUS']
682
-
683
- const SERVICE_STATUSES: readonly ZeropsServiceStatus[] = [
684
- 'NEW',
685
- 'CREATING',
686
- 'ACTIVE',
687
- 'STOPPING',
688
- 'STOPPED',
689
- 'STARTING',
690
- 'RESTARTING',
691
- 'RELOADING',
692
- 'DELETING',
693
- 'DELETED',
694
- 'FAILED',
695
- 'ACTION_FAILED',
696
- 'UPGRADING',
697
- 'READY_TO_DEPLOY',
698
- 'SERVICE_CREATING',
699
- 'SERVICE_ACTIVE',
700
- 'SERVICE_STOPPING',
701
- 'SERVICE_STOPPED',
702
- 'SERVICE_STARTING',
703
- 'SERVICE_RESTARTING',
704
- 'SERVICE_RELOADING',
705
- 'SERVICE_DELETING',
706
- 'SERVICE_DELETED',
707
- 'SERVICE_FAILED',
708
- 'SERVICE_ACTION_FAILED',
709
- 'SERVICE_REPAIRING',
710
- 'SERVICE_CONTAINER_FAILED',
711
- 'SERVICE_MOVING_CONTAINER',
712
- 'SERVICE_UPGRADING',
713
- 'SERVICE_SCALING',
714
- 'SERVICE_REPAIR_FAILED',
715
- 'REPAIRING',
716
- 'CONTAINER_FAILED',
717
- 'MOVING_CONTAINER',
718
- 'SCALING',
719
- 'REPAIR_FAILED',
720
- ]
721
-
722
- /**
723
- * Narrow a wire string to a known status. `undefined` means Zerops sent something this build does not
724
- * know — which the poll loop must treat as "keep waiting", never as success or failure.
725
- */
726
- export const asAppVersionStatus = (value: unknown): ZeropsAppVersionStatus | undefined => STATUSES.find((status) => status === value)
727
-
728
- const asProjectStatus = (value: unknown): ZeropsProjectStatus | undefined => PROJECT_STATUSES.find((status) => status === value)
729
-
730
- const asProjectMode = (value: unknown): ZeropsProjectMode | undefined => PROJECT_MODES.find((mode) => mode === value)
731
-
732
- const asServiceStatus = (value: unknown): ZeropsServiceStatus | undefined => SERVICE_STATUSES.find((status) => status === value)
733
-
734
- const readAppVersion = (value: unknown): ZeropsAppVersion => ({
735
- id: str(value, 'id') ?? '',
736
- status: asAppVersionStatus(prop(value, 'status')),
737
- serviceStackId: str(value, 'serviceStackId'),
738
- projectId: str(value, 'projectId'),
739
- sequence: num(value, 'sequence'),
740
- build: typeof prop(value, 'build') === 'object' && prop(value, 'build') !== null
741
- ? {
742
- startDate: str(prop(value, 'build'), 'startDate'),
743
- endDate: str(prop(value, 'build'), 'endDate'),
744
- pipelineStart: str(prop(value, 'build'), 'pipelineStart'),
745
- pipelineFinish: str(prop(value, 'build'), 'pipelineFinish'),
746
- pipelineFailed: str(prop(value, 'build'), 'pipelineFailed'),
747
- }
748
- : undefined,
749
- })
750
-
751
- const readProcess = (value: unknown): ZeropsProcess => ({
752
- id: str(value, 'id') ?? '',
753
- status: str(value, 'status'),
754
- actionName: str(value, 'actionName'),
755
- serviceStackId: str(value, 'serviceStackId'),
756
- appVersionId: str(prop(value, 'appVersion'), 'id'),
757
- })
758
-
759
- const readService = (value: unknown): ZeropsService => ({
760
- id: str(value, 'id') ?? '',
761
- name: str(value, 'name') ?? '',
762
- projectId: str(value, 'projectId'),
763
- status: asServiceStatus(prop(value, 'status')),
764
- base: str(value, 'base'),
765
- activeAppVersionId: str(prop(value, 'activeAppVersion'), 'id'),
766
- ...(bool(value, 'subdomainAccess') !== undefined ? { subdomainAccess: bool(value, 'subdomainAccess') } : {}),
767
- ...(str(value, 'autoscalingProfileId') !== undefined ? { autoscalingProfileId: str(value, 'autoscalingProfileId') } : {}),
768
- })
769
-
770
- const readProject = (value: unknown): ZeropsProject => ({
771
- id: str(value, 'id') ?? '',
772
- name: str(value, 'name') ?? '',
773
- status: asProjectStatus(prop(value, 'status')),
774
- mode: asProjectMode(prop(value, 'mode')),
775
- ...(str(value, 'description') !== undefined ? { description: str(value, 'description') } : {}),
776
- ...(stringList(value, 'tagList') !== undefined ? { tagList: stringList(value, 'tagList') } : {}),
777
- })
778
-
779
- const ROLE_CODES: readonly ZeropsRoleCode[] = ['OWNER', 'ADMIN', 'BASIC_USER', 'READ_ONLY', 'NO_ACCESS']
780
-
781
- /** `undefined` means Zerops sent a role this build does not know — reported as absent, never as a guess. */
782
- const asRoleCode = (value: unknown): ZeropsRoleCode | undefined => ROLE_CODES.find((role) => role === value)
783
-
784
- const readIntegrationToken = (value: unknown): ZeropsIntegrationToken => ({
785
- id: str(value, 'id') ?? '',
786
- name: str(value, 'name') ?? '',
787
- token: str(value, 'token') ?? '',
788
- projects: arr(value, 'projects').map((entry) => ({
789
- projectId: str(entry, 'projectId') ?? '',
790
- roleCode: asRoleCode(prop(entry, 'roleCode')),
791
- })),
792
- roleCode: asRoleCode(prop(value, 'roleCode')),
793
- })
794
-
795
- const readServiceEnv = (value: unknown): ZeropsServiceEnv => ({
796
- id: str(value, 'id') ?? '',
797
- key: str(value, 'key') ?? '',
798
- content: str(value, 'content') ?? '',
799
- serviceStackId: str(value, 'serviceStackId'),
800
- type: str(value, 'type'),
801
- })
802
-
803
- /**
804
- * A non-2xx answer from the Zerops API. `code` is the platform's own error identifier, so a caller can
805
- * branch on `userDataDuplicateKey` rather than matching a prose message.
806
- */
807
- export class ZeropsApiError extends Error {
808
- constructor(message: string, readonly status: number, readonly code: string) {
809
- super(message)
810
- this.name = 'ZeropsApiError'
811
- }
812
- }
813
-
814
- /** The platform's code for "this service stack already has a variable under that key". */
815
- const USER_DATA_DUPLICATE_KEY = 'userDataDuplicateKey'
816
-
817
- /** The platform's code for a service stack it cannot find — sent with a 400, not a 404. */
818
- const ZEROPS_SERVICE_STACK_NOT_FOUND = 'serviceStackNotFound'
819
-
820
- /**
821
- * The platform's code for "this service publishes no HTTP port", the answer `enableSubdomainAccess` gives
822
- * before a service's first deploy. Exported so a caller can say what is missing instead of relaying prose.
823
- */
824
- export const ZEROPS_SERVICE_NOT_HTTP = 'serviceStackIsNotHttp'
825
-
826
- /**
827
- * Turn a non-2xx response into a `ZeropsApiError`. Zerops answers with `{ error: { code, message, meta } }`
828
- * (the envelope observed live; the OpenAPI `Error` schema shows the inner `{ code, message }`).
829
- *
830
- * `redactDetail` drops the server's MESSAGE, which a validation error can fill with the value it
831
- * rejected. The CODE is a fixed identifier and is kept either way — without it nothing downstream can
832
- * tell a duplicate key from a missing service.
833
- */
834
- const apiError = (label: string, status: number, body: unknown, redactDetail: boolean): ZeropsApiError => {
835
- const code = str(prop(body, 'error'), 'code') ?? str(body, 'code') ?? ''
836
- const message = redactDetail ? '' : (str(prop(body, 'error'), 'message') ?? str(body, 'message') ?? '')
837
- const detail = [code, message].filter((part) => part !== '').join(': ')
838
- return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
839
- }
840
-
841
- const timerSleep: Sleeper = (ms, signal) =>
842
- new Promise<void>((resolve) => {
843
- if (signal.aborted) {
844
- resolve()
845
- return
846
- }
847
- const timer = setTimeout(() => {
848
- signal.removeEventListener('abort', onAbort)
849
- resolve()
850
- }, ms)
851
- const onAbort = (): void => {
852
- clearTimeout(timer)
853
- resolve()
854
- }
855
- signal.addEventListener('abort', onAbort, { once: true })
856
- })
857
-
858
- /** Build the real client. Every request carries the bearer and the run's signal. */
859
- export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
860
- const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
861
- const doFetch = options.fetchImpl ?? fetch
862
- // Its own, rather than `collaborators`' — that module imports this one, so taking the value back would close a cycle.
863
- const sleep = options.sleep ?? timerSleep
864
- const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
865
- const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
866
- const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
867
- class ResponseByteBoundError extends Error {}
868
-
869
- const boundedJson = async (response: Response, label: string, maxBytes: number, signal: AbortSignal): Promise<unknown> => {
870
- if (signal.aborted) {
871
- void response.body?.cancel().catch(() => {})
872
- throw abortError()
873
- }
874
- const contentLength = response.headers.get('content-length')
875
- if (contentLength !== null) {
876
- const declaredBytes = Number(contentLength)
877
- if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
878
- void response.body?.cancel().catch(() => {})
879
- throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
880
- }
881
- }
882
- if (response.body === null) return null
883
- const reader = response.body.getReader()
884
- let abortRead = (): void => {}
885
- const aborted = new Promise<never>((_resolve, reject) => {
886
- abortRead = (): void => {
887
- void reader.cancel().catch(() => {})
888
- reject(abortError())
889
- }
890
- signal.addEventListener('abort', abortRead, { once: true })
891
- if (signal.aborted) abortRead()
892
- })
893
- const chunks: Uint8Array[] = []
894
- let receivedBytes = 0
895
- try {
896
- while (true) {
897
- const next = await Promise.race([reader.read(), aborted])
898
- if (signal.aborted) throw abortError()
899
- if (next.done) break
900
- receivedBytes += next.value.byteLength
901
- if (receivedBytes > maxBytes) {
902
- void reader.cancel().catch(() => {})
903
- throw new ResponseByteBoundError(`zerops: ${label} response exceeded its byte bound`)
904
- }
905
- chunks.push(next.value)
906
- }
907
- } finally {
908
- signal.removeEventListener('abort', abortRead)
909
- }
910
- const bytes = new Uint8Array(receivedBytes)
911
- let offset = 0
912
- for (const chunk of chunks) {
913
- bytes.set(chunk, offset)
914
- offset += chunk.byteLength
915
- }
916
- return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
917
- }
918
-
919
- const request = async (
920
- label: string,
921
- method: string,
922
- path: string,
923
- /**
924
- * `redactDetail` drops the server's message from the thrown error, keeping only its code. Set it on
925
- * any call whose BODY carries a secret value: a validation error can quote what it rejected, and an
926
- * error that echoes a secret into a deploy log is a leak whichever end wrote it.
927
- * `omitErrorResponseDetails` also drops the code for upload flows whose response may carry a
928
- * credential; nothing from that response body may reach an error.
929
- */
930
- init: {
931
- body?: unknown
932
- signal: AbortSignal
933
- redactDetail?: boolean
934
- omitErrorResponseDetails?: boolean
935
- responseMaxBytes?: number
936
- },
937
- ): Promise<unknown> => {
938
- if (init.signal.aborted) {
939
- throw abortError()
940
- }
941
- const headers: Record<string, string> = { authorization: `Bearer ${options.token}`, accept: 'application/json' }
942
- if (init.body !== undefined) {
943
- headers['content-type'] = 'application/json'
944
- }
945
- let response: Response
946
- try {
947
- response = await doFetch(`${base}${path}`, {
948
- method,
949
- headers,
950
- body: init.body === undefined ? undefined : JSON.stringify(init.body),
951
- signal: init.signal,
952
- })
953
- } catch (error) {
954
- if (init.signal.aborted || isAbortError(error)) throw abortError()
955
- throw error
956
- }
957
- let payload: unknown
958
- try {
959
- payload = init.responseMaxBytes === undefined
960
- ? await response.json()
961
- : await boundedJson(response, label, init.responseMaxBytes, init.signal)
962
- } catch (error) {
963
- if (init.signal.aborted || isAbortError(error)) throw abortError()
964
- if (error instanceof ResponseByteBoundError) throw error
965
- payload = null
966
- }
967
- if (!response.ok) {
968
- if (init.omitErrorResponseDetails === true) {
969
- throw new ZeropsApiError(`zerops: ${label} failed (${response.status})`, response.status, '')
970
- }
971
- throw apiError(label, response.status, payload, init.redactDetail === true)
972
- }
973
- return payload
974
- }
975
-
976
- const invalidResponse = (label: string): Error => new Error(`zerops: ${label} returned an invalid response`)
977
-
978
- const requiredString = (payload: unknown, key: string, label: string): string => {
979
- const value = str(payload, key)
980
- if (value === undefined || value.trim() === '') {
981
- throw invalidResponse(label)
982
- }
983
- return value
984
- }
985
-
986
- const readImportResult = (payload: unknown): ZeropsImportResult => ({
987
- projectId: str(payload, 'projectId') ?? '',
988
- projectName: str(payload, 'projectName'),
989
- services: arr(payload, 'serviceStacks').map((entry) => ({
990
- id: str(entry, 'id') ?? '',
991
- name: str(entry, 'name') ?? '',
992
- processes: arr(entry, 'processes').map(readProcess),
993
- })),
994
- })
995
-
996
- const PAGE_SIZE = 100
997
- const readAllPages = async <Item>(
998
- label: string,
999
- path: string,
1000
- signal: AbortSignal,
1001
- readItem: (value: unknown) => Item,
1002
- ): Promise<Item[]> => {
1003
- const items: Item[] = []
1004
- let offset = 0
1005
- while (true) {
1006
- const separator = path.includes('?') ? '&' : '?'
1007
- const payload = await request(label, 'GET', `${path}${separator}limit=${PAGE_SIZE}&offset=${offset}`, { signal })
1008
- const page = arr(payload, 'list')
1009
- const totalCount = num(payload, 'totalCount')
1010
- if (totalCount === undefined || !Number.isInteger(totalCount) || totalCount < 0) {
1011
- throw new Error(`zerops: ${label} returned an invalid totalCount`)
1012
- }
1013
- items.push(...page.map(readItem))
1014
- if (items.length >= totalCount) {
1015
- return items
1016
- }
1017
- if (page.length === 0) {
1018
- throw new Error(`zerops: ${label} ended before totalCount`)
1019
- }
1020
- offset += page.length
1021
- }
1022
- }
1023
-
1024
- /**
1025
- * Named so `putServiceEnv` can reach it: the conflict path needs the record id, and the ONE endpoint
1026
- * that hands it over on every service is `/env` (`/user-data` answers 400 unconditionally).
1027
- */
1028
- const listServiceEnv: ZeropsApi['listServiceEnv'] = async ({ serviceId, signal }) => {
1029
- const payload = await request('list service env', 'GET', `/service-stack/${serviceId}/env`, {
1030
- signal,
1031
- responseMaxBytes: SERVICE_ENV_RESPONSE_MAX_BYTES,
1032
- })
1033
- const items = prop(payload, 'items')
1034
- if (!Array.isArray(items)) throw invalidResponse('list service env')
1035
- return items.map(readServiceEnv)
1036
- }
1037
-
1038
- const getProcess: ZeropsApi['getProcess'] = async ({ processId, signal }) =>
1039
- readProcess(await request('get process', 'GET', `/process/${processId}`, { signal }))
1040
-
1041
- /**
1042
- * Wait out the `stack.updateUserData` process every user-data write answers with.
1043
- *
1044
- * While one runs the platform refuses the NEXT operation on that service with `400
1045
- * userDataSyncRunning` — which is how a deploy that wrote an app's environment and then asked for a
1046
- * build failed, one second before the sync it had started finished. Measured live: PENDING → RUNNING →
1047
- * FINISHED in about three seconds, on a deployed service and on one that has never had code alike.
1048
- *
1049
- * The response is recognised by `actionName`, not by having an `id`: a user-data RECORD carries an id
1050
- * too, and polling `/process/{recordId}` would hang instead of failing. An unrecognised response is
1051
- * left alone — nothing then says the write is still settling.
1052
- */
1053
- const awaitUserDataSync = async (payload: unknown, label: string, signal: AbortSignal): Promise<void> => {
1054
- const processId = str(payload, 'id')
1055
- const action = str(payload, 'actionName')
1056
- if (processId === undefined || processId.trim() === '' || action === undefined || action.trim() === '') {
1057
- return
1058
- }
1059
- await waitForProcess({ api: { getProcess }, processId, sleep, signal, label })
1060
- }
1061
-
1062
- return {
1063
- importServices: async ({ projectId, yaml, signal }) =>
1064
- readImportResult(await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal })),
1065
-
1066
- importProject: async ({ clientId, yaml, signal }) =>
1067
- readImportResult(await request('project import', 'POST', `/client/${clientId}/project/import`, { body: { yaml }, signal })),
1068
-
1069
- triggerPipeline: async ({ serviceId, buildFromGit, zeropsYaml, zeropsSetup, signal }) => {
1070
- const body: Record<string, unknown> = {}
1071
- if (buildFromGit !== undefined) {
1072
- body['buildFromGit'] = buildFromGit
1073
- }
1074
- if (zeropsYaml !== undefined) {
1075
- body['zeropsYaml'] = zeropsYaml
1076
- }
1077
- if (zeropsSetup !== undefined) {
1078
- body['zeropsSetup'] = zeropsSetup
1079
- }
1080
- const payload = await request('trigger-pipeline', 'PUT', `/service-stack/${serviceId}/trigger-pipeline`, { body, signal })
1081
- const process = prop(payload, 'process')
1082
- return process === undefined || process === null ? null : readProcess(process)
1083
- },
1084
-
1085
- createAppVersion: async ({ serviceId, name, signal }) => {
1086
- const body = name === undefined ? {} : { name }
1087
- const payload = await request('create app-version', 'POST', `/service-stack/${serviceId}/app-version`, {
1088
- body,
1089
- signal,
1090
- omitErrorResponseDetails: true,
1091
- })
1092
- const responseServiceId = requiredString(payload, 'serviceStackId', 'create app-version')
1093
- if (responseServiceId !== serviceId) {
1094
- throw invalidResponse('create app-version')
1095
- }
1096
- return {
1097
- id: requiredString(payload, 'id', 'create app-version'),
1098
- uploadUrl: requiredString(payload, 'uploadUrl', 'create app-version'),
1099
- }
1100
- },
1101
-
1102
- buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
1103
- const body = zeropsYamlSetup === undefined ? { zeropsYaml } : { zeropsYaml, zeropsYamlSetup }
1104
- // `redactDetail`, not `omitErrorResponseDetails`: this request body is the app's COMMITTED
1105
- // descriptor and this response carries no upload URL, so there is nothing here to protect by
1106
- // dropping the code as well — and a bare `(400)` is what made `userDataSyncRunning` cost a day.
1107
- const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
1108
- body,
1109
- signal,
1110
- redactDetail: true,
1111
- })
1112
- const processId = requiredString(payload, 'id', 'build and deploy app-version')
1113
- const responseAppVersionId = str(prop(payload, 'appVersion'), 'id')
1114
- if (responseAppVersionId !== undefined && (responseAppVersionId.trim() === '' || responseAppVersionId !== appVersionId)) {
1115
- throw invalidResponse('build and deploy app-version')
1116
- }
1117
- return { ...readProcess(payload), id: processId, appVersionId }
1118
- },
1119
-
1120
- deleteAppVersion: async ({ appVersionId, signal }) => {
1121
- const payload = await request('delete app-version', 'DELETE', `/app-version/${appVersionId}`, {
1122
- signal,
1123
- omitErrorResponseDetails: true,
1124
- })
1125
- if (prop(payload, 'success') !== true) {
1126
- throw invalidResponse('delete app-version')
1127
- }
1128
- },
1129
-
1130
- getAppVersion: async ({ appVersionId, signal }) =>
1131
- readAppVersion(await request('get app-version', 'GET', `/app-version/${appVersionId}`, { signal })),
1132
-
1133
- latestAppVersion: async ({ serviceId, signal }) => {
1134
- const payload = await request('list app-versions', 'GET', `/service-stack/${serviceId}/app-version?limit=25`, { signal })
1135
- const versions = arr(payload, 'list').map(readAppVersion)
1136
- // Highest `sequence` wins — the list's order is not documented, the sequence's meaning is.
1137
- return versions.reduce<ZeropsAppVersion | null>(
1138
- (best, version) => (best === null || (version.sequence ?? -1) > (best.sequence ?? -1) ? version : best),
1139
- null,
1140
- )
1141
- },
1142
-
1143
- cancelBuild: async ({ appVersionId, signal }) => {
1144
- await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
1145
- },
1146
-
1147
- getProcess,
1148
-
1149
- enableSubdomainAccess: async ({ serviceId, signal }) => {
1150
- // No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
1151
- await request('enable subdomain access', 'PUT', `/service-stack/${serviceId}/enable-subdomain-access`, { signal })
1152
- },
1153
-
1154
- getService: async ({ serviceId, signal }) => readService(await request('get service', 'GET', `/service-stack/${serviceId}`, { signal })),
1155
-
1156
- findService: async ({ projectId, hostname, signal }) => {
1157
- try {
1158
- return readService(await request('find service', 'GET', `/service-stack-by-name/${projectId}/${encodeURIComponent(hostname)}`, { signal }))
1159
- } catch (error) {
1160
- // A missing service is an answer, not a failure; anything else propagates. The live platform
1161
- // says it as 400 `serviceStackNotFound` (docs/reference/zerops-platform.md), not as a 404.
1162
- if (error instanceof ZeropsApiError && (error.status === 404 || error.code === ZEROPS_SERVICE_STACK_NOT_FOUND)) {
1163
- return null
1164
- }
1165
- throw error
1166
- }
1167
- },
1168
-
1169
- getProject: async ({ projectId, signal }) => {
1170
- const payload = await request('get project', 'GET', `/project/${projectId}`, { signal })
1171
- return readProject(payload)
1172
- },
1173
-
1174
- listProjects: async ({ clientId, signal }) => readAllPages('list projects', `/client/${clientId}/project`, signal, readProject),
1175
-
1176
- findProjects: async ({ clientId, name, signal }) => {
1177
- const payload = await request(
1178
- 'find projects',
1179
- 'GET',
1180
- `/client/${clientId}/projects-by-name/${encodeURIComponent(name)}`,
1181
- { signal },
1182
- )
1183
- return arr(payload, 'projects').map(readProject)
1184
- },
1185
-
1186
- listProjectServices: async ({ projectId, signal }) =>
1187
- readAllPages('list project services', `/project/${projectId}/service-stack`, signal, readService),
1188
-
1189
- listServiceEnv,
1190
-
1191
- createServiceEnv: async ({ serviceId, key, value, signal }) => {
1192
- let started: unknown
1193
- try {
1194
- started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1195
- body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1196
- signal,
1197
- omitErrorResponseDetails: true,
1198
- })
1199
- } catch (error) {
1200
- if (error instanceof Error && error.name === 'AbortError') throw error
1201
- throw new Error('zerops: create-only service env failed')
1202
- }
1203
- await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
1204
- },
1205
-
1206
- putServiceEnv: async ({ serviceId, key, value, signal }) => {
1207
- const label = `the user-data sync on service ${serviceId}`
1208
- // `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
1209
- try {
1210
- const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1211
- body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1212
- signal,
1213
- redactDetail: true,
1214
- })
1215
- await awaitUserDataSync(created, label, signal)
1216
- return
1217
- } catch (error) {
1218
- if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
1219
- throw error
1220
- }
1221
- }
1222
- // Only the conflict reads, and only then: the create must work on a service that has never been
1223
- // deployed, which is the whole of ADR-0004's bring-up order.
1224
- const existing = (await listServiceEnv({ serviceId, signal })).find((entry) => entry.key === key)
1225
- if (existing === undefined || existing.id === '') {
1226
- // The service declares the key in its own `zerops.yaml` (`type: ENV`), which conflicts on create
1227
- // yet never appears in the env list. Replacing it here would be undone by the next deploy.
1228
- throw new Error(
1229
- `zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
1230
- )
1231
- }
1232
- const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
1233
- body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1234
- signal,
1235
- redactDetail: true,
1236
- })
1237
- await awaitUserDataSync(updated, label, signal)
1238
- },
1239
-
1240
- deleteServiceEnv: async ({ envId, signal }) => {
1241
- const deleted = await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
1242
- await awaitUserDataSync(deleted, `the user-data sync for record ${envId}`, signal)
1243
- },
1244
-
1245
- getProjectEnv: async ({ projectEnvId, signal }) =>
1246
- readServiceEnv(await request('get project env', 'GET', `/project-env/${projectEnvId}`, { signal })),
1247
-
1248
- createIntegrationToken: async ({ clientId, name, projects, roleCode, canCreateProjects, signal }) =>
1249
- // `redactDetail`: unlike every other write here the SECRET is in the RESPONSE, not the request, so
1250
- // this is the one error envelope that could quote a minted token back at us.
1251
- readIntegrationToken(
1252
- await request('create integration token', 'POST', `/client/${clientId}/integration-token`, {
1253
- body: {
1254
- name,
1255
- roleCode: roleCode ?? 'NO_ACCESS',
1256
- // Always sent, like `roleCode`: a security-relevant field that never reaches the wire is a
1257
- // field nobody can review.
1258
- canCreateProjects: canCreateProjects ?? false,
1259
- projects: projects.map((grant) => ({ projectId: grant.projectId, roleCode: grant.roleCode })),
1260
- },
1261
- signal,
1262
- redactDetail: true,
1263
- }),
1264
- ),
1265
-
1266
- getLogAccess: async ({ projectId, signal }) => {
1267
- const payload = await request('get log access', 'GET', `/project/${projectId}/log`, { signal })
1268
- return {
1269
- url: str(payload, 'url') ?? '',
1270
- urlPlain: str(payload, 'urlPlain') ?? '',
1271
- urlInfo: str(payload, 'urlInfo') ?? '',
1272
- urlUi: str(payload, 'urlUi') ?? '',
1273
- accessToken: str(payload, 'accessToken') ?? '',
1274
- expiration: str(payload, 'expiration') ?? '',
1275
- }
1276
- },
1277
-
1278
- readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
1279
- if (access.urlPlain === '') {
1280
- return []
1281
- }
1282
- const separator = access.urlPlain.includes('?') ? '&' : '?'
1283
- const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
1284
- try {
1285
- const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
1286
- const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
1287
- if (!response.ok) {
1288
- throw new Error(`zerops: read build log failed (${response.status})`)
1289
- }
1290
- const text = await response.text()
1291
- const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
1292
- const head = line.slice(0, Math.max(line.indexOf(' '), 0))
1293
- return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
1294
- })
1295
- return { lines, error: undefined }
1296
- } catch (error) {
1297
- return { lines: [], error }
1298
- }
1299
- }
1300
- // Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
1301
- // service id matches nothing (see the interface).
1302
- const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
1303
- const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
1304
- const attempted = [build, runtime].filter((result) => result !== undefined)
1305
- if (attempted.length === 0) {
1306
- return []
1307
- }
1308
- // Independent windows: only a failure of every one attempted is worth raising.
1309
- const failure = attempted.find((result) => result.error !== undefined)
1310
- if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
1311
- throw failure.error
1312
- }
1313
- const boundary = since === undefined ? Number.NaN : Date.parse(since)
1314
- // The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
1315
- const runtimeLines = runtime === undefined || Number.isNaN(boundary)
1316
- ? runtime?.lines ?? []
1317
- : runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
1318
- return [...build?.lines ?? [], ...runtimeLines]
1319
- },
1320
- }
1321
- }
1322
-
1323
- // ── waiting on a process ────────────────────────────────────────────────────────
1324
-
1325
- /**
1326
- * How often `waitForProcess` polls. The two process durations anyone has measured are 0.7 s
1327
- * (`stack.enableSubdomainAccess`) and 2.6 s (`stack.updateUserData`), so 5 s — the platform DEPLOY's
1328
- * interval — would spend most of its time asleep on a call that already finished.
1329
- */
1330
- const PROCESS_POLL_INTERVAL_MS = 2_000
1331
-
1332
- /** ~5 minutes. The slow case is an import that creates managed services, not a build; a build is polled through `/app-version`. */
1333
- const PROCESS_POLL_ATTEMPTS = 150
1334
-
1335
- /**
1336
- * Poll ONE process to completion.
1337
- *
1338
- * The same loop `installation-zerops`'s `deployService` runs over `/app-version`, run over `/process`
1339
- * instead: read, decide ONLY on a status this build knows to be terminal, sleep, and give up on a deadline
1340
- * of our own rather than on a coincidence. Returns the finished process so a caller can read
1341
- * `actionName`/`appVersionId` off it.
1342
- *
1343
- * Anything outside `ZEROPS_PROCESS_TERMINAL` means "keep waiting", including a status this build has never
1344
- * seen — which is why an unpublished vocabulary (see that constant) can cost a timeout and never a false
1345
- * success. A non-`FINISHED` terminal status throws, naming the status and nothing else.
1346
- */
1347
- export const waitForProcess = async (input: {
1348
- api: Pick<ZeropsApi, 'getProcess'>
1349
- processId: string
1350
- sleep: Sleeper
1351
- signal: AbortSignal
1352
- /** What to call this process in a failure or timeout message. Defaults to its id. */
1353
- label?: string
1354
- intervalMs?: number
1355
- attempts?: number
1356
- }): Promise<ZeropsProcess> => {
1357
- const label = input.label ?? input.processId
1358
- const intervalMs = input.intervalMs ?? PROCESS_POLL_INTERVAL_MS
1359
- const attempts = input.attempts ?? PROCESS_POLL_ATTEMPTS
1360
- for (let attempt = 0;; attempt += 1) {
1361
- // A `Sleeper` resolves early on abort, so without this the loop would spin out its whole budget
1362
- // against a cancelled run.
1363
- if (input.signal.aborted) {
1364
- throw new Error(`zerops: waiting for ${label} was cancelled`)
1365
- }
1366
- const process = await input.api.getProcess({ processId: input.processId, signal: input.signal })
1367
- if (process.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status)) {
1368
- if (process.status !== ZEROPS_PROCESS_FINISHED) {
1369
- throw new Error(`zerops: ${label} finished as ${process.status}`)
1370
- }
1371
- return process
1372
- }
1373
- if (attempt >= attempts) {
1374
- throw new Error(`zerops: ${label} did not finish in time`)
1375
- }
1376
- await input.sleep(intervalMs, input.signal)
1377
- }
1378
- }
1
+ export { createZeropsApi, ZEROPS_API_BASE } from './api/client'
2
+ export { ZEROPS_SERVICE_NOT_HTTP, ZeropsApiError } from './api/errors'
3
+ export { waitForProcess } from './api/polling'
4
+ export {
5
+ type FetchLike,
6
+ ZEROPS_ACTIVE,
7
+ ZEROPS_PROCESS_FINISHED,
8
+ ZEROPS_PROCESS_TERMINAL,
9
+ ZEROPS_TERMINAL,
10
+ type ZeropsApi,
11
+ type ZeropsApiOptions,
12
+ type ZeropsAppVersion,
13
+ type ZeropsAppVersionStatus,
14
+ type ZeropsAppVersionUpload,
15
+ type ZeropsImportedService,
16
+ type ZeropsImportResult,
17
+ type ZeropsIntegrationToken,
18
+ type ZeropsLogAccess,
19
+ type ZeropsLogLine,
20
+ type ZeropsProcess,
21
+ type ZeropsProject,
22
+ type ZeropsProjectMode,
23
+ type ZeropsProjectStatus,
24
+ type ZeropsRoleCode,
25
+ type ZeropsService,
26
+ type ZeropsServiceEnv,
27
+ type ZeropsServiceStatus,
28
+ type ZeropsTokenProjectGrant,
29
+ } from './api/types'