@astrale-os/adapter-cloudflare 0.5.0-beta.177 → 0.5.0-beta.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,11 +75,14 @@ Every line is readable. Namespace mode is a thin layer over the hosting kit belo
75
75
  else. Its tags name its artifact (build digest), line and key; never `astrale-service`, which is
76
76
  Services'.
77
77
  - **Activation.** The deploy writes `record:<label>`, whose presence makes the dispatcher route the
78
- label; it never holds a provider version or a secret digest. Readiness then waits, bounded,
79
- until the URL serves the sealed release from the provider version the upload created.
78
+ label: the canonical `DeploymentRecordV1`, published through the kit's opaque record with its
79
+ media type, which the dispatcher serves at `/.well-known/astrale/deployment.json`. It never holds
80
+ a provider version or a secret digest. Readiness then waits, bounded, until the URL serves the
81
+ sealed release from the provider version the upload created.
80
82
  - **Reuse.** A redeploy of the same release finds the script under its label. It is reused only
81
- when its artifact and line tags and its record name this build and configuration (or, when an
82
- interrupted deploy left it unactivated, when it binds exactly this configuration). Then only that
83
+ when its artifact and line tags and its record name this build and configuration (only the
84
+ record's commit may differ: the deployment keeps the record it was first activated with), or,
85
+ when an interrupted deploy left it unactivated, when it binds exactly this configuration. Then only that
83
86
  script's declared secrets are rewritten in place, and readiness waits for the version the
84
87
  rewrite minted. Anything else is a `deployment-conflict`, refused with nothing written; no other
85
88
  deployment is ever touched. A `summary:<label>` an older adapter wrote is ignored.
@@ -112,21 +115,29 @@ Node and inside a Worker (`fetch` and WebCrypto only).
112
115
  - **Client.** `CloudflareClient`: bounded bodies, `Retry-After`/429 waits by policy
113
116
  (`DEFAULT_RATE_LIMIT`, `OPERATOR_RATE_LIMIT` or none), timeouts and a deadline, typed
114
117
  `CloudflareApiError`s (`kind`, `status`, `codes`), cursor and numbered pagination, multipart
115
- forms and raw KV text.
116
- - **Routing KV.** `RoutingKv` (by title or id) and the contract: `record:<label>` (canonical
117
- `DeploymentRecordV1`, written once; re-publication only for the same release, `sameRelease`),
118
- `retirement:<label>` (`withdrawn`), `current:<name>` (one of the name's labels), `cron:<name>`
119
- (canonical UTC crons). Every write is read back before it counts.
118
+ forms, raw KV text and bytes.
119
+ - **Routing KV.** `RoutingKv` (by title or id) and the contract: `record:<label>` (the deployment's
120
+ record, opaque bytes with their media type in the value's metadata, `{"contentType":...}`, or
121
+ `-` with `{"contentType":null}` for a deployment that publishes none; written once and kept only
122
+ for the same bytes and media type), `retirement:<label>` (`withdrawn`), `current:<name>` (one of
123
+ the name's labels), `cron:<name>` (canonical UTC crons). A record written before records were
124
+ opaque has no metadata: it is a canonical `DeploymentRecordV1`, read and served as one. Every
125
+ write is read back before it counts.
120
126
  - **Scripts.** `deploymentBindings`, `deploymentTags` (with a host's extra tags),
121
127
  `uploadDeploymentScript` (create-only, every module kind, assets in parallel),
122
128
  `observeScript`, `scriptSettings`, `scriptSecretNames`, `putScriptSecret`, `deleteScript`, and
123
129
  `createSigningIdentity` (WebCrypto Ed25519).
124
- - **Lifecycle.** `activateDeployment`, `promoteDeployment`, `replaceSchedules`, two-phase retirement
130
+ - **Lifecycle.** `activateDeployment` (a label and its record, `WorkerRecord` of
131
+ `@astrale-os/sdk/deployment/worker`, or `null`), `readRecord`, `promoteDeployment`,
132
+ `replaceSchedules`, two-phase retirement
125
133
  (`markRetired`, then `completeRetirement` once `retirementVisible` sees the dispatcher answer
126
134
  `DEPLOYMENT_RETIRED`), `waitForReadiness` through the public URL or a dispatch binding, and
127
135
  `listLineDeployments`.
128
136
  - **Dispatcher.** Plain ES modules shipped with the package (`dispatcherModules()`), routing a label
129
- to its script, a name to `current:<name>`, and firing `cron:<name>` on schedule. A host prepends
137
+ to its script, a name to `current:<name>`, and firing `cron:<name>` on schedule. It serves a
138
+ label's record at `/.well-known/astrale/deployment.json`, its bytes and media type as published
139
+ (immutable, `405` but for `GET` and `HEAD`), and hands that path to a deployment that publishes
140
+ none. A host prepends
130
141
  its own routing and schedule targets with `createDispatcher(extension)` in its own `index.mjs`,
131
142
  without forking it. The public never reaches `/__scheduled`; nothing in the generated Worker
132
143
  serves it yet.
@@ -232,18 +243,11 @@ until the adapter versions are aligned.
232
243
  Services receives the placed modules as they are, so there the digest names the bytes the
233
244
  deployment runs. Direct mode runs `wrangler deploy`, which bundles the placed modules again: there
234
245
  the digest names the portable Worker the deployment was made from, not the bytes Cloudflare runs.
235
- A host that receives a Worker upload recomputes the digest from the exact upload with the same
236
- function:
237
-
238
- ```ts
239
- import { workerBuildManifest } from '@astrale-os/adapter-cloudflare/release'
240
- import { buildDigest } from '@astrale-os/sdk/deployment/build/manifest'
241
-
242
- const digest = buildDigest(workerBuildManifest({ origin, revision, bundle, entry, modules, assets }))
243
- ```
244
-
245
- Modules keep their uploaded name and an asset `/<path>` is the file `assets/<path>`. The bundler
246
- version names this layout, not a package release. Both subpaths are runtime-safe.
246
+ A host that receives a Worker upload recomputes the digest from the exact upload with
247
+ `workerBuildDigest` (`@astrale-os/sdk/deployment/worker`), given the members of the Bundle's build
248
+ manifest its deployer declares (`format`, `version`, `origin`, `schema`, `bundler`): modules keep
249
+ their uploaded name and an asset `/<path>` is the file `assets/<path>`, so the digest is the
250
+ Bundle's own. The bundler version names this layout, not a package release.
247
251
 
248
252
  ## Material for a custom Worker entrypoint
249
253
 
@@ -4,6 +4,7 @@ export { loadWorkerArtifact } from './worker/index.js';
4
4
  export type { WorkerArtifact } from './worker/index.js';
5
5
  export { bundleUpload, uploadBuildDigest, uploadBuildEvidence, workerUpload, type BundleUpload, } from './worker/upload.js';
6
6
  export { isAdapterVar, resolveRouter, workerSettings } from './configuration.js';
7
+ export { encodeWorkerSettings, SETTINGS_BINDING } from '../worker/settings.js';
7
8
  export { GENERATED_WORKER_RUNTIME } from './codegen/wrangler.js';
8
9
  export { frontendFiles } from './frontend/files.js';
9
10
  export { verifyWorkerFrontend } from './frontend/readiness.js';
@@ -3,6 +3,7 @@ export { cloudflareWorkerBundler } from './bundler.js';
3
3
  export { loadWorkerArtifact } from './worker/index.js';
4
4
  export { bundleUpload, uploadBuildDigest, uploadBuildEvidence, workerUpload, } from './worker/upload.js';
5
5
  export { isAdapterVar, resolveRouter, workerSettings } from './configuration.js';
6
+ export { encodeWorkerSettings, SETTINGS_BINDING } from '../worker/settings.js';
6
7
  export { GENERATED_WORKER_RUNTIME } from './codegen/wrangler.js';
7
8
  export { frontendFiles } from './frontend/files.js';
8
9
  export { verifyWorkerFrontend } from './frontend/readiness.js';
@@ -1,15 +1,22 @@
1
- import type { DeploymentRecordV1 } from '@astrale-os/sdk/deployment/address';
1
+ import type { WorkerRecord } from '@astrale-os/sdk/deployment/worker';
2
2
  import type { RoutingKv } from './kv.js';
3
3
  /**
4
- * Activate one deployment: publish its record at `record:<label>`, which the dispatcher reads to
5
- * route the label and serves at `/.well-known/astrale/deployment.json`. A record never changes once
6
- * published: a record already there is kept and answered when it declares the same release
7
- * (`sameRelease`, so only its commit may differ), and refused (`deployment-conflict`) otherwise,
8
- * without any write.
4
+ * Activate one deployment: write `record:<label>`, which the dispatcher reads to route the label,
5
+ * with the record its deployer publishes (opaque bytes and their media type, served verbatim at
6
+ * `/.well-known/astrale/deployment.json`), or `null` for a deployment that publishes none. A
7
+ * record never changes once published: one already there is kept and answered when it is the same
8
+ * record (same media type, same bytes, or both none), and refused (`deployment-conflict`)
9
+ * otherwise, without any write.
9
10
  */
10
- export declare function activateDeployment(kv: RoutingKv, record: DeploymentRecordV1): Promise<DeploymentRecordV1>;
11
- /** The record a label was activated with, admitted, or undefined when it is not activated. */
12
- export declare function readRecord(kv: RoutingKv, label: string): Promise<DeploymentRecordV1 | undefined>;
11
+ export declare function activateDeployment(kv: RoutingKv, label: string, record: WorkerRecord | null): Promise<WorkerRecord | null>;
12
+ /**
13
+ * The record a label was activated with: the record, `null` when the deployment publishes none,
14
+ * or undefined when the label is not activated. A record written before records were opaque,
15
+ * without metadata, is the `DeploymentRecordV1` it holds, with that media type.
16
+ */
17
+ export declare function readRecord(kv: RoutingKv, label: string): Promise<WorkerRecord | null | undefined>;
18
+ /** Whether two activations publish the same record (or both none). */
19
+ export declare function sameRecord(left: WorkerRecord | null, right: WorkerRecord | null): boolean;
13
20
  /**
14
21
  * Point a name (a deployment line) at the deployment its URL `https://<name>.<routing domain>`
15
22
  * serves: `current:<name>` = `label`, or no deployment when `label` is null (the key is deleted).
@@ -1,29 +1,66 @@
1
- import { cronKey, currentKey, decodeCurrent, decodeRecord, decodeSchedules, encodeRecord, encodeSchedules, isLabelOf, recordKey, sameRelease, } from './kv.js';
1
+ import { DEPLOYMENT_RECORD_MEDIA_TYPE, parseDeploymentLabel, } from '@astrale-os/sdk/deployment/address';
2
+ import { acceptWorkerRecord, sameWorkerRecord } from '@astrale-os/sdk/deployment/worker';
3
+ import { cronKey, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, isLabelOf, recordKey, } from './kv.js';
2
4
  import { HostingRefusal } from './refusal.js';
5
+ /** What `record:<label>` holds for a deployment that publishes no record: never served. */
6
+ const NO_RECORD = new TextEncoder().encode('-');
3
7
  /**
4
- * Activate one deployment: publish its record at `record:<label>`, which the dispatcher reads to
5
- * route the label and serves at `/.well-known/astrale/deployment.json`. A record never changes once
6
- * published: a record already there is kept and answered when it declares the same release
7
- * (`sameRelease`, so only its commit may differ), and refused (`deployment-conflict`) otherwise,
8
- * without any write.
8
+ * Activate one deployment: write `record:<label>`, which the dispatcher reads to route the label,
9
+ * with the record its deployer publishes (opaque bytes and their media type, served verbatim at
10
+ * `/.well-known/astrale/deployment.json`), or `null` for a deployment that publishes none. A
11
+ * record never changes once published: one already there is kept and answered when it is the same
12
+ * record (same media type, same bytes, or both none), and refused (`deployment-conflict`)
13
+ * otherwise, without any write.
9
14
  */
10
- export async function activateDeployment(kv, record) {
11
- const label = record.providerScript;
12
- const text = encodeRecord(record);
15
+ export async function activateDeployment(kv, label, record) {
16
+ parseDeploymentLabel(label);
17
+ const published = record === null ? null : acceptWorkerRecord(record);
13
18
  const stored = await readRecord(kv, label);
14
19
  if (stored !== undefined) {
15
- if (!sameRelease(stored, record)) {
20
+ if (!sameRecord(stored, published)) {
16
21
  throw new HostingRefusal('deployment-conflict', `Another record is already published for ${label}; nothing was written.`);
17
22
  }
18
23
  return stored;
19
24
  }
20
- await kv.put(recordKey(label), text, 'application/json');
21
- return record;
25
+ await kv.putWithMetadata(recordKey(label), published?.bytes ?? NO_RECORD, {
26
+ contentType: published?.contentType ?? null,
27
+ });
28
+ return published;
22
29
  }
23
- /** The record a label was activated with, admitted, or undefined when it is not activated. */
30
+ /**
31
+ * The record a label was activated with: the record, `null` when the deployment publishes none,
32
+ * or undefined when the label is not activated. A record written before records were opaque,
33
+ * without metadata, is the `DeploymentRecordV1` it holds, with that media type.
34
+ */
24
35
  export async function readRecord(kv, label) {
25
- const text = await kv.get(recordKey(label));
26
- return text === undefined ? undefined : decodeRecord(text, label);
36
+ const stored = await kv.getWithMetadata(recordKey(label));
37
+ return stored === undefined ? undefined : storedRecord(stored, label);
38
+ }
39
+ /** Admit what `record:<label>` holds, value and metadata, as the dispatcher serves it. */
40
+ function storedRecord(stored, label) {
41
+ const { metadata } = stored;
42
+ let contentType = DEPLOYMENT_RECORD_MEDIA_TYPE;
43
+ if (metadata !== null) {
44
+ if (typeof metadata !== 'object' ||
45
+ Array.isArray(metadata) ||
46
+ Reflect.ownKeys(metadata).length !== 1 ||
47
+ !Object.hasOwn(metadata, 'contentType')) {
48
+ throw new HostingRefusal('record-invalid', `The record of ${label} has invalid metadata.`);
49
+ }
50
+ contentType = metadata.contentType;
51
+ if (contentType === null)
52
+ return null;
53
+ }
54
+ try {
55
+ return acceptWorkerRecord({ contentType: contentType, bytes: stored.bytes });
56
+ }
57
+ catch (cause) {
58
+ throw new HostingRefusal('record-invalid', `The record of ${label} is not a record: ${String(cause)}`);
59
+ }
60
+ }
61
+ /** Whether two activations publish the same record (or both none). */
62
+ export function sameRecord(left, right) {
63
+ return left === null || right === null ? left === right : sameWorkerRecord(left, right);
27
64
  }
28
65
  /**
29
66
  * Point a name (a deployment line) at the deployment its URL `https://<name>.<routing domain>`
@@ -126,6 +126,11 @@ export declare class CloudflareClient {
126
126
  readonly text: string;
127
127
  readonly contentType: string;
128
128
  }): Promise<string | undefined>;
129
+ /**
130
+ * A raw GET of bytes, for a KV value whose bytes are its meaning (a deployment record): the
131
+ * response body, or undefined when it answers 404. Bodies are bounded to 1 MiB.
132
+ */
133
+ bytes(path: string): Promise<Uint8Array | undefined>;
129
134
  private send;
130
135
  private request;
131
136
  /** The request timeout, bounded by the deadline and the caller's cancellation. */
@@ -204,6 +204,30 @@ export class CloudflareClient {
204
204
  throw failure(method, path, response.status, decodeEnvelope(text));
205
205
  return text;
206
206
  }
207
+ /**
208
+ * A raw GET of bytes, for a KV value whose bytes are its meaning (a deployment record): the
209
+ * response body, or undefined when it answers 404. Bodies are bounded to 1 MiB.
210
+ */
211
+ async bytes(path) {
212
+ const response = await this.request('GET', path, {});
213
+ if (response.status === 404) {
214
+ await discard(response);
215
+ return undefined;
216
+ }
217
+ let bytes;
218
+ try {
219
+ bytes = await boundedBytes(response, MAXIMUM_TEXT_RESPONSE_BYTES);
220
+ }
221
+ catch (cause) {
222
+ if (cause instanceof CloudflareApiError)
223
+ throw cause;
224
+ this.unavailable('GET', path, cause);
225
+ }
226
+ if (!response.ok) {
227
+ throw failure('GET', path, response.status, decodeEnvelope(new TextDecoder().decode(bytes)));
228
+ }
229
+ return bytes;
230
+ }
207
231
  async send(method, path, input, allowEmptySuccess = false) {
208
232
  const response = await this.request(method, path, input);
209
233
  const text = await this.read(response, MAXIMUM_JSON_RESPONSE_BYTES, method, path);
@@ -389,6 +413,9 @@ function describe(path) {
389
413
  return 'request';
390
414
  }
391
415
  async function boundedText(response, maximumBytes) {
416
+ return new TextDecoder().decode(await boundedBytes(response, maximumBytes));
417
+ }
418
+ async function boundedBytes(response, maximumBytes) {
392
419
  const declared = response.headers.get('content-length');
393
420
  if (declared !== null) {
394
421
  const bytes = Number(declared);
@@ -398,11 +425,10 @@ async function boundedText(response, maximumBytes) {
398
425
  }
399
426
  }
400
427
  if (response.body === null)
401
- return '';
428
+ return new Uint8Array();
402
429
  const reader = response.body.getReader();
403
- const decoder = new TextDecoder();
430
+ const parts = [];
404
431
  let size = 0;
405
- let text = '';
406
432
  for (;;) {
407
433
  const part = await reader.read();
408
434
  if (part.done)
@@ -417,9 +443,15 @@ async function boundedText(response, maximumBytes) {
417
443
  }
418
444
  throw new CloudflareApiError('upstream', 'Cloudflare response body is too large.');
419
445
  }
420
- text += decoder.decode(part.value, { stream: true });
446
+ parts.push(part.value);
447
+ }
448
+ const bytes = new Uint8Array(size);
449
+ let offset = 0;
450
+ for (const part of parts) {
451
+ bytes.set(part, offset);
452
+ offset += part.byteLength;
421
453
  }
422
- return text + decoder.decode();
454
+ return bytes;
423
455
  }
424
456
  function allowedHost(base) {
425
457
  try {
@@ -3,6 +3,7 @@ import { scriptMissing } from './failure.mjs'
3
3
  // @ts-check
4
4
 
5
5
  /** @typedef {import('./env.mjs').DispatcherEnvironment} DispatcherEnvironment */
6
+ /** @typedef {{ body?: ArrayBuffer, contentType?: string }} PublishedRecord */
6
7
 
7
8
  /**
8
9
  * Every deployment label, as `@astrale-os/sdk/deployment/address` declares `DEPLOYMENT_LABEL`: an
@@ -13,8 +14,12 @@ const DEPLOYMENT_LABEL = /^(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?[a-z2-7]{16}-[a-z2-7]{1
13
14
  /** The longest label a deploy gives: a line of 40 characters, a dash and the content. */
14
15
  const MAXIMUM_LABEL_LENGTH = 57
15
16
 
16
- /** Reserved: the dispatcher answers it from the record, and the deployment never sees it. */
17
+ /**
18
+ * Where the dispatcher serves a deployment's record, which the deployment then never sees. A
19
+ * deployment that publishes no record answers the path itself.
20
+ */
17
21
  const DEPLOYMENT_RECORD_PATH = '/.well-known/astrale/deployment.json'
22
+ /** The media type of a record written before records were opaque: a `DeploymentRecordV1`. */
18
23
  const DEPLOYMENT_RECORD_TYPE = 'application/vnd.astrale.deployment-record+json;v=1'
19
24
  const ENVELOPE_TYPE = 'application/vnd.astrale+json'
20
25
  /** The retirement probe (`retirementVisible`); it is never a call of the deployment. */
@@ -31,9 +36,10 @@ export function isDeploymentLabel(label) {
31
36
  * (`./services.mjs`). A deployer writes `record:<label>` to activate the label, and its owner
32
37
  * `retirement:<label>` to retire it; the record outlives the retirement. Every call of a retired
33
38
  * deployment answers `DEPLOYMENT_RETIRED`, whatever its path. The record endpoint is not a call:
34
- * the dispatcher answers it itself, and the record it serves immutable stays readable after
35
- * retirement. A deployment without its record is not active yet (KV is eventually consistent, so
36
- * a caller retries), and only an active deployment runs, as one call point when `CALLS` is bound.
39
+ * the dispatcher answers it itself with the record the deployer published, its bytes and its media
40
+ * type as written, and the record it serves immutable stays readable after retirement. A
41
+ * deployment without its record is not active yet (KV is eventually consistent, so a caller
42
+ * retries), and only an active deployment runs, as one call point when `CALLS` is bound.
37
43
  *
38
44
  * @param {Request} request
39
45
  * @param {DispatcherEnvironment} environment
@@ -44,11 +50,14 @@ export async function dispatchDeployment(request, environment, label) {
44
50
  try {
45
51
  const [retirement, record] = await Promise.all([
46
52
  environment.SCHEDULES.get(`retirement:${label}`),
47
- environment.SCHEDULES.get(`record:${label}`),
53
+ // Only the record endpoint reads the record's bytes; a call only needs its presence.
54
+ url.pathname === DEPLOYMENT_RECORD_PATH
55
+ ? publishedRecord(environment, label)
56
+ : environment.SCHEDULES.get(`record:${label}`).then((value) =>
57
+ value === null ? null : /** @type {PublishedRecord} */ ({}),
58
+ ),
48
59
  ])
49
- if (url.pathname === DEPLOYMENT_RECORD_PATH && record !== null) {
50
- return deploymentRecordResponse(request, record)
51
- }
60
+ if (record?.contentType !== undefined) return deploymentRecordResponse(request, record)
52
61
  if (retirement !== null) return deploymentRetiredResponse(url.origin)
53
62
  if (record === null) return inactiveDeploymentResponse(url.origin)
54
63
  if (url.pathname !== RETIREMENT_PROBE_PATH) {
@@ -61,12 +70,34 @@ export async function dispatchDeployment(request, environment, label) {
61
70
  }
62
71
  }
63
72
 
73
+ /**
74
+ * What `record:<label>` holds, as the record endpoint serves it: `null` when the label is not
75
+ * activated, the record's bytes and media type, or no `contentType` when the deployment publishes
76
+ * no record. A value written without metadata, before records were opaque, is a
77
+ * `DeploymentRecordV1`.
78
+ *
79
+ * @param {DispatcherEnvironment} environment
80
+ * @param {string} label
81
+ * @returns {Promise<PublishedRecord | null>}
82
+ */
83
+ async function publishedRecord(environment, label) {
84
+ const stored = await environment.SCHEDULES.getWithMetadata(`record:${label}`, 'arrayBuffer')
85
+ if (stored.value === null) return null
86
+ if (stored.metadata === null || stored.metadata === undefined) {
87
+ return { body: stored.value, contentType: DEPLOYMENT_RECORD_TYPE }
88
+ }
89
+ const contentType = /** @type {{ contentType?: unknown }} */ (stored.metadata).contentType
90
+ return typeof contentType === 'string'
91
+ ? { body: stored.value, contentType }
92
+ : { body: stored.value }
93
+ }
94
+
64
95
  /**
65
96
  * The record a deployer published for this label. It never changes: the label is derived from the
66
- * build and the frozen configuration it records.
97
+ * build and the configuration of the deployment it describes.
67
98
  *
68
99
  * @param {Request} request
69
- * @param {string} record
100
+ * @param {PublishedRecord} record
70
101
  */
71
102
  function deploymentRecordResponse(request, record) {
72
103
  if (request.method !== 'GET' && request.method !== 'HEAD') {
@@ -75,10 +106,10 @@ function deploymentRecordResponse(request, record) {
75
106
  headers: { allow: 'GET, HEAD', 'cache-control': 'no-store' },
76
107
  })
77
108
  }
78
- return new Response(record, {
109
+ return new Response(record.body, {
79
110
  status: 200,
80
111
  headers: {
81
- 'content-type': DEPLOYMENT_RECORD_TYPE,
112
+ 'content-type': /** @type {string} */ (record.contentType),
82
113
  'cache-control': 'public, max-age=31536000, immutable',
83
114
  },
84
115
  })
@@ -23,6 +23,11 @@ export type SchedulePage =
23
23
  /** The routing KV binding (`SCHEDULES`): the dispatcher only reads it. */
24
24
  export interface RoutingKvBinding {
25
25
  get(key: string): Promise<string | null>
26
+ /** Read for the record endpoint only: a record's bytes and the metadata it was written with. */
27
+ getWithMetadata(
28
+ key: string,
29
+ type: 'arrayBuffer',
30
+ ): Promise<{ readonly value: ArrayBuffer | null; readonly metadata: unknown }>
26
31
  list(options: { readonly prefix: string; readonly cursor?: string }): Promise<SchedulePage>
27
32
  }
28
33
 
@@ -6,7 +6,8 @@
6
6
  * - provision once: a dispatch namespace, its routing KV, the dispatcher Worker and the wildcard
7
7
  * ingress of the routing domain (`planHosting`, `provisionHosting`; `astrale-domain cloudflare init`);
8
8
  * - deploy: one create-only script per deployment label, activated by its record in the routing
9
- * KV (`uploadDeploymentScript`, `activateDeployment`; `cloudflare({ namespace })` does it);
9
+ * KV, opaque bytes the dispatcher serves verbatim (`uploadDeploymentScript`,
10
+ * `activateDeployment`; `cloudflare({ namespace })` and Admin's Services do it);
10
11
  * - operate: point a name at a deployment, schedule crons, retire in two phases, list a line.
11
12
  *
12
13
  * Everything but provisioning runs in Node and inside a Worker (`fetch` and WebCrypto only).
@@ -15,11 +16,11 @@
15
16
  export { CLOUDFLARE_API_BASE, CloudflareApiError, CloudflareClient, DEFAULT_RATE_LIMIT, isNotFound, OPERATOR_RATE_LIMIT, type CloudflareClientOptions, type CloudflareFailureKind, type CloudflarePage, type RateLimitPolicy, } from './client.js';
16
17
  export { HostingRefusal } from './refusal.js';
17
18
  export { callsDataset, isWorkerName, routingKvTitle } from './names.js';
18
- export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeRecord, decodeSchedules, encodeRecord, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, sameRelease, WITHDRAWN, type RoutingKvLocator, } from './kv.js';
19
+ export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, WITHDRAWN, type RoutingKvLocator, } from './kv.js';
19
20
  export { ARTIFACT_TAG_PREFIX, artifactOf, ASSETS_BINDING, BUILD_DIGEST_BINDING, decodeScript, deleteScript, deploymentBindings, deploymentTags, isExistingScript, KEY_TAG_PREFIX, LINE_TAG_PREFIX, observeScript, putScriptSecret, ROUTER_BINDING, scriptPath, scriptSecretNames, scriptSettings, SETTINGS_BINDING, settingsBind, SIGNING_IDENTITY_BINDING, uploadDeploymentScript, VERSION_METADATA_BINDING, versionUuid, WORKER_URL_BINDING, type DeploymentBindingsInput, type NamespaceScript, type ScriptBinding, type ScriptModule, type ScriptSettings, type ScriptUpload, } from './script.js';
20
21
  export { uploadAssets, type ScriptAsset } from './assets.js';
21
22
  export { createSigningIdentity, type DeploymentSigningIdentity } from './identity.js';
22
- export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, } from './activation.js';
23
+ export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, sameRecord, } from './activation.js';
23
24
  export { completeRetirement, markRetired, readRetirement, RETIREMENT_PROBE_PATH, RETIREMENT_PROBE_RETRY_MS, RETIREMENT_PROPAGATION_MS, retirementVisible, type RetirementCompletion, } from './retirement.js';
24
25
  export { dispatchFetcher, publicFetcher, VERSION_HEADER, versionFetch, waitForReadiness, type ReadinessFetcher, } from './readiness.js';
25
26
  export { listLineDeployments, type LineDeployment } from './listing.js';
@@ -6,7 +6,8 @@
6
6
  * - provision once: a dispatch namespace, its routing KV, the dispatcher Worker and the wildcard
7
7
  * ingress of the routing domain (`planHosting`, `provisionHosting`; `astrale-domain cloudflare init`);
8
8
  * - deploy: one create-only script per deployment label, activated by its record in the routing
9
- * KV (`uploadDeploymentScript`, `activateDeployment`; `cloudflare({ namespace })` does it);
9
+ * KV, opaque bytes the dispatcher serves verbatim (`uploadDeploymentScript`,
10
+ * `activateDeployment`; `cloudflare({ namespace })` and Admin's Services do it);
10
11
  * - operate: point a name at a deployment, schedule crons, retire in two phases, list a line.
11
12
  *
12
13
  * Everything but provisioning runs in Node and inside a Worker (`fetch` and WebCrypto only).
@@ -15,11 +16,11 @@
15
16
  export { CLOUDFLARE_API_BASE, CloudflareApiError, CloudflareClient, DEFAULT_RATE_LIMIT, isNotFound, OPERATOR_RATE_LIMIT, } from './client.js';
16
17
  export { HostingRefusal } from './refusal.js';
17
18
  export { callsDataset, isWorkerName, routingKvTitle } from './names.js';
18
- export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeRecord, decodeSchedules, encodeRecord, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, sameRelease, WITHDRAWN, } from './kv.js';
19
+ export { CRON_PREFIX, cronKey, CURRENT_PREFIX, currentKey, decodeCurrent, decodeSchedules, encodeSchedules, findKvNamespace, isLabelOf, RECORD_PREFIX, recordKey, RETIREMENT_PREFIX, retirementKey, RoutingKv, WITHDRAWN, } from './kv.js';
19
20
  export { ARTIFACT_TAG_PREFIX, artifactOf, ASSETS_BINDING, BUILD_DIGEST_BINDING, decodeScript, deleteScript, deploymentBindings, deploymentTags, isExistingScript, KEY_TAG_PREFIX, LINE_TAG_PREFIX, observeScript, putScriptSecret, ROUTER_BINDING, scriptPath, scriptSecretNames, scriptSettings, SETTINGS_BINDING, settingsBind, SIGNING_IDENTITY_BINDING, uploadDeploymentScript, VERSION_METADATA_BINDING, versionUuid, WORKER_URL_BINDING, } from './script.js';
20
21
  export { uploadAssets } from './assets.js';
21
22
  export { createSigningIdentity } from './identity.js';
22
- export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, } from './activation.js';
23
+ export { activateDeployment, promoteDeployment, readCurrent, readRecord, readSchedules, replaceSchedules, sameRecord, } from './activation.js';
23
24
  export { completeRetirement, markRetired, readRetirement, RETIREMENT_PROBE_PATH, RETIREMENT_PROBE_RETRY_MS, RETIREMENT_PROPAGATION_MS, retirementVisible, } from './retirement.js';
24
25
  export { dispatchFetcher, publicFetcher, VERSION_HEADER, versionFetch, waitForReadiness, } from './readiness.js';
25
26
  export { listLineDeployments } from './listing.js';
@@ -1,17 +1,17 @@
1
- import type { DeploymentRecordV1 } from '@astrale-os/sdk/deployment/address';
2
1
  import type { CloudflareClient } from './client.js';
3
2
  /**
4
3
  * The routing KV contract every dispatcher reads (`SCHEDULES`) and every writer of the kit keeps:
5
4
  *
6
- * | key | value | content type |
5
+ * | key | value | metadata |
7
6
  * |---|---|---|
8
- * | `record:<label>` | RFC 8785 JSON of the label's `DeploymentRecordV1`; never rewritten | `application/json` |
9
- * | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher | `text/plain` |
10
- * | `current:<name>` | the label a name serves now, one of `<name>-…` | `text/plain` |
11
- * | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty | `application/json` |
7
+ * | `record:<label>` | the deployment's record, opaque bytes; `-` when it publishes none; never rewritten | `{"contentType":<its media type>}`, `{"contentType":null}` without a record |
8
+ * | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher | none |
9
+ * | `current:<name>` | the label a name serves now, one of `<name>-…` | none |
10
+ * | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty | none |
12
11
  *
13
- * The record of a label activates it and outlives its retirement. Workers KV has no
14
- * compare-and-set: every write is read back before it counts.
12
+ * `record:<label>` activates the label and outlives its retirement. One written before records
13
+ * were opaque has no metadata: it is the RFC 8785 JSON of a `DeploymentRecordV1`, served as one.
14
+ * Workers KV has no compare-and-set: every write is read back before it counts.
15
15
  */
16
16
  export declare const RECORD_PREFIX = "record:";
17
17
  export declare const RETIREMENT_PREFIX = "retirement:";
@@ -27,17 +27,6 @@ export declare function retirementKey(label: string): string;
27
27
  export declare function currentKey(name: string): string;
28
28
  /** The key of a name's UTC crons, which the dispatcher fires on its current deployment. */
29
29
  export declare function cronKey(name: string): string;
30
- /** The text `record:<label>` holds: the record admitted, as RFC 8785 canonical JSON. */
31
- export declare function encodeRecord(record: DeploymentRecordV1): string;
32
- /** Admit the text `record:<label>` holds as that label's record. */
33
- export declare function decodeRecord(text: string, label: string): DeploymentRecordV1;
34
- /**
35
- * Whether two records declare the same deployment: same origin, environment, release, build,
36
- * Schema revision, configuration and script. Only the commit may differ: a redeploy of identical
37
- * bytes and configuration from another commit is the same deployment, whose record keeps the
38
- * commit it was first activated from. This is the kit's one re-publication rule.
39
- */
40
- export declare function sameRelease(left: DeploymentRecordV1, right: DeploymentRecordV1): boolean;
41
30
  /** Admit the label `current:<name>` holds: a deployment label of that name's line. */
42
31
  export declare function decodeCurrent(text: string, name: string): string;
43
32
  /** Whether `label` is a deployment label whose line is `name`, as the dispatcher admits it. */
@@ -76,6 +65,16 @@ export declare class RoutingKv {
76
65
  get(key: string): Promise<string | undefined>;
77
66
  /** Write `text` at `key`, then read it back. */
78
67
  put(key: string, text: string, contentType: string): Promise<void>;
68
+ /**
69
+ * The bytes stored at `key` and their metadata (`null` when they were written without), or
70
+ * undefined when the key is absent.
71
+ */
72
+ getWithMetadata(key: string): Promise<{
73
+ readonly bytes: Uint8Array;
74
+ readonly metadata: unknown;
75
+ } | undefined>;
76
+ /** Write `bytes` at `key` with its JSON `metadata`, then read both back. */
77
+ putWithMetadata(key: string, bytes: Uint8Array, metadata: Readonly<Record<string, unknown>>): Promise<void>;
79
78
  /** Delete `key`, then read back its absence. */
80
79
  delete(key: string): Promise<void>;
81
80
  /**
@@ -84,6 +83,7 @@ export declare class RoutingKv {
84
83
  */
85
84
  keys(prefix: string): Promise<readonly string[]>;
86
85
  private value;
86
+ private path;
87
87
  }
88
88
  /** The id of the account's KV namespace with exactly this title, or undefined. */
89
89
  export declare function findKvNamespace(client: CloudflareClient, title: string): Promise<string | undefined>;