@astrale-os/adapter-cloudflare 0.5.0-beta.169 → 0.5.0-beta.170

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
@@ -61,6 +61,17 @@ label.
61
61
  - **Retention.** `retain` (called once a Publication commits) marks the summary `published`, which
62
62
  the platform's preview expiry never retires. A label the platform withdrew (`retirement:<label>`)
63
63
  answers `DEPLOYMENT_RETIRED` at the dispatcher and is refused by every later deploy.
64
+ - **Listing.** `astrale-domain list` lists the Environment's deployments from the routing KV, and
65
+ writes nothing: one row per `summary:<label>` of the Environment's line, with its record as
66
+ `record:<label>` stores it (a record the SDK refuses is listed with `record: null` and the
67
+ refusal), and the call target `{ kind: 'namespace', namespace, script }` that the platform's
68
+ per-script tooling takes, such as its in-place secret rotation. A summary without its record is
69
+ `deploying` (not activated), and an active one whose label carries the tombstone is `retiring`.
70
+ Nothing on the namespace records last calls: they come from its calls dataset,
71
+ `astrale_deployment_calls_<the same hexadecimal>`, through the Analytics Engine SQL API, and an
72
+ active preview expires 30 days after the later of its activation and its last call. The token
73
+ then also needs Account Analytics Read; without it, the deployments are listed without last calls
74
+ or expiries.
64
75
  - **Refused.** A direct-mode parameter beside `namespace` (`route`, `workerName`, `identityIssuer`,
65
76
  `addressing`, `signingIdentity`), a `router.binding` other than `ROUTER`, and any `wrangler` key
66
77
  but `services`, `dispatch_namespaces` and `compatibility_flags`, `triggers` first: an immutable
package/dist/adapter.d.ts CHANGED
@@ -16,8 +16,9 @@ export interface CloudflareDependencies extends CloudflareDeployDependencies {
16
16
  * Deploy one Project Environment through Cloudflare Workers.
17
17
  *
18
18
  * With `namespace`, each release becomes one immutable deployment in that platform dispatch
19
- * namespace (adapter interface v2). Without it, the Environment stays on the legacy direct mode:
20
- * one stable Worker replaced in place by each deploy (CT33).
19
+ * namespace (adapter interface v2), and `astrale-domain list` lists them. Without it, the
20
+ * Environment stays on the legacy direct mode: one stable Worker replaced in place by each deploy
21
+ * (CT33).
21
22
  */
22
23
  export declare function cloudflare(parameters?: CloudflareParams): Deployment<CloudflareParams, Adapter<CloudflareParams>>;
23
24
  export declare function cloudflareWithDependencies(parameters: CloudflareParams, dependencies: CloudflareDependencies): Deployment<CloudflareParams, Adapter<CloudflareParams>>;
package/dist/adapter.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
3
3
  import { cloudflareWorkerBundler } from './build/bundler.js';
4
4
  import { createSigningIdentity } from './identity/material.js';
5
5
  import { artifact, deployCloudflare, directReadiness, materialize, runWranglerDeploymentStatus, runWranglerRollout, signingIdentity, } from './legacy/direct/index.js';
6
- import { admitNamespace, configureNamespace, deployNamespaceRelease, placeNamespace, retainNamespaceDeployment, } from './namespace/index.js';
6
+ import { admitNamespace, configureNamespace, deployNamespaceRelease, listNamespaceDeployments, placeNamespace, retainNamespaceDeployment, } from './namespace/index.js';
7
7
  export { waitForReadiness } from './legacy/direct/index.js';
8
8
  const packageVersion = createRequire(import.meta.url)('../package.json')
9
9
  .version;
@@ -11,8 +11,9 @@ const packageVersion = createRequire(import.meta.url)('../package.json')
11
11
  * Deploy one Project Environment through Cloudflare Workers.
12
12
  *
13
13
  * With `namespace`, each release becomes one immutable deployment in that platform dispatch
14
- * namespace (adapter interface v2). Without it, the Environment stays on the legacy direct mode:
15
- * one stable Worker replaced in place by each deploy (CT33).
14
+ * namespace (adapter interface v2), and `astrale-domain list` lists them. Without it, the
15
+ * Environment stays on the legacy direct mode: one stable Worker replaced in place by each deploy
16
+ * (CT33).
16
17
  */
17
18
  export function cloudflare(parameters = {}) {
18
19
  return cloudflareWithDependencies(parameters, {
@@ -34,6 +35,7 @@ export function cloudflareWithDependencies(parameters, dependencies) {
34
35
  configure: configureNamespace,
35
36
  placement: async (parameters) => placeNamespace(parameters),
36
37
  deployRelease: (parameters, context) => deployNamespaceRelease(parameters, context, namespace),
38
+ list: (parameters, context) => listNamespaceDeployments(parameters, context, namespace),
37
39
  retain: (parameters, context) => retainNamespaceDeployment(parameters, context, namespace),
38
40
  // Legacy direct mode (CT33 `legacy-direct`), deleted with it (D10).
39
41
  prepare: dependencies.artifact,
@@ -27,7 +27,8 @@ export interface CloudflarePage {
27
27
  }
28
28
  /**
29
29
  * The few Cloudflare account endpoints namespace mode uses: dispatch-namespace scripts, their
30
- * secrets, their static assets, and the routing KV. The token is sent only to the API base; it is
30
+ * secrets, their static assets, the routing KV, and the calls dataset it lists last calls from. The
31
+ * token is sent only to the API base; it is
31
32
  * never logged, and neither is any request body (they carry secret values).
32
33
  */
33
34
  export declare class CloudflareApi {
@@ -52,6 +53,17 @@ export declare class CloudflareApi {
52
53
  form(method: string, path: string, body: FormData, headers?: Readonly<Record<string, string>>, bearer?: string): Promise<unknown>;
53
54
  /** Read one KV value as text, or undefined when the key is absent. */
54
55
  kvGet(kv: string, key: string): Promise<string | undefined>;
56
+ /**
57
+ * The names of every key of one KV namespace that starts with `prefix`, in Cloudflare's order
58
+ * (lexicographic), following the listing's cursor to its end.
59
+ */
60
+ kvKeys(kv: string, prefix: string): Promise<readonly string[]>;
61
+ /**
62
+ * Run one read-only Analytics Engine SQL query (`SELECT … FORMAT JSON`) and return the text it
63
+ * answers, which is the query's JSON document and not Cloudflare's envelope. A token without
64
+ * Account Analytics Read is refused with 401 or 403.
65
+ */
66
+ sql(query: string): Promise<string>;
55
67
  /** Write one KV value as text. */
56
68
  kvPut(kv: string, key: string, value: string, contentType: string): Promise<void>;
57
69
  private envelope;
@@ -4,6 +4,8 @@ const DEFAULT_TIMEOUT_MS = 30_000;
4
4
  const UPLOAD_TIMEOUT_MS = 120_000;
5
5
  const MAXIMUM_RATE_LIMIT_RETRIES = 3;
6
6
  const MAXIMUM_RATE_LIMIT_WAIT_MS = 10_000;
7
+ /** The most keys one page of a KV key listing returns, as Cloudflare caps it. */
8
+ const KV_KEYS_PAGE = 1_000;
7
9
  /**
8
10
  * A Cloudflare API refusal or failure. `status` is the HTTP status (412 for a create-only upload
9
11
  * over an existing script); `codes` are the error codes of Cloudflare's envelope, when it sent one
@@ -21,7 +23,8 @@ export class CloudflareApiError extends Error {
21
23
  }
22
24
  /**
23
25
  * The few Cloudflare account endpoints namespace mode uses: dispatch-namespace scripts, their
24
- * secrets, their static assets, and the routing KV. The token is sent only to the API base; it is
26
+ * secrets, their static assets, the routing KV, and the calls dataset it lists last calls from. The
27
+ * token is sent only to the API base; it is
25
28
  * never logged, and neither is any request body (they carry secret values).
26
29
  */
27
30
  export class CloudflareApi {
@@ -92,6 +95,52 @@ export class CloudflareApi {
92
95
  throw failure('GET', 'KV value', response.status, text);
93
96
  return text;
94
97
  }
98
+ /**
99
+ * The names of every key of one KV namespace that starts with `prefix`, in Cloudflare's order
100
+ * (lexicographic), following the listing's cursor to its end.
101
+ */
102
+ async kvKeys(kv, prefix) {
103
+ const names = [];
104
+ let cursor = '';
105
+ for (;;) {
106
+ const query = new URLSearchParams({ prefix, limit: String(KV_KEYS_PAGE) });
107
+ if (cursor !== '')
108
+ query.set('cursor', cursor);
109
+ const envelope = await this.envelope('GET', this.account(`/storage/kv/namespaces/${encodeURIComponent(kv)}/keys?${query}`), {});
110
+ if (!Array.isArray(envelope.result)) {
111
+ throw new CloudflareApiError('Cloudflare returned an invalid KV key listing.', 200);
112
+ }
113
+ for (const entry of envelope.result) {
114
+ const name = entry?.name;
115
+ if (typeof name !== 'string' || !name.startsWith(prefix)) {
116
+ throw new CloudflareApiError('Cloudflare returned an invalid KV key listing.', 200);
117
+ }
118
+ names.push(name);
119
+ }
120
+ const next = envelope.result_info?.cursor;
121
+ if (typeof next !== 'string' || next === '')
122
+ return names;
123
+ if (next === cursor) {
124
+ throw new CloudflareApiError('Cloudflare repeated a KV key listing cursor.', 200);
125
+ }
126
+ cursor = next;
127
+ }
128
+ }
129
+ /**
130
+ * Run one read-only Analytics Engine SQL query (`SELECT … FORMAT JSON`) and return the text it
131
+ * answers, which is the query's JSON document and not Cloudflare's envelope. A token without
132
+ * Account Analytics Read is refused with 401 or 403.
133
+ */
134
+ async sql(query) {
135
+ const response = await this.send('POST', this.account('/analytics_engine/sql'), {
136
+ body: query,
137
+ headers: { 'content-type': 'text/plain' },
138
+ });
139
+ const text = await response.text();
140
+ if (!response.ok)
141
+ throw failure('POST', 'Analytics Engine query', response.status, text);
142
+ return text;
143
+ }
95
144
  /** Write one KV value as text. */
96
145
  async kvPut(kv, key, value, contentType) {
97
146
  const response = await this.send('PUT', this.kvValue(kv, key), {
@@ -185,6 +234,10 @@ function describe(path) {
185
234
  return 'script settings';
186
235
  if (path.includes('/dispatch/namespaces/'))
187
236
  return 'namespace script';
237
+ if (path.includes('/analytics_engine/sql'))
238
+ return 'Analytics Engine query';
239
+ if (/\/storage\/kv\/namespaces\/[^/]+\/keys/u.test(path))
240
+ return 'KV keys';
188
241
  if (path.includes('/storage/kv/namespaces'))
189
242
  return 'KV namespace';
190
243
  return 'request';
@@ -0,0 +1,29 @@
1
+ import { type CloudflareApi } from './api.js';
2
+ /** The most labels one Analytics Engine query names; a longer list is read in several queries. */
3
+ export declare const LAST_CALL_QUERY_LABELS = 100;
4
+ /**
5
+ * How long a preview is kept after its activation or its last call, whichever is later (AM-123):
6
+ * Services' rule, which the platform's preview expiry plan (SV6) applies to the platform namespace.
7
+ */
8
+ export declare const PREVIEW_RETENTION_MILLISECONDS: number;
9
+ /**
10
+ * The instant of each label's last call, from the data point the platform dispatcher writes for
11
+ * each call that runs a deployment (`CALLS.writeDataPoint({ indexes: [label] })`), read with the
12
+ * Analytics Engine SQL API: one read-only query for every `LAST_CALL_QUERY_LABELS` labels, the same
13
+ * query Services' `lastCalls` runs. A label without a data point reads `null`. Analytics Engine
14
+ * keeps three months of data points and samples busy labels, so the instant is the last call it
15
+ * kept: a busy label's is recent anyway.
16
+ *
17
+ * `undefined` when the API token lacks Account Analytics Read (the query is refused with 401 or
18
+ * 403): a listing then omits last calls and expiries rather than computing them from anything else
19
+ * (AM-133). Any other failure, or an answer that is not exactly the query's rows, throws: a listing
20
+ * never prints a last call it could not read.
21
+ */
22
+ export declare function readLastCalls(api: CloudflareApi, dataset: string, labels: readonly string[]): Promise<ReadonlyMap<string, string | null> | undefined>;
23
+ /** One Analytics Engine SQL query for the last call of each label in a batch. */
24
+ export declare function lastCallsQuery(dataset: string, labels: readonly string[]): string;
25
+ /**
26
+ * A preview's expiry: `PREVIEW_RETENTION_MILLISECONDS` after the later of its activation and its
27
+ * last call, as an ISO instant.
28
+ */
29
+ export declare function previewExpiresAt(activatedAt: string, lastCallAt: string | null): string;
@@ -0,0 +1,104 @@
1
+ import { parseDeploymentLabel } from '@astrale-os/sdk/deployment/address';
2
+ import { CloudflareApiError } from './api.js';
3
+ /** The most labels one Analytics Engine query names; a longer list is read in several queries. */
4
+ export const LAST_CALL_QUERY_LABELS = 100;
5
+ /**
6
+ * How long a preview is kept after its activation or its last call, whichever is later (AM-123):
7
+ * Services' rule, which the platform's preview expiry plan (SV6) applies to the platform namespace.
8
+ */
9
+ export const PREVIEW_RETENTION_MILLISECONDS = 30 * 24 * 60 * 60 * 1_000;
10
+ /** Analytics Engine dataset names, as `platformCallsDataset` derives them. */
11
+ const DATASET = /^astrale_deployment_calls_[0-9a-f]{32}$/u;
12
+ /** The only characters a deployment label has, so a label is quoted into SQL as it is. */
13
+ const SQL_SAFE_LABEL = /^[a-z0-9-]+$/u;
14
+ /**
15
+ * The instant of each label's last call, from the data point the platform dispatcher writes for
16
+ * each call that runs a deployment (`CALLS.writeDataPoint({ indexes: [label] })`), read with the
17
+ * Analytics Engine SQL API: one read-only query for every `LAST_CALL_QUERY_LABELS` labels, the same
18
+ * query Services' `lastCalls` runs. A label without a data point reads `null`. Analytics Engine
19
+ * keeps three months of data points and samples busy labels, so the instant is the last call it
20
+ * kept: a busy label's is recent anyway.
21
+ *
22
+ * `undefined` when the API token lacks Account Analytics Read (the query is refused with 401 or
23
+ * 403): a listing then omits last calls and expiries rather than computing them from anything else
24
+ * (AM-133). Any other failure, or an answer that is not exactly the query's rows, throws: a listing
25
+ * never prints a last call it could not read.
26
+ */
27
+ export async function readLastCalls(api, dataset, labels) {
28
+ if (!DATASET.test(dataset))
29
+ throw new TypeError('The calls dataset name is invalid.');
30
+ const unique = [...new Set(labels)].sort();
31
+ for (const label of unique) {
32
+ parseDeploymentLabel(label);
33
+ if (!SQL_SAFE_LABEL.test(label))
34
+ throw new TypeError('Deployment label is invalid.');
35
+ }
36
+ const calls = new Map(unique.map((label) => [label, null]));
37
+ for (let start = 0; start < unique.length; start += LAST_CALL_QUERY_LABELS) {
38
+ const batch = unique.slice(start, start + LAST_CALL_QUERY_LABELS);
39
+ let text;
40
+ try {
41
+ text = await api.sql(lastCallsQuery(dataset, batch));
42
+ }
43
+ catch (cause) {
44
+ if (cause instanceof CloudflareApiError && (cause.status === 401 || cause.status === 403)) {
45
+ return undefined;
46
+ }
47
+ throw cause;
48
+ }
49
+ for (const [label, instant] of lastCallRows(text, new Set(batch)))
50
+ calls.set(label, instant);
51
+ }
52
+ return calls;
53
+ }
54
+ /** One Analytics Engine SQL query for the last call of each label in a batch. */
55
+ export function lastCallsQuery(dataset, labels) {
56
+ const list = labels.map((label) => `'${label}'`).join(', ');
57
+ return [
58
+ 'SELECT index1 AS label, toUnixTimestamp(max(timestamp)) AS last',
59
+ `FROM ${dataset}`,
60
+ `WHERE index1 IN (${list})`,
61
+ 'GROUP BY index1',
62
+ 'FORMAT JSON',
63
+ ].join(' ');
64
+ }
65
+ /**
66
+ * A preview's expiry: `PREVIEW_RETENTION_MILLISECONDS` after the later of its activation and its
67
+ * last call, as an ISO instant.
68
+ */
69
+ export function previewExpiresAt(activatedAt, lastCallAt) {
70
+ const activated = Date.parse(activatedAt);
71
+ const called = lastCallAt === null ? activated : Date.parse(lastCallAt);
72
+ return new Date(Math.max(activated, called) + PREVIEW_RETENTION_MILLISECONDS).toISOString();
73
+ }
74
+ /** The rows of one query's JSON document: each a label it asked for and a positive instant. */
75
+ function lastCallRows(text, asked) {
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(text);
79
+ }
80
+ catch (cause) {
81
+ throw new TypeError('Cloudflare returned invalid deployment call evidence.', { cause });
82
+ }
83
+ const data = parsed?.data;
84
+ if (!Array.isArray(data))
85
+ invalid();
86
+ const rows = [];
87
+ const seen = new Set();
88
+ for (const row of data) {
89
+ if (row === null || typeof row !== 'object' || Array.isArray(row))
90
+ invalid();
91
+ const { label, last } = row;
92
+ if (typeof label !== 'string' || !asked.has(label) || seen.has(label))
93
+ invalid();
94
+ const seconds = typeof last === 'string' ? Number(last) : last;
95
+ if (typeof seconds !== 'number' || !Number.isSafeInteger(seconds) || seconds <= 0)
96
+ invalid();
97
+ seen.add(label);
98
+ rows.push([label, new Date(seconds * 1_000).toISOString()]);
99
+ }
100
+ return rows;
101
+ }
102
+ function invalid() {
103
+ throw new TypeError('Cloudflare returned invalid deployment call evidence.');
104
+ }
@@ -12,6 +12,8 @@ export interface NamespaceCoordinates {
12
12
  readonly account?: string;
13
13
  /** The title of the routing KV the platform dispatcher reads (`SCHEDULES`). */
14
14
  readonly routingKvTitle: string;
15
+ /** The Analytics Engine dataset the platform dispatcher writes one data point to per call (`CALLS`). */
16
+ readonly callsDataset: string;
15
17
  }
16
18
  /**
17
19
  * Admit the `namespace` of one Environment, and that the Environment names no direct-mode
@@ -25,6 +27,13 @@ export declare function namespaceCoordinates(parameters: CloudflareParams): Name
25
27
  * namespace name alone and its configuration holds no KV id.
26
28
  */
27
29
  export declare function platformRoutingKvTitle(name: string): string;
30
+ /**
31
+ * The Analytics Engine dataset of a platform namespace's calls: `astrale_deployment_calls_<hex32>`,
32
+ * with the `<hex32>` of its routing KV title (AM-131). The platform dispatcher (SV6) writes one data
33
+ * point `{ indexes: [label] }` to it for each call that runs a deployment; a listing reads the last
34
+ * call of each label from it, with the Analytics Engine SQL API, and never writes to it.
35
+ */
36
+ export declare function platformCallsDataset(name: string): string;
28
37
  /** The KV key whose presence activates a label at the dispatcher (AM-89): its record. */
29
38
  export declare function recordKey(label: string): string;
30
39
  /** The KV key of a platform deployment's summary (AM-131), written before its record. */
@@ -37,6 +37,7 @@ export function namespaceCoordinates(parameters) {
37
37
  routingDomain: namespace.routingDomain,
38
38
  ...(namespace.account === undefined ? {} : { account: namespace.account }),
39
39
  routingKvTitle: platformRoutingKvTitle(namespace.name),
40
+ callsDataset: platformCallsDataset(namespace.name),
40
41
  });
41
42
  }
42
43
  /**
@@ -48,6 +49,15 @@ export function namespaceCoordinates(parameters) {
48
49
  export function platformRoutingKvTitle(name) {
49
50
  return `astrale-deployment-routing-${digestPrefix(name)}`;
50
51
  }
52
+ /**
53
+ * The Analytics Engine dataset of a platform namespace's calls: `astrale_deployment_calls_<hex32>`,
54
+ * with the `<hex32>` of its routing KV title (AM-131). The platform dispatcher (SV6) writes one data
55
+ * point `{ indexes: [label] }` to it for each call that runs a deployment; a listing reads the last
56
+ * call of each label from it, with the Analytics Engine SQL API, and never writes to it.
57
+ */
58
+ export function platformCallsDataset(name) {
59
+ return `astrale_deployment_calls_${digestPrefix(name)}`;
60
+ }
51
61
  /** The KV key whose presence activates a label at the dispatcher (AM-89): its record. */
52
62
  export function recordKey(label) {
53
63
  return `record:${label}`;
@@ -1,6 +1,8 @@
1
1
  import type { Placement, ReleaseDeployContext, ReleaseDeployResult, RetainContext } from '@astrale-os/sdk/deployment/adapter';
2
2
  import type { SigningIdentityMaterial } from '../identity/material.js';
3
3
  import type { CloudflareParams } from '../params.js';
4
+ import type { NamespaceCoordinates } from './coordinates.js';
5
+ import { CloudflareApi } from './api.js';
4
6
  /** The effects of namespace mode, replaceable in tests. */
5
7
  export interface NamespaceDependencies {
6
8
  /** Reaches the Cloudflare API, and the deployment URL for readiness. */
@@ -47,3 +49,8 @@ export declare function deployNamespaceRelease(parameters: CloudflareParams, con
47
49
  * `published`, which the platform's preview expiry never retires. Nothing else changes.
48
50
  */
49
51
  export declare function retainNamespaceDeployment(parameters: CloudflareParams, context: RetainContext, dependencies: NamespaceDependencies): Promise<void>;
52
+ /**
53
+ * The Cloudflare API of the namespace's account, with the token of `CLOUDFLARE_API_TOKEN`; `token`
54
+ * says what the operation needs of it.
55
+ */
56
+ export declare function cloudflareApi(coordinates: NamespaceCoordinates, signal: AbortSignal, dependencies: Pick<NamespaceDependencies, 'fetch' | 'environment' | 'pause' | 'apiBase'>, token?: string): CloudflareApi;
@@ -364,19 +364,24 @@ function expectedBinding(binding) {
364
364
  function byName(left, right) {
365
365
  return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
366
366
  }
367
- function cloudflareApi(coordinates, signal, dependencies) {
367
+ /** What a deploy asks of `CLOUDFLARE_API_TOKEN`, as a missing token is told. */
368
+ const DEPLOY_TOKEN = 'a token that edits the scripts of the platform namespace and the values of its routing KV';
369
+ /**
370
+ * The Cloudflare API of the namespace's account, with the token of `CLOUDFLARE_API_TOKEN`; `token`
371
+ * says what the operation needs of it.
372
+ */
373
+ export function cloudflareApi(coordinates, signal, dependencies, token = DEPLOY_TOKEN) {
368
374
  const accountId = coordinates.account ?? dependencies.environment.CLOUDFLARE_ACCOUNT_ID;
369
375
  if (accountId === undefined || !/^[0-9a-f]{32}$/u.test(accountId)) {
370
376
  throw new TypeError('Namespace mode needs the Cloudflare account: set `namespace.account` or CLOUDFLARE_ACCOUNT_ID.');
371
377
  }
372
- const token = dependencies.environment.CLOUDFLARE_API_TOKEN;
373
- if (token === undefined || token.length === 0 || token.trim() !== token) {
374
- throw new TypeError('Namespace mode needs CLOUDFLARE_API_TOKEN: a token that edits the scripts of the platform ' +
375
- 'namespace and the values of its routing KV.');
378
+ const secret = dependencies.environment.CLOUDFLARE_API_TOKEN;
379
+ if (secret === undefined || secret.length === 0 || secret.trim() !== secret) {
380
+ throw new TypeError(`Namespace mode needs CLOUDFLARE_API_TOKEN: ${token}.`);
376
381
  }
377
382
  return new CloudflareApi({
378
383
  accountId,
379
- token,
384
+ token: secret,
380
385
  fetch: dependencies.fetch,
381
386
  signal,
382
387
  ...(dependencies.apiBase === undefined ? {} : { base: dependencies.apiBase }),
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Namespace mode of adapter-cloudflare (D4, CT33 `canonical`): each release of an Environment
3
3
  * becomes one immutable script in a platform dispatch namespace, named by its deployment label
4
- * and activated by its record in the namespace's routing KV (AM-131).
4
+ * and activated by its record in the namespace's routing KV (AM-131). Its deployments are listed
5
+ * from that KV, with their last calls from the namespace's calls dataset (AM-133).
5
6
  */
6
7
  export { CloudflareApi, CloudflareApiError, CLOUDFLARE_API_BASE } from './api.js';
8
+ export { LAST_CALL_QUERY_LABELS, lastCallsQuery, PREVIEW_RETENTION_MILLISECONDS, previewExpiresAt, readLastCalls, } from './calls.js';
7
9
  export { ASSETS_BINDING, configureNamespace, ROUTER_BINDING, VERSION_METADATA_BINDING, } from './configure.js';
8
- export { admitNamespace, DIRECT_PARAMETERS, namespaceCoordinates, platformRoutingKvTitle, recordKey, retirementKey, summaryKey, type NamespaceCoordinates, } from './coordinates.js';
10
+ export { admitNamespace, DIRECT_PARAMETERS, namespaceCoordinates, platformCallsDataset, platformRoutingKvTitle, recordKey, retirementKey, summaryKey, type NamespaceCoordinates, } from './coordinates.js';
9
11
  export { deployNamespaceRelease, placeNamespace, retainNamespaceDeployment, type NamespaceDependencies, } from './deploy-release.js';
12
+ export { listNamespaceDeployments } from './list.js';
10
13
  export { ACTIVATION_READINESS_MS, ROTATION_READINESS_MS, waitForNamespaceReadiness, } from './readiness.js';
11
14
  export { NamespaceRefusal, PlatformRouting, sameRelease } from './routing.js';
12
15
  export { ARTIFACT_TAG_PREFIX, deploymentBindings, deploymentSettings, deploymentTags, KEY_TAG_PREFIX, LINE_TAG_PREFIX, SERVICES_TAG, versionUuid, type NamespaceScript, type ScriptBinding, } from './script.js';
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Namespace mode of adapter-cloudflare (D4, CT33 `canonical`): each release of an Environment
3
3
  * becomes one immutable script in a platform dispatch namespace, named by its deployment label
4
- * and activated by its record in the namespace's routing KV (AM-131).
4
+ * and activated by its record in the namespace's routing KV (AM-131). Its deployments are listed
5
+ * from that KV, with their last calls from the namespace's calls dataset (AM-133).
5
6
  */
6
7
  export { CloudflareApi, CloudflareApiError, CLOUDFLARE_API_BASE } from './api.js';
8
+ export { LAST_CALL_QUERY_LABELS, lastCallsQuery, PREVIEW_RETENTION_MILLISECONDS, previewExpiresAt, readLastCalls, } from './calls.js';
7
9
  export { ASSETS_BINDING, configureNamespace, ROUTER_BINDING, VERSION_METADATA_BINDING, } from './configure.js';
8
- export { admitNamespace, DIRECT_PARAMETERS, namespaceCoordinates, platformRoutingKvTitle, recordKey, retirementKey, summaryKey, } from './coordinates.js';
10
+ export { admitNamespace, DIRECT_PARAMETERS, namespaceCoordinates, platformCallsDataset, platformRoutingKvTitle, recordKey, retirementKey, summaryKey, } from './coordinates.js';
9
11
  export { deployNamespaceRelease, placeNamespace, retainNamespaceDeployment, } from './deploy-release.js';
12
+ export { listNamespaceDeployments } from './list.js';
10
13
  export { ACTIVATION_READINESS_MS, ROTATION_READINESS_MS, waitForNamespaceReadiness, } from './readiness.js';
11
14
  export { NamespaceRefusal, PlatformRouting, sameRelease } from './routing.js';
12
15
  export { ARTIFACT_TAG_PREFIX, deploymentBindings, deploymentSettings, deploymentTags, KEY_TAG_PREFIX, LINE_TAG_PREFIX, SERVICES_TAG, versionUuid, } from './script.js';
@@ -0,0 +1,33 @@
1
+ import type { ListContext, ListedDeployment } from '@astrale-os/sdk/deployment/adapter';
2
+ import type { CloudflareParams } from '../params.js';
3
+ import type { NamespaceDependencies } from './deploy-release.js';
4
+ /**
5
+ * The deployments of one Environment that the platform namespace keeps: one per `summary:<label>`
6
+ * of the Environment's line in the routing KV found by its derived title (AM-131), in label order.
7
+ * Nothing is written, to the KV, to a script or to the calls dataset.
8
+ *
9
+ * Each deployment carries what its summary says of it, its record as `record:<label>` stores it,
10
+ * unadmitted (the listing admits it, and lists one it refuses from the summary's facts), and its
11
+ * call target, the namespace and the script SV6's per-script tooling takes: there is no Services
12
+ * node to call. The routing KV decides what the dispatcher serves, so it also decides the state: a
13
+ * summary without `record:<label>` is a deployment uploaded but not activated (`deploying`, without
14
+ * a record, since the platform keeps none: the dispatcher answers 503 for it), and an `active`
15
+ * summary whose label carries the tombstone `retirement:<label>` is `retiring`, with the tombstone's
16
+ * value as its retirement (the dispatcher answers 410), until the retirement tooling records it in
17
+ * the summary.
18
+ *
19
+ * The records and tombstones are listed before the summaries: a deployer writes a label's summary
20
+ * before the record that activates it (AM-131), so a deploy that lands during the listing is seen
21
+ * whole or not at all. A record whose summary the key listing does not show yet (Workers KV lists
22
+ * are eventually consistent) has its summary read directly.
23
+ *
24
+ * Nothing on the platform namespace records a last call (AM-133): each one is read from the calls
25
+ * dataset, for every activated label (the dispatcher counts no other), and an active preview expires
26
+ * 30 days after the later of its activation (`createdAt`) and that call. Without Account Analytics
27
+ * Read, both are omitted, never taken from anything else. A summary that does not decode, a record
28
+ * whose summary cannot be read, a tombstone that is neither `expired` nor `withdrawn`, a key of the
29
+ * line that names no deployment, or a last-calls query that fails or answers other rows, fails the
30
+ * listing of the Environment: a row is never printed without its facts, and no activated deployment
31
+ * is left out.
32
+ */
33
+ export declare function listNamespaceDeployments(parameters: CloudflareParams, context: ListContext, dependencies: NamespaceDependencies): Promise<readonly ListedDeployment[]>;
@@ -0,0 +1,139 @@
1
+ import { deploymentLine } from '@astrale-os/sdk/deployment/address';
2
+ import { previewExpiresAt, readLastCalls } from './calls.js';
3
+ import { namespaceCoordinates } from './coordinates.js';
4
+ import { cloudflareApi } from './deploy-release.js';
5
+ import { NamespaceRefusal, PlatformRouting } from './routing.js';
6
+ /** What a listing asks of `CLOUDFLARE_API_TOKEN`, as a missing token is told. */
7
+ const LIST_TOKEN = 'a token that reads the values of its routing KV, and with Account Analytics Read the calls ' +
8
+ 'dataset its last calls come from';
9
+ /**
10
+ * The deployments of one Environment that the platform namespace keeps: one per `summary:<label>`
11
+ * of the Environment's line in the routing KV found by its derived title (AM-131), in label order.
12
+ * Nothing is written, to the KV, to a script or to the calls dataset.
13
+ *
14
+ * Each deployment carries what its summary says of it, its record as `record:<label>` stores it,
15
+ * unadmitted (the listing admits it, and lists one it refuses from the summary's facts), and its
16
+ * call target, the namespace and the script SV6's per-script tooling takes: there is no Services
17
+ * node to call. The routing KV decides what the dispatcher serves, so it also decides the state: a
18
+ * summary without `record:<label>` is a deployment uploaded but not activated (`deploying`, without
19
+ * a record, since the platform keeps none: the dispatcher answers 503 for it), and an `active`
20
+ * summary whose label carries the tombstone `retirement:<label>` is `retiring`, with the tombstone's
21
+ * value as its retirement (the dispatcher answers 410), until the retirement tooling records it in
22
+ * the summary.
23
+ *
24
+ * The records and tombstones are listed before the summaries: a deployer writes a label's summary
25
+ * before the record that activates it (AM-131), so a deploy that lands during the listing is seen
26
+ * whole or not at all. A record whose summary the key listing does not show yet (Workers KV lists
27
+ * are eventually consistent) has its summary read directly.
28
+ *
29
+ * Nothing on the platform namespace records a last call (AM-133): each one is read from the calls
30
+ * dataset, for every activated label (the dispatcher counts no other), and an active preview expires
31
+ * 30 days after the later of its activation (`createdAt`) and that call. Without Account Analytics
32
+ * Read, both are omitted, never taken from anything else. A summary that does not decode, a record
33
+ * whose summary cannot be read, a tombstone that is neither `expired` nor `withdrawn`, a key of the
34
+ * line that names no deployment, or a last-calls query that fails or answers other rows, fails the
35
+ * listing of the Environment: a row is never printed without its facts, and no activated deployment
36
+ * is left out.
37
+ */
38
+ export async function listNamespaceDeployments(parameters, context, dependencies) {
39
+ const coordinates = namespaceCoordinates(parameters);
40
+ const api = cloudflareApi(coordinates, context.signal, dependencies, LIST_TOKEN);
41
+ const routing = await PlatformRouting.open(api, coordinates.routingKvTitle);
42
+ const line = deploymentLine({
43
+ origin: context.origin,
44
+ environment: context.environment,
45
+ addressing: 'readable',
46
+ });
47
+ const activated = new Set(await routing.labels('record', line));
48
+ const withdrawn = new Set(await routing.labels('retirement', line));
49
+ const summarized = new Set(await routing.labels('summary', line));
50
+ const labels = [...new Set([...summarized, ...activated])].sort();
51
+ const listed = [];
52
+ for (const label of labels) {
53
+ const summary = await routing.summary(label);
54
+ if (summary === undefined) {
55
+ throw new NamespaceRefusal('summary-invalid', summarized.has(label)
56
+ ? `The summary of ${label} disappeared while the namespace was listed; list again.`
57
+ : // The deployer writes a summary before the record that activates its label (AM-131).
58
+ `The routing KV activates ${label} without a summary; list again, and if it ` +
59
+ 'persists, the summary its deployer writes first is lost.');
60
+ }
61
+ let record = {};
62
+ if (activated.has(label)) {
63
+ const stored = await routing.storedRecord(label);
64
+ if (stored === undefined) {
65
+ throw new NamespaceRefusal('record-invalid', `The record of ${label} disappeared while the namespace was listed; list again.`);
66
+ }
67
+ record = { record: stored };
68
+ }
69
+ else if (summary.state === 'active') {
70
+ // A deploy may write its summary and then its record between the record and the summary
71
+ // key listings: read the record of an active summary-only label directly, so a deployment
72
+ // activated during the listing is listed as the dispatcher serves it.
73
+ const stored = await routing.storedRecord(label);
74
+ if (stored !== undefined) {
75
+ activated.add(label);
76
+ record = { record: stored };
77
+ }
78
+ }
79
+ let retirement = {};
80
+ if (withdrawn.has(label) && summary.state === 'active') {
81
+ const tombstone = await routing.retirement(label);
82
+ if (tombstone !== undefined) {
83
+ if (tombstone !== 'expired' && tombstone !== 'withdrawn') {
84
+ throw new NamespaceRefusal('retirement-invalid', `The tombstone of ${label} is neither expired nor withdrawn.`);
85
+ }
86
+ retirement = { retirement: tombstone };
87
+ }
88
+ }
89
+ listed.push({ summary, ...record, ...retirement });
90
+ }
91
+ const called = await readLastCalls(api, coordinates.callsDataset, labels.filter((label) => activated.has(label)));
92
+ return Object.freeze(listed.map((deployment) => {
93
+ const { summary } = deployment;
94
+ const state = observedState(summary, 'record' in deployment, deployment.retirement);
95
+ const lastCall = called?.get(summary.label);
96
+ return Object.freeze({
97
+ id: summary.id,
98
+ url: summary.url,
99
+ label: summary.label,
100
+ line: summary.line,
101
+ owner: summary.owner,
102
+ state: state.state,
103
+ retention: summary.retention,
104
+ createdAt: summary.createdAt,
105
+ ...(lastCall === undefined || lastCall === null ? {} : { lastCallAt: lastCall }),
106
+ ...(called !== undefined && state.state === 'active' && summary.retention === 'preview'
107
+ ? { expiresAt: previewExpiresAt(summary.createdAt, lastCall ?? null) }
108
+ : {}),
109
+ ...(summary.retiredAt === undefined ? {} : { retiredAt: summary.retiredAt }),
110
+ ...(state.retirement === undefined ? {} : { retirement: state.retirement }),
111
+ signingKeyId: summary.signingKeyId,
112
+ ...('record' in deployment ? { record: deployment.record } : {}),
113
+ callTarget: Object.freeze({
114
+ kind: 'namespace',
115
+ namespace: coordinates.name,
116
+ script: summary.label,
117
+ }),
118
+ });
119
+ }));
120
+ }
121
+ /**
122
+ * The state of a platform deployment as the dispatcher serves it: its summary's, except that an
123
+ * `active` summary is `retiring` once the tombstone `retirement:<label>` retires the label (with the
124
+ * tombstone's value), and else `deploying` until `record:<label>` activates it. The dispatcher reads
125
+ * the tombstone first: a tombstoned label answers 410 with or without a record.
126
+ */
127
+ function observedState(summary, activated, tombstone) {
128
+ if (summary.state !== 'active') {
129
+ return {
130
+ state: summary.state,
131
+ ...(summary.retirement === undefined ? {} : { retirement: summary.retirement }),
132
+ };
133
+ }
134
+ if (tombstone !== undefined)
135
+ return { state: 'retiring', retirement: tombstone };
136
+ if (!activated)
137
+ return { state: 'deploying' };
138
+ return { state: 'active' };
139
+ }
@@ -2,7 +2,7 @@ import type { DeploymentRecordV1, PlatformDeploymentSummaryV1 } from '@astrale-o
2
2
  import type { CloudflareApi } from './api.js';
3
3
  /** Why namespace mode refused before or instead of a write at the host. */
4
4
  export declare class NamespaceRefusal extends Error {
5
- readonly reason: 'not-provisioned' | 'deployment-conflict' | 'deployment-retired' | 'summary-invalid' | 'record-invalid';
5
+ readonly reason: 'not-provisioned' | 'deployment-conflict' | 'deployment-retired' | 'summary-invalid' | 'record-invalid' | 'retirement-invalid';
6
6
  constructor(reason: NamespaceRefusal['reason'], message: string);
7
7
  }
8
8
  /**
@@ -21,6 +21,19 @@ export declare class PlatformRouting {
21
21
  retirement(label: string): Promise<string | undefined>;
22
22
  /** The record a label was activated with, admitted exactly, or undefined when not activated. */
23
23
  record(label: string): Promise<DeploymentRecordV1 | undefined>;
24
+ /**
25
+ * The labels of one line that hold a key of `kind` (`summary:<label>`, `record:<label>` or
26
+ * `retirement:<label>`), from one listing of the keys under `<kind>:<line>-`. A key there that
27
+ * names no deployment label is refused; one whose label is on another line, whose readable part
28
+ * happens to extend this one, is not this line's.
29
+ */
30
+ labels(kind: 'summary' | 'record' | 'retirement', line: string): Promise<readonly string[]>;
31
+ /**
32
+ * The record a label was activated with, as stored and never admitted: its JSON value (JSON
33
+ * `null` included), or its text when that is not JSON; undefined when the label has no record. A
34
+ * listing admits it.
35
+ */
36
+ storedRecord(label: string): Promise<unknown>;
24
37
  /** The summary of a label, admitted exactly, or undefined when it has none. */
25
38
  summary(label: string): Promise<PlatformDeploymentSummaryV1 | undefined>;
26
39
  /** Write one summary as its canonical text and read it back. */
@@ -1,4 +1,4 @@
1
- import { acceptDeploymentRecord, acceptPlatformDeploymentSummary, encodePlatformDeploymentSummary, } from '@astrale-os/sdk/deployment/address';
1
+ import { acceptDeploymentRecord, acceptPlatformDeploymentSummary, encodePlatformDeploymentSummary, parseDeploymentLabel, } from '@astrale-os/sdk/deployment/address';
2
2
  import { json } from '@astrale-os/sdk/value';
3
3
  import { recordKey, retirementKey, summaryKey } from './coordinates.js';
4
4
  /** Why namespace mode refused before or instead of a write at the host. */
@@ -64,6 +64,45 @@ export class PlatformRouting {
64
64
  }
65
65
  return record;
66
66
  }
67
+ /**
68
+ * The labels of one line that hold a key of `kind` (`summary:<label>`, `record:<label>` or
69
+ * `retirement:<label>`), from one listing of the keys under `<kind>:<line>-`. A key there that
70
+ * names no deployment label is refused; one whose label is on another line, whose readable part
71
+ * happens to extend this one, is not this line's.
72
+ */
73
+ async labels(kind, line) {
74
+ const prefix = `${kind}:`;
75
+ const labels = [];
76
+ for (const name of await this.api.kvKeys(this.kv, `${prefix}${line}-`)) {
77
+ const label = name.slice(prefix.length);
78
+ let parts;
79
+ try {
80
+ parts = parseDeploymentLabel(label);
81
+ }
82
+ catch {
83
+ throw new NamespaceRefusal(`${kind}-invalid`, `The routing KV holds ${name}, which names no deployment label.`);
84
+ }
85
+ if (parts.line === line)
86
+ labels.push(label);
87
+ }
88
+ return labels;
89
+ }
90
+ /**
91
+ * The record a label was activated with, as stored and never admitted: its JSON value (JSON
92
+ * `null` included), or its text when that is not JSON; undefined when the label has no record. A
93
+ * listing admits it.
94
+ */
95
+ async storedRecord(label) {
96
+ const text = await this.api.kvGet(this.kv, recordKey(label));
97
+ if (text === undefined)
98
+ return undefined;
99
+ try {
100
+ return JSON.parse(text);
101
+ }
102
+ catch {
103
+ return text;
104
+ }
105
+ }
67
106
  /** The summary of a label, admitted exactly, or undefined when it has none. */
68
107
  async summary(label) {
69
108
  const text = await this.api.kvGet(this.kv, summaryKey(label));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/adapter-cloudflare",
3
- "version": "0.5.0-beta.169",
3
+ "version": "0.5.0-beta.170",
4
4
  "description": "Deploy an Astrale Domain through Cloudflare Workers",
5
5
  "keywords": [
6
6
  "adapter",
@@ -61,7 +61,7 @@
61
61
  "registry": "https://registry.npmjs.org/"
62
62
  },
63
63
  "dependencies": {
64
- "@astrale-os/sdk": "0.6.0-beta.14",
64
+ "@astrale-os/sdk": "0.6.0-beta.15",
65
65
  "@noble/hashes": "2.3.0",
66
66
  "@restatedev/restate-sdk-cloudflare-workers": "1.17.0",
67
67
  "hono": "^4.6.20",