@sanoma-ai/connector-cloudflare 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ops.js CHANGED
@@ -5,14 +5,14 @@ import { instanceUrl, mintInstanceToken, WAKEUP_TOKEN } from "@sanoma-ai/workflo
5
5
  import { alarm, container } from "./connector.js";
6
6
  import { refused } from "./errors.js";
7
7
  import { accountPinned } from "./registry.js";
8
- import { serveSites, SITES_PENDING } from "./sites.js";
9
- import { HEALTH_PATH, IMAGE, MAIN_MODULE, NAMESPACE, OBJECT_CLASS, workerModules, } from "./worker.js";
10
- // Cloudflare as the operations use it (`CloudflareApi`), and the container's and the alarm's
8
+ import { appBundle, appModules, ASSETS_CONFIG, MAIN_MODULE, OBJECT_CLASS, uploadAssets, waitFor, workerModules, } from "./deploy.js";
9
+ import { HEALTH_PATH, IMAGE, NAMESPACE } from "../worker/instance.js";
10
+ // Cloudflare as the operations use it (`CloudflareApi`), and the host's and the alarm's
11
11
  // operations over it: the driver's calls its API with the SDK and the Worker over HTTP, the
12
12
  // fake's answers from its state and its Worker in this process. Both go through `containerOps`
13
13
  // and `alarmOps` (and `recordsOps`, in `records.ts`), so they upload, plan and refuse alike.
14
- /** The Workers runtime the Worker is written for. */
15
- export const COMPATIBILITY_DATE = "2026-10-01";
14
+ /** The Workers runtime of a create's uploads before its first bundle; the bundle's own after. */
15
+ const COMPATIBILITY_DATE = "2026-10-01";
16
16
  /** The migration that makes the Worker's object class, on its first upload only. */
17
17
  export const MIGRATION = { new_tag: "v1", new_sqlite_classes: [OBJECT_CLASS] };
18
18
  /** How long the account may take to prepare an image to run, as Wrangler waits for it. */
@@ -29,13 +29,17 @@ const TEXT = {
29
29
  /** The app's own custom domain, when declared: the state's `url` is then `https://<hostname>`. */
30
30
  hostname: "SANOMA_HOSTNAME",
31
31
  url: HOST_URL,
32
- /** Set while the public sites' upload is still to be made (`sites.ts`). */
33
- sitesPending: SITES_PENDING,
34
32
  };
33
+ /** The app's database, a Hyperdrive config over the host's `SANOMA_DATABASE_URL` (`AppBundleEnv.DATABASE`). */
34
+ const DATABASE = "DATABASE";
35
+ /** The app bundle's static assets (`AppBundleEnv.ASSETS`). */
36
+ const ASSETS = "ASSETS";
35
37
  /** Names a secret may not take: the Worker's own bindings. */
36
- const RESERVED = new Set([NAMESPACE, ...Object.values(TEXT)]);
38
+ const RESERVED = new Set([NAMESPACE, DATABASE, ASSETS, ...Object.values(TEXT)]);
37
39
  /** Whether a Worker's bindings are Sanoma's: its own object's namespace among them. */
38
40
  const isOurs = (bindings) => bindings.some((b) => b.type === "durable_object_namespace" && b.name === NAMESPACE);
41
+ /** Whether a Worker serves the app: only the upload of a bundle binds its database. */
42
+ const servesApp = (bindings) => bindings.some((b) => b.type === "hyperdrive" && b.name === DATABASE);
39
43
  /** The company's Worker, named after it. */
40
44
  export const scriptOf = (name) => `sanoma-${name}`;
41
45
  /** The zone a hostname is looked up in: its last two labels (`www.acme.example` → `acme.example`). */
@@ -99,26 +103,34 @@ const same = (field, a, b) => field === "domains"
99
103
  ? domainsText((a ?? {})) === domainsText((b ?? {}))
100
104
  : (a ?? null) === (b ?? null);
101
105
  /**
102
- * `{ revision }` when the Worker answers, else why not; with `start`, once its container answers
103
- * too, which starts it. A read asks without: a stopped host is healthy, and stays stopped. A
104
- * container that ended is `exit`: the Worker would start it again on the next ask, so a deploy
106
+ * `{ revision }` when the Worker's object answers its health, else why not. A read asks without
107
+ * `start`: a stopped host is healthy, and stays stopped. With `start` and the instance token, only
108
+ * once its container runs at what the Worker names and answered, which the ask starts (202
109
+ * meanwhile). A container that ended is `exit`: asking again would only start it again, so a deploy
105
110
  * stops asking.
106
111
  */
107
- const health = async (cf, url, start) => {
112
+ const health = async (cf, url, token) => {
108
113
  let res;
109
114
  try {
110
- res = await cf.send(`${url}${HEALTH_PATH}${start ? "?start=1" : ""}`, { method: "GET" });
115
+ res = await cf.send(`${url}${HEALTH_PATH}${token === undefined ? "" : "?start=1"}`, {
116
+ method: "GET",
117
+ ...(token !== undefined && { headers: { authorization: `Bearer ${token}` } }),
118
+ });
111
119
  }
112
120
  catch (error) {
113
121
  return { error: errorMessage(error), exit: false };
114
122
  }
115
123
  const body = (await res.json().catch(() => undefined));
116
- if (res.ok && typeof body?.revision === "string")
124
+ if (res.status === 200 && typeof body?.revision === "string")
117
125
  return {
118
126
  revision: body.revision,
119
127
  ...(typeof body.configurationRevision === "string" && { configurationRevision: body.configurationRevision }),
120
128
  };
121
- const error = typeof body?.error === "string" ? body.error : `${HEALTH_PATH} answered ${res.status}`;
129
+ const error = typeof body?.error === "string"
130
+ ? body.error
131
+ : res.status === 202
132
+ ? `its container is starting${typeof body?.last === "string" ? ` (${body.last})` : ""}`
133
+ : `${HEALTH_PATH} answered ${res.status}`;
122
134
  return { error, exit: typeof body?.exit === "string" };
123
135
  };
124
136
  /** Whether the full name `name` is in the zone `zone`: the zone's apex or a name under it. */
@@ -141,16 +153,64 @@ const zonesOf = async (cf, name, hostnames) => {
141
153
  }
142
154
  return new Map(hostnames.map((hostname) => [hostname, ids.get(apexOf(hostname))]));
143
155
  };
156
+ /** The Hyperdrive config's origin from the host's database URL; a refusal that never shows the URL, which holds the password. */
157
+ export function originOf(name, databaseUrl) {
158
+ let url;
159
+ try {
160
+ url = new URL(databaseUrl);
161
+ }
162
+ catch {
163
+ // Said below, without the URL.
164
+ }
165
+ const scheme = url?.protocol.slice(0, -1);
166
+ let decoded;
167
+ try {
168
+ if (url) {
169
+ decoded = {
170
+ database: decodeURIComponent(url.pathname.slice(1)),
171
+ user: decodeURIComponent(url.username),
172
+ password: decodeURIComponent(url.password),
173
+ };
174
+ }
175
+ }
176
+ catch {
177
+ // A malformed escape: said below, without the URL.
178
+ }
179
+ if (!url ||
180
+ !decoded ||
181
+ (scheme !== "postgres" && scheme !== "postgresql") ||
182
+ !url.hostname ||
183
+ !decoded.user ||
184
+ !decoded.database) {
185
+ throw refused(`container ${name}: SANOMA_DATABASE_URL is not a postgres:// URL with a user, a host and a database, which Hyperdrive needs`, "invalid");
186
+ }
187
+ return { scheme, host: url.hostname, port: Number(url.port || 5432), ...decoded };
188
+ }
144
189
  /**
145
- * `cloudflare.host`'s and `cloudflare.container`'s operations over `api()`, made on each call: the
146
- * one Worker either declares, `type` the one whose fields a call is parsed by (the container's by
147
- * default). A host is given by apply what its data file leaves out; its port is the image's. A create and an update
148
- * are one upsert, in Wrangler's order: the image copied and pinned, then prepared, the Worker
149
- * uploaded with the declared bindings and that image (the migration on its first upload), its
150
- * workers.dev URL turned on, the application made when there is none, and then a wait until the
151
- * Worker answers at the new revision, which starts the container. A new image is a new upload:
152
- * the object destroys a container started at another and starts it again. With domains and the
153
- * instance token, the public sites the container built go on the Worker last (`serveSites`).
190
+ * `cloudflare.host`'s operations over `api()`, made on each call (`cloudflare.container`'s are
191
+ * the same, `type` the one whose fields a call is parsed by). A host is given by apply what its
192
+ * data file leaves out; its port is the image's. A create and an update are one upsert, a deploy
193
+ * at revision R in three steps, each with its own wait, so a visitor is answered throughout:
194
+ *
195
+ * 1. Build first: the app bundle at R, asked of the Worker's object, which starts its container
196
+ * when it is stopped and passes the ask on to the worker running there (`appBundle`): a
197
+ * create's container, and an update's still at R-1, builds it in a checkout of R.
198
+ * 2. One upload: the Worker at R, the bundle's modules under `app/`, its assets through an upload
199
+ * session, and every binding (the text that says what it runs, the secrets, the database as a
200
+ * Hyperdrive config, the assets, the object's namespace and its container's image). The
201
+ * visitors' next request runs it.
202
+ * 3. Restart last: the object, asked for its health with `start`, drains the worker at R-1 and
203
+ * starts the container at R (`instance.js`), and the wait ends once it answers there. An update
204
+ * whose plan is none (the upload made, the restart not seen: a deploy that died in its wait, or
205
+ * the restart of the worker that ran it) makes this step alone.
206
+ *
207
+ * Before them, as Wrangler orders it: the image copied and pinned, then prepared, the Hyperdrive
208
+ * config made or set again, and on a create the Worker uploaded without an app (`no-app.js`, the
209
+ * migration on its first upload), its workers.dev URL turned on and its application made, then
210
+ * uploaded once more so its object learns the application. A domain declared before and no longer
211
+ * is detached before the upload, and a missing one attached after it. The instance token
212
+ * (`SANOMA_WAKEUP_TOKEN`), which the bundle and the restart are asked with, is the declared one,
213
+ * else the stored one, minted when none is, and always bound.
154
214
  */
155
215
  export function containerOps(api, { startTimeoutMs, pollMs }, type = container) {
156
216
  /** The Worker `name` runs, from its bindings and its application; `checkHealth` asks it too. None for a Worker that is not Sanoma's. */
@@ -187,80 +247,83 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
187
247
  // Where the app answers now: at its hostname once attached; its health is asked on workers.dev.
188
248
  url: servedAt(hostname, url),
189
249
  worker_url: url,
190
- healthy: checkHealth && app !== undefined && "revision" in (await health(cf, url, false)),
250
+ healthy: checkHealth && app !== undefined && "revision" in (await health(cf, url)),
191
251
  };
192
252
  const secrets = bindings.filter((b) => b.type === "secret_text").map((b) => b.name);
193
- // The deploy uploaded the Worker, and its sites upload did not follow (`serveSites`).
194
- const sitesPending = text.has(TEXT.sitesPending);
195
- return { state, secrets, sitesPending };
253
+ return { state, secrets, serves: servesApp(bindings) };
196
254
  };
197
255
  const read = async ({ id }) => {
198
256
  const found = await inspect(id, true);
199
257
  return found ? { id, gone: false, state: found.state } : { id, gone: true };
200
258
  };
201
- /** The plan, and whether all it changes is the public sites' upload a deploy did not finish. */
202
- const diff = async ({ id, desired, }) => {
259
+ const plan = async ({ id, desired }) => {
203
260
  if (desired)
204
261
  checkHostname(desired.name, desired.hostname, desired.domains);
205
262
  const found = id === undefined ? undefined : await inspect(id, false);
206
263
  if (!desired)
207
- return { planned: { action: found ? "delete" : "none", ...NONE }, sitesOnly: false };
264
+ return { action: found ? "delete" : "none", ...NONE };
208
265
  const { secrets, ...declared } = desired;
209
266
  const unknown = [...type.fields.vendorOwned];
210
267
  // A create plans without the account: it needs no credentials yet.
211
268
  if (!found)
212
- return { planned: { action: "create", planned: declared, unknown, replace: [] }, sitesOnly: false };
269
+ return { action: "create", planned: declared, unknown, replace: [] };
213
270
  const planned = declared;
214
271
  const { state } = found;
215
- if (planned.name !== state.name) {
216
- return { planned: { action: "replace", planned, unknown, replace: ["name"] }, sitesOnly: false };
217
- }
272
+ if (planned.name !== state.name)
273
+ return { action: "replace", planned, unknown, replace: ["name"] };
218
274
  const changed =
219
275
  // `domains` and `hostname` left out are compared too: removing one is a change.
220
- COMPARED.some((field) => (field in planned || OPTIONAL.has(field)) && !same(field, planned[field], state[field])) || sorted(Object.keys(secrets ?? {})) !== sorted(found.secrets);
221
- if (!changed && !found.sitesPending)
222
- return { planned: { action: "none", planned: state, ...NONE }, sitesOnly: false };
276
+ COMPARED.some((field) => (field in planned || OPTIONAL.has(field)) && !same(field, planned[field], state[field])) ||
277
+ // The instance token is bound whether declared or not.
278
+ sorted(new Set([...Object.keys(secrets ?? {}), WAKEUP_TOKEN])) !== sorted(found.secrets) ||
279
+ // A create that ended before its upload of the app.
280
+ !found.serves;
281
+ if (!changed)
282
+ return { action: "none", planned: state, ...NONE };
223
283
  // What the Worker will serve: the declared domains and hostname, none of the found ones left
224
284
  // out; the app at the declared hostname, or on workers.dev without one.
225
285
  const { domains: _domains, hostname: _hostname, ...was } = state;
226
286
  const url = servedAt(planned.hostname, state.worker_url);
227
287
  return {
288
+ action: "update",
228
289
  planned: {
229
- action: "update",
230
- planned: {
231
- ...was,
232
- ...planned,
233
- ...(state.configurationRevision !== undefined &&
234
- planned.configurationRevision == null && { configurationRevision: null }),
235
- url,
236
- },
237
- ...NONE,
290
+ ...was,
291
+ ...planned,
292
+ ...(state.configurationRevision !== undefined &&
293
+ planned.configurationRevision == null && { configurationRevision: null }),
294
+ url,
238
295
  },
239
- // The Worker otherwise runs what is declared, its application made.
240
- sitesOnly: !changed && state.id !== null,
296
+ ...NONE,
241
297
  };
242
298
  };
243
- const plan = async (input) => (await diff(input)).planned;
244
- /** Waits until the Worker at `url` answers at `revision`, which starts its container. */
245
- const started = async (cf, script, url, revision, configurationRevision) => {
246
- const until = Date.now() + startTimeoutMs;
247
- for (;;) {
248
- const seen = await health(cf, url, true);
299
+ /** `desired` parsed, its secrets resolved, and the instance token: the declared one, else the stored one, minted when none is. */
300
+ const resolved = async (desired, store) => {
301
+ const declared = type.schema.parse(await resolveSecrets(desired, store));
302
+ const token = declared.secrets?.[WAKEUP_TOKEN] ?? (await store.ensure(WAKEUP_TOKEN, mintInstanceToken));
303
+ return { declared, token };
304
+ };
305
+ /**
306
+ * Step 3: has the object restart the container at `revision` (its health with `start`, the worker
307
+ * at another drained first) and waits until it answers there. Asking again is harmless: a
308
+ * container that runs at it is kept.
309
+ */
310
+ const restarted = async (cf, { script, url, token }, revision, configurationRevision) => {
311
+ // Retryable once its time is up: the upload is made, so the next apply plans none, and waits for this step alone.
312
+ await waitFor(`${script}: still starting at ${revision}`, { startTimeoutMs, pollMs }, async () => {
313
+ const seen = await health(cf, url, token);
249
314
  if ("revision" in seen &&
250
315
  seen.revision === revision &&
251
316
  (seen.configurationRevision ?? "") === (configurationRevision ?? ""))
252
- return;
317
+ return { value: undefined };
253
318
  // Not a wait: the container started and ended, and asking again would only start it again.
254
319
  if ("exit" in seen && seen.exit)
255
320
  throw refused(`${script} did not start at ${revision}: ${seen.error}`);
256
- if (Date.now() >= until) {
257
- const why = "revision" in seen
321
+ return {
322
+ last: "revision" in seen
258
323
  ? `it runs ${seen.revision} with configuration ${seen.configurationRevision ?? "unset"}`
259
- : seen.error;
260
- throw refused(`${script} did not answer at ${revision} in ${Math.round(startTimeoutMs / 1000)} s: ${why}`);
261
- }
262
- await delay(pollMs);
263
- }
324
+ : seen.error,
325
+ };
326
+ });
264
327
  };
265
328
  /** Waits until the account prepared `image` to run, as Wrangler does before an upload. */
266
329
  const prepared = async (cf, image) => {
@@ -287,18 +350,14 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
287
350
  await delay(pollMs);
288
351
  }
289
352
  };
290
- /**
291
- * Makes the Worker and its application match `desired`, and waits for it: a create and an
292
- * update alike. With `sitesOnly` (the Worker runs `desired` already, but its sites upload did
293
- * not follow), the public sites' upload alone.
294
- */
295
- const put = async (desired, { secrets: store }, sitesOnly = false) => {
296
- const declared = type.schema.parse(await resolveSecrets(desired, store));
353
+ /** Makes the Worker and its application match `desired` at its revision, in the three steps of `containerOps`: a create and an update alike. */
354
+ const put = async (desired, { secrets: store }) => {
355
+ const { declared, token } = await resolved(desired, store);
297
356
  const { name, image, source, ref, revision, configurationRevision, domains } = declared;
298
357
  const port = declared.port ?? RUNTIME_PORT;
299
358
  const hostname = declared.hostname ?? undefined;
300
359
  checkHostname(name, hostname, domains);
301
- const secrets = declared.secrets ?? {};
360
+ const secrets = { ...declared.secrets, [WAKEUP_TOKEN]: token };
302
361
  if (!name || !image || !source || !ref) {
303
362
  const missing = !name ? "name" : !image ? "image" : !source ? "source" : "ref";
304
363
  throw refused(`container ${name ?? ""}: no ${missing}: apply gives a host one`);
@@ -309,6 +368,10 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
309
368
  const taken = Object.keys(secrets).filter((key) => RESERVED.has(key));
310
369
  if (taken.length)
311
370
  throw refused(`container ${name}: ${taken.join(", ")} is the Worker's own; name the secret otherwise`);
371
+ if (!secrets.SANOMA_DATABASE_URL) {
372
+ throw refused(`container ${name}: no SANOMA_DATABASE_URL among its secrets: the app on the Worker reaches the database through Hyperdrive over it`);
373
+ }
374
+ const origin = originOf(name, secrets.SANOMA_DATABASE_URL);
312
375
  const cf = api();
313
376
  const script = scriptOf(name);
314
377
  const url = await cf.workerUrl(script);
@@ -329,8 +392,12 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
329
392
  const missing = await zonesOf(cf, name, [...hostnames].filter((h) => !held.has(h)));
330
393
  const runs = accountPinned(image, cf.account) ? image : await cf.copyImage(image);
331
394
  await prepared(cf, runs);
395
+ // The Hyperdrive config the Worker binds as its database, named after it: made, or set to the URL again.
396
+ const config = await cf.hyperdrive(script);
397
+ if (config)
398
+ await cf.updateHyperdrive(config.id, script, origin);
399
+ const database = config?.id ?? (await cf.createHyperdrive(script, origin)).id;
332
400
  const served = domains ? domainsText(domains) : undefined;
333
- const token = secrets[WAKEUP_TOKEN];
334
401
  const text = {
335
402
  [TEXT.source]: source,
336
403
  [TEXT.ref]: ref,
@@ -342,63 +409,81 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
342
409
  [TEXT.url]: url,
343
410
  ...(served && { [TEXT.domains]: served }),
344
411
  ...(hostname && { [TEXT.hostname]: hostname }),
345
- // Until `serveSites` uploads the Worker again, without it.
346
- ...(domains && token && { [TEXT.sitesPending]: "1" }),
347
412
  };
348
413
  const bindings = [
349
414
  { type: "durable_object_namespace", name: NAMESPACE, class_name: OBJECT_CLASS },
350
415
  ...Object.entries(text).map(([key, value]) => ({ type: "plain_text", name: key, text: value })),
351
- ...Object.entries(secrets).map(([key, value]) => ({
352
- type: "secret_text",
353
- name: key,
354
- text: value,
355
- })),
416
+ ...Object.entries(secrets).map(([key, value]) => ({ type: "secret_text", name: key, text: value })),
356
417
  ];
357
- const metadata = {
418
+ const { entry, noApp } = await workerModules();
419
+ const worker = {
358
420
  main_module: MAIN_MODULE,
359
- compatibility_date: COMPATIBILITY_DATE,
360
- bindings,
361
421
  containers: [{ name: script, class_name: OBJECT_CLASS, images: { [IMAGE]: runs } }],
362
422
  observability: { enabled: true },
363
- modules: await workerModules(),
364
423
  };
365
- let app = sitesOnly ? await cf.application(script) : undefined;
366
- if (sitesOnly) {
367
- if (!app)
368
- throw refused(`${script}: its container application is gone`);
424
+ /** The Worker without an app: what a create runs until its first bundle is built. */
425
+ const bare = {
426
+ ...worker,
427
+ bindings,
428
+ compatibility_date: COMPATIBILITY_DATE,
429
+ compatibility_flags: [],
430
+ modules: [...entry, noApp],
431
+ };
432
+ if (!current)
433
+ await cf.upload(script, { ...bare, migrations: MIGRATION });
434
+ await cf.enableSubdomain(script);
435
+ let app = await cf.application(script);
436
+ /** Whether the Worker runs at R already, without its app: uploaded so in this put. */
437
+ let bareAtRevision = !current;
438
+ if (!app) {
439
+ // The application is made for the object's namespace, which the first upload made.
440
+ const namespace = (await cf.bindings(script))?.find((b) => b.name === NAMESPACE)?.namespace_id;
441
+ if (!namespace)
442
+ throw refused(`${script}: Cloudflare names no namespace for its ${NAMESPACE} binding`);
443
+ app = await cf.createApplication(script, namespace);
444
+ // The object learns which application backs its namespace from a deployment made after
445
+ // the application exists: without this upload every start answers "There is no container
446
+ // application assigned to this Durable Object namespace", for good (found live 2026-10-29;
447
+ // a Worker deployed again after the application started at once).
448
+ await cf.upload(script, bare);
449
+ bareAtRevision = true;
369
450
  }
370
- else {
371
- // A domain declared before and no longer is detached while the bindings still record it, and
372
- // a missing one attached once the upload records it: whatever fails in between, none is left
373
- // attached and unrecorded, and the next apply attaches what is still missing. A site's domain
374
- // so never reaches a Worker that would serve the app there (README, Custom domains).
375
- for (const domain of attached) {
376
- if (before.has(domain.hostname) && !hostnames.has(domain.hostname))
377
- await cf.detachDomain(domain.id);
378
- }
379
- await cf.upload(script, { ...metadata, ...(current === undefined && { migrations: MIGRATION }) });
380
- await cf.enableSubdomain(script);
381
- app = await cf.application(script);
382
- if (!app) {
383
- // The application is made for the object's namespace, which the first upload made.
384
- const namespace = (await cf.bindings(script))?.find((b) => b.name === NAMESPACE)?.namespace_id;
385
- if (!namespace)
386
- throw refused(`${script}: Cloudflare names no namespace for its ${NAMESPACE} binding`);
387
- app = await cf.createApplication(script, namespace);
388
- // The object learns which application backs its namespace from a deployment made after
389
- // the application exists: without this second upload every start answers "There is no
390
- // container application assigned to this Durable Object namespace", for good (found live
391
- // 2026-10-29; a Worker deployed again after the application started at once).
392
- await cf.upload(script, metadata);
393
- }
394
- for (const [domain, zone] of missing)
395
- await cf.attachDomain(script, domain, zone);
396
- await started(cf, script, url, revision, configurationRevision);
451
+ const at = { script, url, token };
452
+ // 1. Build first.
453
+ let bundle = await appBundle(cf, { ...at, revision, earlier: !bareAtRevision }, { startTimeoutMs, pollMs });
454
+ if ("stale" in bundle && !bareAtRevision) {
455
+ // The deployment cannot build it (another token, a runtime without the route, a revision
456
+ // whose worker ends): the Worker at R without its app starts R's container, which can; its
457
+ // pages say the app is being set up until step 2.
458
+ await cf.upload(script, bare);
459
+ bundle = await appBundle(cf, { ...at, revision, earlier: false }, { startTimeoutMs, pollMs });
460
+ }
461
+ if ("stale" in bundle)
462
+ throw refused(`${script}: no app bundle at ${revision}: ${bundle.stale}`);
463
+ const jwt = bundle.assets.length ? await uploadAssets(cf, script, bundle.assets) : undefined;
464
+ // 2. One upload. A domain declared before and no longer is detached while the bindings still
465
+ // record it, and a missing one attached once the upload records it: whatever fails in between,
466
+ // none is left attached and unrecorded, and the next apply attaches what is still missing.
467
+ for (const domain of attached) {
468
+ if (before.has(domain.hostname) && !hostnames.has(domain.hostname))
469
+ await cf.detachDomain(domain.id);
397
470
  }
398
- // The public sites on the Worker, once the container that builds them runs (`sites.ts`): the
399
- // same bindings, so every domain attached is one they record.
400
- if (domains && token)
401
- await serveSites(cf, { script, url, token, metadata, startTimeoutMs, pollMs });
471
+ await cf.upload(script, {
472
+ ...worker,
473
+ bindings: [
474
+ ...bindings,
475
+ { type: "hyperdrive", name: DATABASE, id: database },
476
+ ...(jwt ? [{ type: "assets", name: ASSETS }] : []),
477
+ ],
478
+ compatibility_date: bundle.compatibilityDate,
479
+ compatibility_flags: bundle.compatibilityFlags,
480
+ ...(jwt && { assets: { jwt, config: ASSETS_CONFIG } }),
481
+ modules: [...entry, ...appModules(script, bundle)],
482
+ });
483
+ for (const [domain, zone] of missing)
484
+ await cf.attachDomain(script, domain, zone);
485
+ // 3. Restart last.
486
+ await restarted(cf, at, revision, configurationRevision);
402
487
  const state = {
403
488
  id: app.id,
404
489
  name,
@@ -422,14 +507,24 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
422
507
  plan,
423
508
  create: ({ desired }, call) => put(desired, call),
424
509
  update: async ({ id, desired, expect, }, call) => {
425
- const { planned: fresh, sitesOnly } = await diff({ id, desired });
426
- replanned(`${type.type}.update`, fresh, expect);
427
- if (fresh.action === "none") {
428
- const found = await read({ id });
429
- if (found.state)
430
- return { id, state: found.state };
510
+ const fresh = await plan({ id, desired });
511
+ if (fresh.action !== "none") {
512
+ replanned(`${type.type}.update`, fresh, expect);
513
+ return put(desired, call);
431
514
  }
432
- return put(desired, call, sitesOnly);
515
+ // The Worker runs what is declared: the restart alone, which confirms it (matching bindings
516
+ // also describe a deploy whose restart did not finish). Read and plan remain passive.
517
+ // The token resolved alone: a restart reads no other secret, and uploads none.
518
+ const state = fresh.planned;
519
+ if (!state.revision)
520
+ throw refused(`container ${id}: no revision to confirm`);
521
+ const declaredToken = desired.secrets?.[WAKEUP_TOKEN];
522
+ const token = declaredToken === undefined
523
+ ? await call.secrets.ensure(WAKEUP_TOKEN, mintInstanceToken)
524
+ : String(await resolveSecrets(declaredToken, call.secrets));
525
+ const at = { script: scriptOf(id), url: state.worker_url, token };
526
+ await restarted(api(), at, state.revision, state.configurationRevision);
527
+ return { id, state: { ...state, healthy: true } };
433
528
  },
434
529
  delete: async ({ id }) => {
435
530
  const cf = api();
@@ -448,6 +543,10 @@ export function containerOps(api, { startTimeoutMs, pollMs }, type = container)
448
543
  if (app)
449
544
  await cf.deleteApplication(app.id);
450
545
  await cf.deleteScript(script);
546
+ // After the Worker, which binds it.
547
+ const database = await cf.hyperdrive(script);
548
+ if (database)
549
+ await cf.deleteHyperdrive(database.id);
451
550
  return { id, gone: true };
452
551
  },
453
552
  };