@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.
@@ -1,20 +1,20 @@
1
- import { acceptDeploymentRecord, parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
2
- import { json } from '@astrale-os/sdk/value';
3
- import { CloudflareApiError } from './client.js';
1
+ import { parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
2
+ import { CloudflareApiError, isNotFound } from './client.js';
4
3
  import { canonicalUtcCrons } from './dispatcher/cron.mjs';
5
4
  import { HostingRefusal } from './refusal.js';
6
5
  /**
7
6
  * The routing KV contract every dispatcher reads (`SCHEDULES`) and every writer of the kit keeps:
8
7
  *
9
- * | key | value | content type |
8
+ * | key | value | metadata |
10
9
  * |---|---|---|
11
- * | `record:<label>` | RFC 8785 JSON of the label's `DeploymentRecordV1`; never rewritten | `application/json` |
12
- * | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher | `text/plain` |
13
- * | `current:<name>` | the label a name serves now, one of `<name>-…` | `text/plain` |
14
- * | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty | `application/json` |
10
+ * | `record:<label>` | the deployment's record, opaque bytes; `-` when it publishes none; never rewritten | `{"contentType":<its media type>}`, `{"contentType":null}` without a record |
11
+ * | `retirement:<label>` | `withdrawn`; any value retires the label at the dispatcher | none |
12
+ * | `current:<name>` | the label a name serves now, one of `<name>-…` | none |
13
+ * | `cron:<name>` | JSON array of distinct canonical five-field UTC crons; deleted when empty | none |
15
14
  *
16
- * The record of a label activates it and outlives its retirement. Workers KV has no
17
- * compare-and-set: every write is read back before it counts.
15
+ * `record:<label>` activates the label and outlives its retirement. One written before records
16
+ * were opaque has no metadata: it is the RFC 8785 JSON of a `DeploymentRecordV1`, served as one.
17
+ * Workers KV has no compare-and-set: every write is read back before it counts.
18
18
  */
19
19
  export const RECORD_PREFIX = 'record:';
20
20
  export const RETIREMENT_PREFIX = 'retirement:';
@@ -38,39 +38,6 @@ export function currentKey(name) {
38
38
  export function cronKey(name) {
39
39
  return `${CRON_PREFIX}${name}`;
40
40
  }
41
- /** The text `record:<label>` holds: the record admitted, as RFC 8785 canonical JSON. */
42
- export function encodeRecord(record) {
43
- return json.serialize(acceptDeploymentRecord(record));
44
- }
45
- /** Admit the text `record:<label>` holds as that label's record. */
46
- export function decodeRecord(text, label) {
47
- let record;
48
- try {
49
- record = acceptDeploymentRecord(JSON.parse(text));
50
- }
51
- catch {
52
- throw new HostingRefusal('record-invalid', `The record of ${label} is not a DeploymentRecordV1.`);
53
- }
54
- if (record.providerScript !== label) {
55
- throw new HostingRefusal('record-invalid', `The record published under ${label} names the script ${record.providerScript}.`);
56
- }
57
- return record;
58
- }
59
- /**
60
- * Whether two records declare the same deployment: same origin, environment, release, build,
61
- * Schema revision, configuration and script. Only the commit may differ: a redeploy of identical
62
- * bytes and configuration from another commit is the same deployment, whose record keeps the
63
- * commit it was first activated from. This is the kit's one re-publication rule.
64
- */
65
- export function sameRelease(left, right) {
66
- return (left.origin === right.origin &&
67
- left.environment === right.environment &&
68
- left.releaseDigest === right.releaseDigest &&
69
- left.buildDigest === right.buildDigest &&
70
- left.schemaRevision === right.schemaRevision &&
71
- left.configuration === right.configuration &&
72
- left.providerScript === right.providerScript);
73
- }
74
41
  /** Admit the label `current:<name>` holds: a deployment label of that name's line. */
75
42
  export function decodeCurrent(text, name) {
76
43
  if (!isLabelOf(text, name)) {
@@ -159,6 +126,40 @@ export class RoutingKv {
159
126
  if ((await this.get(key)) !== text)
160
127
  notKept(key);
161
128
  }
129
+ /**
130
+ * The bytes stored at `key` and their metadata (`null` when they were written without), or
131
+ * undefined when the key is absent.
132
+ */
133
+ async getWithMetadata(key) {
134
+ const bytes = await this.client.bytes(this.value(key));
135
+ if (bytes === undefined)
136
+ return undefined;
137
+ let metadata;
138
+ try {
139
+ metadata = await this.client.json('GET', this.path('metadata', key));
140
+ }
141
+ catch (cause) {
142
+ if (isNotFound(cause))
143
+ return undefined;
144
+ throw cause;
145
+ }
146
+ return Object.freeze({ bytes, metadata: metadata ?? null });
147
+ }
148
+ /** Write `bytes` at `key` with its JSON `metadata`, then read both back. */
149
+ async putWithMetadata(key, bytes, metadata) {
150
+ const text = JSON.stringify(metadata);
151
+ const form = new FormData();
152
+ form.append('value', new Blob([bytes]));
153
+ form.append('metadata', text);
154
+ await this.client.form('PUT', this.value(key), form);
155
+ const stored = await this.getWithMetadata(key);
156
+ if (stored === undefined ||
157
+ stored.bytes.byteLength !== bytes.byteLength ||
158
+ stored.bytes.some((byte, index) => byte !== bytes[index]) ||
159
+ JSON.stringify(stored.metadata) !== text) {
160
+ notKept(key);
161
+ }
162
+ }
162
163
  /** Delete `key`, then read back its absence. */
163
164
  async delete(key) {
164
165
  await this.client.text('DELETE', this.value(key));
@@ -181,7 +182,10 @@ export class RoutingKv {
181
182
  }));
182
183
  }
183
184
  value(key) {
184
- return this.client.account(`/storage/kv/namespaces/${encodeURIComponent(this.id)}/values/${encodeURIComponent(key)}`);
185
+ return this.path('values', key);
186
+ }
187
+ path(kind, key) {
188
+ return this.client.account(`/storage/kv/namespaces/${encodeURIComponent(this.id)}/${kind}/${encodeURIComponent(key)}`);
185
189
  }
186
190
  }
187
191
  /** The id of the account's KV namespace with exactly this title, or undefined. */
@@ -1,19 +1,20 @@
1
+ import type { WorkerRecord } from '@astrale-os/sdk/deployment/worker';
1
2
  import type { CloudflareClient } from './client.js';
2
3
  import type { RoutingKv } from './kv.js';
3
4
  import type { NamespaceScript } from './script.js';
4
5
  /**
5
6
  * One deployment of a line as the routing KV and the namespace hold it:
6
7
  *
7
- * - `record`: `record:<label>` as stored, JSON-decoded and never admitted (JSON `null` included;
8
- * its text when it is not JSON); absent when the label has no record;
8
+ * - `record`: the record `record:<label>` publishes (its media type and bytes, never read), `null`
9
+ * when the deployment publishes none; absent when the label is not activated;
9
10
  * - `retired`: whether `retirement:<label>` withdraws it;
10
11
  * - `script`: the script under the label, absent once deleted (a completed retirement).
11
12
  *
12
- * The dispatcher serves it (`active`) exactly when it has a record, no tombstone and a script.
13
+ * The dispatcher serves it (`active`) exactly when it is activated, has no tombstone and a script.
13
14
  */
14
15
  export interface LineDeployment {
15
16
  readonly label: string;
16
- readonly record?: unknown;
17
+ readonly record?: WorkerRecord | null;
17
18
  readonly retired: boolean;
18
19
  readonly script?: NamespaceScript;
19
20
  }
@@ -22,8 +23,8 @@ export interface LineDeployment {
22
23
  * the routing KV, each with its script, in label order. A key there that names no deployment
23
24
  * label is refused (`record-invalid`, `retirement-invalid`); one whose label is on another line,
24
25
  * whose readable part happens to extend this one, is not this line's. A label that only has an
25
- * uploaded script (never activated nor retired) is not listed: nothing in the KV names it.
26
- * Nothing is written.
26
+ * uploaded script (never activated nor retired) is not listed: nothing in the KV names it. A stored
27
+ * record the kit refuses is refused (`record-invalid`). Nothing is written.
27
28
  */
28
29
  export declare function listLineDeployments(input: {
29
30
  readonly client: CloudflareClient;
@@ -1,6 +1,7 @@
1
1
  import { parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
2
+ import { readRecord } from './activation.js';
2
3
  import { CLOUDFLARE_FAN_OUT, mapBounded } from './bounded.js';
3
- import { RECORD_PREFIX, recordKey, RETIREMENT_PREFIX } from './kv.js';
4
+ import { RECORD_PREFIX, RETIREMENT_PREFIX } from './kv.js';
4
5
  import { HostingRefusal } from './refusal.js';
5
6
  import { readRetirement } from './retirement.js';
6
7
  import { observeScript } from './script.js';
@@ -9,8 +10,8 @@ import { observeScript } from './script.js';
9
10
  * the routing KV, each with its script, in label order. A key there that names no deployment
10
11
  * label is refused (`record-invalid`, `retirement-invalid`); one whose label is on another line,
11
12
  * whose readable part happens to extend this one, is not this line's. A label that only has an
12
- * uploaded script (never activated nor retired) is not listed: nothing in the KV names it.
13
- * Nothing is written.
13
+ * uploaded script (never activated nor retired) is not listed: nothing in the KV names it. A stored
14
+ * record the kit refuses is refused (`record-invalid`). Nothing is written.
14
15
  */
15
16
  export async function listLineDeployments(input) {
16
17
  const { client, kv, namespace, line } = input;
@@ -19,13 +20,13 @@ export async function listLineDeployments(input) {
19
20
  const activated = new Set(await labelsOf(kv, RECORD_PREFIX, line));
20
21
  const labels = [...new Set([...activated, ...retired])].sort();
21
22
  return Object.freeze(await mapBounded(labels, CLOUDFLARE_FAN_OUT, async (label) => {
22
- const stored = activated.has(label) ? await kv.get(recordKey(label)) : undefined;
23
+ const stored = activated.has(label) ? await readRecord(kv, label) : undefined;
23
24
  if (retired.has(label))
24
25
  await readRetirement(kv, label);
25
26
  const script = await observeScript(client, namespace, label);
26
27
  return Object.freeze({
27
28
  label,
28
- ...(stored === undefined ? {} : { record: parseStored(stored) }),
29
+ ...(stored === undefined ? {} : { record: stored }),
29
30
  retired: retired.has(label),
30
31
  ...(script === undefined ? {} : { script }),
31
32
  });
@@ -47,11 +48,3 @@ async function labelsOf(kv, prefix, line) {
47
48
  }
48
49
  return labels;
49
50
  }
50
- function parseStored(text) {
51
- try {
52
- return JSON.parse(text);
53
- }
54
- catch {
55
- return text;
56
- }
57
- }
@@ -12,7 +12,7 @@ export declare class HostingRefusal extends Error {
12
12
  | 'deployment-conflict'
13
13
  /** The label is withdrawn and is never served again. */
14
14
  | 'deployment-retired'
15
- /** A stored `record:` is not this label's `DeploymentRecordV1`. */
15
+ /** A stored `record:` is not a record the kit publishes (media type, bytes, metadata). */
16
16
  | 'record-invalid'
17
17
  /** A stored `retirement:` holds another value than `withdrawn`. */
18
18
  | 'retirement-invalid'
@@ -1,4 +1,4 @@
1
- import type { DeploymentRecordV1 } from '@astrale-os/sdk/deployment/address';
1
+ import type { WorkerRecord } from '@astrale-os/sdk/deployment/worker';
2
2
  import type { CloudflareClient } from './client.js';
3
3
  import type { RoutingKv } from './kv.js';
4
4
  import type { NamespaceScript } from './script.js';
@@ -49,10 +49,11 @@ export type RetirementCompletion = {
49
49
  * it the key bound to it; until then delete nothing and answer `propagating`. The record stays at
50
50
  * `record:<label>`.
51
51
  *
52
- * The script is deleted only when it is the caller's deployment: a stored record must name the
53
- * caller's build, configuration and script when the caller passes its `record`, and the script
54
- * must satisfy `owns` (its tags, its etag) when the caller passes it; otherwise nothing is deleted
55
- * (`conflict`). An absent script is already removed. Rerunning converges.
52
+ * The script is deleted only when it is the caller's deployment: a stored record must be the one
53
+ * the caller published (`record`: the same media type and bytes, or `null` for none) when the
54
+ * caller passes it, and the script must satisfy `owns` (its tags, its etag) when the caller passes
55
+ * it; otherwise nothing is deleted (`conflict`). An absent script is already removed. Rerunning
56
+ * converges.
56
57
  */
57
58
  export declare function completeRetirement(input: {
58
59
  readonly client: CloudflareClient;
@@ -60,7 +61,7 @@ export declare function completeRetirement(input: {
60
61
  readonly namespace: string;
61
62
  readonly label: string;
62
63
  readonly probe: () => Promise<boolean>;
63
- readonly record?: Pick<DeploymentRecordV1, 'buildDigest' | 'configuration' | 'providerScript'>;
64
+ readonly record?: WorkerRecord | null;
64
65
  readonly owns?: (script: NamespaceScript) => boolean;
65
66
  /** When the tombstone was written (epoch ms), if the caller keeps it. */
66
67
  readonly retiredAt?: number;
@@ -1,4 +1,5 @@
1
- import { recordKey, retirementKey, WITHDRAWN } from './kv.js';
1
+ import { readRecord, sameRecord } from './activation.js';
2
+ import { retirementKey, WITHDRAWN } from './kv.js';
2
3
  import { HostingRefusal } from './refusal.js';
3
4
  import { deleteScript, observeScript } from './script.js';
4
5
  /** The dispatcher's retirement probe path; it is never a call of the deployment. */
@@ -67,17 +68,18 @@ export async function retirementVisible(input) {
67
68
  * it the key bound to it; until then delete nothing and answer `propagating`. The record stays at
68
69
  * `record:<label>`.
69
70
  *
70
- * The script is deleted only when it is the caller's deployment: a stored record must name the
71
- * caller's build, configuration and script when the caller passes its `record`, and the script
72
- * must satisfy `owns` (its tags, its etag) when the caller passes it; otherwise nothing is deleted
73
- * (`conflict`). An absent script is already removed. Rerunning converges.
71
+ * The script is deleted only when it is the caller's deployment: a stored record must be the one
72
+ * the caller published (`record`: the same media type and bytes, or `null` for none) when the
73
+ * caller passes it, and the script must satisfy `owns` (its tags, its etag) when the caller passes
74
+ * it; otherwise nothing is deleted (`conflict`). An absent script is already removed. Rerunning
75
+ * converges.
74
76
  */
75
77
  export async function completeRetirement(input) {
76
78
  const { client, label } = input;
77
79
  if (input.record !== undefined) {
78
- const published = await input.kv.get(recordKey(label));
79
- if (published !== undefined && !namesRelease(published, input.record)) {
80
- return conflict(`The record published for ${label} names another build or configuration.`);
80
+ const published = await readRecord(input.kv, label);
81
+ if (published !== undefined && !sameRecord(published, input.record)) {
82
+ return conflict(`The record published for ${label} is not this deployment's.`);
81
83
  }
82
84
  }
83
85
  const script = await observeScript(client, input.namespace, label);
@@ -96,21 +98,6 @@ export async function completeRetirement(input) {
96
98
  await deleteScript(client, input.namespace, label);
97
99
  return Object.freeze({ kind: 'removed' });
98
100
  }
99
- function namesRelease(published, record) {
100
- let value;
101
- try {
102
- value = JSON.parse(published);
103
- }
104
- catch {
105
- return false;
106
- }
107
- const read = value;
108
- return (read !== null &&
109
- typeof read === 'object' &&
110
- read.buildDigest === record.buildDigest &&
111
- read.configuration === record.configuration &&
112
- read.providerScript === record.providerScript);
113
- }
114
101
  function conflict(message) {
115
102
  return Object.freeze({
116
103
  kind: 'conflict',
@@ -77,8 +77,11 @@ export interface DeploymentBindingsInput {
77
77
  readonly url: string;
78
78
  /** The build digest of the bytes uploaded (`ASTRALE_BUILD_DIGEST`). */
79
79
  readonly buildDigest: string;
80
- /** Its execution settings, already encoded (`ASTRALE_SETTINGS`). */
81
- readonly settings: string;
80
+ /**
81
+ * A generated Worker's execution settings, already encoded (`ASTRALE_SETTINGS`), bound after the
82
+ * build digest; none when absent (a Worker host binds a deployer's settings among its variables).
83
+ */
84
+ readonly settings?: string;
82
85
  /** The author's plain variables, bound in name order. */
83
86
  readonly vars: Readonly<Record<string, string>>;
84
87
  /** Service bindings, in the order given. */
@@ -93,27 +96,29 @@ export interface DeploymentBindingsInput {
93
96
  }[];
94
97
  /** The Worker the `ROUTER` service binding reaches; none when absent. */
95
98
  readonly router?: string;
96
- /** The private JWK text of its key (`ASTRALE_SIGNING_IDENTITY`). */
97
- readonly signingIdentity: string;
99
+ /** The private JWK text of its key (`ASTRALE_SIGNING_IDENTITY`); none when absent. */
100
+ readonly signingIdentity?: string;
98
101
  /** Its secrets, bound in name order. */
99
102
  readonly secrets: Readonly<Record<string, string>>;
100
103
  }
101
104
  /**
102
105
  * The bindings of one new deployment, in their order: `WORKER_URL`, `ASTRALE_BUILD_DIGEST`,
103
- * `ASTRALE_SETTINGS`, the variables, the service and dispatch namespace bindings, `ROUTER`, the
104
- * version metadata `ASTRALE_VERSION_METADATA`, the key `ASTRALE_SIGNING_IDENTITY`, then the
105
- * secrets. A script upload with assets adds `ASSETS` last. Nothing ever adds a binding afterwards:
106
- * only a secret's value is rewritten in place. A name bound twice is refused.
106
+ * `ASTRALE_SETTINGS` (when given), the variables, the service and dispatch namespace bindings,
107
+ * `ROUTER` (when given), the version metadata `ASTRALE_VERSION_METADATA`, the key
108
+ * `ASTRALE_SIGNING_IDENTITY` (when given), then the secrets. A script upload with assets adds
109
+ * `ASSETS` last. Nothing ever adds a binding afterwards: only a secret's value is rewritten in
110
+ * place. A name bound twice is refused.
107
111
  */
108
112
  export declare function deploymentBindings(input: DeploymentBindingsInput): readonly ScriptBinding[];
109
113
  /**
110
114
  * The tags of one deployment script: the caller's `extra` tags first (a host's own, such as
111
- * Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and key.
115
+ * Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and, when
116
+ * it binds one, key.
112
117
  */
113
118
  export declare function deploymentTags(input: {
114
119
  readonly buildDigest: string;
115
120
  readonly line: string;
116
- readonly keyId: string;
121
+ readonly keyId?: string;
117
122
  readonly extra?: readonly string[];
118
123
  }): readonly string[];
119
124
  /** The artifact tag value of one build: the hexadecimal digest of its manifest. */
@@ -28,16 +28,19 @@ const MODULE_PART_TYPES = Object.freeze({
28
28
  });
29
29
  /**
30
30
  * The bindings of one new deployment, in their order: `WORKER_URL`, `ASTRALE_BUILD_DIGEST`,
31
- * `ASTRALE_SETTINGS`, the variables, the service and dispatch namespace bindings, `ROUTER`, the
32
- * version metadata `ASTRALE_VERSION_METADATA`, the key `ASTRALE_SIGNING_IDENTITY`, then the
33
- * secrets. A script upload with assets adds `ASSETS` last. Nothing ever adds a binding afterwards:
34
- * only a secret's value is rewritten in place. A name bound twice is refused.
31
+ * `ASTRALE_SETTINGS` (when given), the variables, the service and dispatch namespace bindings,
32
+ * `ROUTER` (when given), the version metadata `ASTRALE_VERSION_METADATA`, the key
33
+ * `ASTRALE_SIGNING_IDENTITY` (when given), then the secrets. A script upload with assets adds
34
+ * `ASSETS` last. Nothing ever adds a binding afterwards: only a secret's value is rewritten in
35
+ * place. A name bound twice is refused.
35
36
  */
36
37
  export function deploymentBindings(input) {
37
38
  const bindings = [
38
39
  { type: 'plain_text', name: WORKER_URL_BINDING, text: input.url },
39
40
  { type: 'plain_text', name: BUILD_DIGEST_BINDING, text: input.buildDigest },
40
- { type: 'plain_text', name: SETTINGS_BINDING, text: input.settings },
41
+ ...(input.settings === undefined
42
+ ? []
43
+ : [{ type: 'plain_text', name: SETTINGS_BINDING, text: input.settings }]),
41
44
  ...Object.keys(input.vars)
42
45
  .sort()
43
46
  .map((name) => ({ type: 'plain_text', name, text: input.vars[name] })),
@@ -55,7 +58,15 @@ export function deploymentBindings(input) {
55
58
  ? []
56
59
  : [{ type: 'service', name: ROUTER_BINDING, service: input.router }]),
57
60
  { type: 'version_metadata', name: VERSION_METADATA_BINDING },
58
- { type: 'secret_text', name: SIGNING_IDENTITY_BINDING, text: input.signingIdentity },
61
+ ...(input.signingIdentity === undefined
62
+ ? []
63
+ : [
64
+ {
65
+ type: 'secret_text',
66
+ name: SIGNING_IDENTITY_BINDING,
67
+ text: input.signingIdentity,
68
+ },
69
+ ]),
59
70
  ...Object.keys(input.secrets)
60
71
  .sort()
61
72
  .map((name) => ({ type: 'secret_text', name, text: input.secrets[name] })),
@@ -69,14 +80,15 @@ export function deploymentBindings(input) {
69
80
  }
70
81
  /**
71
82
  * The tags of one deployment script: the caller's `extra` tags first (a host's own, such as
72
- * Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and key.
83
+ * Services' `astrale-service` and `astrale-install:`), then its artifact (build), line and, when
84
+ * it binds one, key.
73
85
  */
74
86
  export function deploymentTags(input) {
75
87
  return Object.freeze([
76
88
  ...(input.extra ?? []),
77
89
  `${ARTIFACT_TAG_PREFIX}${artifactOf(input.buildDigest)}`,
78
90
  `${LINE_TAG_PREFIX}${input.line}`,
79
- `${KEY_TAG_PREFIX}${input.keyId}`,
91
+ ...(input.keyId === undefined ? [] : [`${KEY_TAG_PREFIX}${input.keyId}`]),
80
92
  ]);
81
93
  }
82
94
  /** The artifact tag value of one build: the hexadecimal digest of its manifest. */
@@ -95,6 +95,8 @@ interface KvNamespace {
95
95
  readonly id: string;
96
96
  readonly title: string;
97
97
  readonly values: Map<string, string>;
98
+ /** The metadata a value was written with; a value written without has none. */
99
+ readonly metadata: Map<string, unknown>;
98
100
  }
99
101
  interface Zone {
100
102
  readonly id: string;
@@ -172,8 +174,15 @@ export declare class FakeCloudflare {
172
174
  addKv(title: string): KvNamespace;
173
175
  /** Add one zone named `name` (the fake's first zone is `FAKE_ZONE`). */
174
176
  addZone(name: string): Zone;
175
- /** The values of the KV namespace titled `title` (or with that id). */
177
+ /**
178
+ * The values of the KV namespace titled `title` (or with that id), as UTF-8 text: the fake keeps
179
+ * UTF-8 values only. A value set here has no metadata: a deployment record set here is one
180
+ * written before records were opaque.
181
+ */
176
182
  kv(titleOrId: string): Map<string, string>;
183
+ /** The metadata of the values of the KV namespace titled `title` (or with that id), by key. */
184
+ kvMetadata(titleOrId: string): Map<string, unknown>;
185
+ private kvNamespace;
177
186
  /** The scripts of one dispatch namespace. */
178
187
  scripts(namespace: string): Map<string, FakeScript>;
179
188
  /** A provider version in the UUID form a Worker's version metadata reports. */
@@ -91,7 +91,12 @@ export class FakeCloudflare {
91
91
  }
92
92
  /** Add one KV namespace; its id is derived from its title. */
93
93
  addKv(title) {
94
- const kv = { id: `kv${this.kvs.length}-${title}`.slice(0, 32), title, values: new Map() };
94
+ const kv = {
95
+ id: `kv${this.kvs.length}-${title}`.slice(0, 32),
96
+ title,
97
+ values: new Map(),
98
+ metadata: new Map(),
99
+ };
95
100
  this.kvs.push(kv);
96
101
  return kv;
97
102
  }
@@ -102,12 +107,23 @@ export class FakeCloudflare {
102
107
  this.zones.set(id, zone);
103
108
  return zone;
104
109
  }
105
- /** The values of the KV namespace titled `title` (or with that id). */
110
+ /**
111
+ * The values of the KV namespace titled `title` (or with that id), as UTF-8 text: the fake keeps
112
+ * UTF-8 values only. A value set here has no metadata: a deployment record set here is one
113
+ * written before records were opaque.
114
+ */
106
115
  kv(titleOrId) {
116
+ return this.kvNamespace(titleOrId).values;
117
+ }
118
+ /** The metadata of the values of the KV namespace titled `title` (or with that id), by key. */
119
+ kvMetadata(titleOrId) {
120
+ return this.kvNamespace(titleOrId).metadata;
121
+ }
122
+ kvNamespace(titleOrId) {
107
123
  const kv = this.kvs.find((candidate) => candidate.title === titleOrId || candidate.id === titleOrId);
108
124
  if (kv === undefined)
109
125
  throw new TypeError(`The fake account has no KV ${titleOrId}.`);
110
- return kv.values;
126
+ return kv;
111
127
  }
112
128
  /** The scripts of one dispatch namespace. */
113
129
  scripts(namespace) {
@@ -189,14 +205,27 @@ export class FakeCloudflare {
189
205
  };
190
206
  }
191
207
  kvBinding(id) {
192
- const values = () => {
208
+ const namespace = () => {
193
209
  const kv = this.kvs.find((candidate) => candidate.id === id);
194
210
  if (kv === undefined)
195
211
  throw new Error('KV namespace not found.');
196
- return kv.values;
212
+ return kv;
197
213
  };
214
+ const values = () => namespace().values;
198
215
  return {
199
216
  get: async (key) => values().get(key) ?? null,
217
+ getWithMetadata: async (key, type) => {
218
+ if (type !== 'arrayBuffer')
219
+ throw new TypeError('The fake reads metadata as bytes only.');
220
+ const value = values().get(key);
221
+ if (value === undefined)
222
+ return { value: null, metadata: null };
223
+ const bytes = new TextEncoder().encode(value);
224
+ return {
225
+ value: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
226
+ metadata: namespace().metadata.get(key) ?? null,
227
+ };
228
+ },
200
229
  list: async ({ prefix, cursor }) => {
201
230
  const names = [...values().keys()].filter((key) => key.startsWith(prefix)).sort();
202
231
  const size = this.options.kvKeysPageSize ?? 1000;
@@ -317,6 +346,18 @@ export class FakeCloudflare {
317
346
  response: envelope(page.map((name) => ({ name })), { count: page.length, cursor: next }),
318
347
  };
319
348
  }
349
+ const metadata = /^\/storage\/kv\/namespaces\/([^/]+)\/metadata\/(.+)$/u.exec(path);
350
+ if (metadata !== null && method === 'GET') {
351
+ const kv = this.kvs.find((candidate) => candidate.id === decodeURIComponent(metadata[1]));
352
+ if (kv === undefined)
353
+ return { response: failure(404, 10013, 'namespace not found') };
354
+ const key = decodeURIComponent(metadata[2]);
355
+ return {
356
+ response: kv.values.has(key)
357
+ ? envelope(kv.metadata.get(key) ?? null)
358
+ : failure(404, 10009, 'get: key not found'),
359
+ };
360
+ }
320
361
  const value = /^\/storage\/kv\/namespaces\/([^/]+)\/values\/(.+)$/u.exec(path);
321
362
  if (value !== null) {
322
363
  const kv = this.kvs.find((candidate) => candidate.id === decodeURIComponent(value[1]));
@@ -332,15 +373,25 @@ export class FakeCloudflare {
332
373
  };
333
374
  }
334
375
  if (method === 'PUT') {
376
+ const contentType = request.headers.get('content-type');
377
+ // A value written with metadata is a multipart form: its `value` and its JSON `metadata`.
378
+ if (contentType?.startsWith('multipart/form-data') === true) {
379
+ const form = await request.formData();
380
+ const part = form.get('value');
381
+ const written = typeof part === 'string' ? part : await utf8(part);
382
+ const declared = JSON.parse(String(form.get('metadata')));
383
+ kv.values.set(key, written);
384
+ kv.metadata.set(key, declared);
385
+ return { response: envelope(null), body: { key, value: written, metadata: declared } };
386
+ }
335
387
  const text = await request.text();
336
388
  kv.values.set(key, text);
337
- return {
338
- response: envelope(null),
339
- body: { key, value: text, contentType: request.headers.get('content-type') },
340
- };
389
+ kv.metadata.delete(key);
390
+ return { response: envelope(null), body: { key, value: text, contentType } };
341
391
  }
342
392
  if (method === 'DELETE') {
343
393
  kv.values.delete(key);
394
+ kv.metadata.delete(key);
344
395
  return { response: envelope(null), body: { key } };
345
396
  }
346
397
  }
@@ -697,3 +748,9 @@ function redact(metadata) {
697
748
  : binding),
698
749
  };
699
750
  }
751
+ /** A value part as UTF-8 text: the fake keeps UTF-8 values only, and refuses any other bytes. */
752
+ async function utf8(part) {
753
+ if (part === null)
754
+ throw new TypeError('The fake KV write has no value.');
755
+ return new TextDecoder('utf-8', { fatal: true }).decode(await part.arrayBuffer());
756
+ }