@serve.zone/interfaces 32.56.0 → 32.58.0

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.
@@ -0,0 +1,127 @@
1
+ import { readExactDataRecord } from '../private/dataproperties.js';
2
+ /**
3
+ * The refusal a controller states when it does not take a node's runtime session request.
4
+ *
5
+ * It travels as `TypedResponseError.errorData` on `registerPalletRuntimeSession` and on every
6
+ * session-scoped read and report a node sends its controller. `code` is a frozen wire value.
7
+ * `momentaryReasons` are the controller's own momentary state, not a judgement of the node: a
8
+ * registering node waits and offers the same registration again, keeping everything it runs.
9
+ * Every other reason, and an error that carries no such payload, is a verdict — on a registration
10
+ * it ends the node's session. Momentary against verdict governs only whether a node defers a
11
+ * registration: a node retries its session reads and reports regardless of the reason, so the
12
+ * reasons a report is refused with (`state-conflict`, `*-write-conflict`, a capacity limit such as
13
+ * `log-intake-saturated`) are unchanged by this contract.
14
+ *
15
+ * Who states which reason today, on a registration (Cloudly ≥33.19.1, `isBudgetRefusal`):
16
+ * - Cloudly states `controller-deadline` when the request ran out of
17
+ * `runtimeSessionRequestBounds.controllerBudgetMs`, and `controller-unavailable` when it is
18
+ * stopping. Every other failure, a transient one of its own included, is answered without a
19
+ * payload and so reads as a verdict.
20
+ * - No relay states a refusal of its own yet.
21
+ *
22
+ * The rule producers adopt:
23
+ * - Cloudly states `controller-unavailable` on a registration for every transient failure of its
24
+ * own: its database connection lost or unavailable, a registration write conflict, a race with a
25
+ * credential being applied.
26
+ * - A relay states only `controller-unavailable`, and only when it cannot carry the request or its
27
+ * answer: no Cloudly link, or its own bound (`runtimeSessionRequestBounds`) ran out. Every
28
+ * answer Cloudly gave it forwards untouched; it never states a verdict or `controller-deadline`.
29
+ * - A verdict names the check that refused: a revoked or inactive credential, a deleted, retiring
30
+ * or foreign node, an invalid body. It never carries a momentary reason.
31
+ *
32
+ * The payload is exactly `{ code, reason }`. Pallet 36.3.14 reads the two members and tolerates
33
+ * others, but every reader built on `readRuntimeSessionRefusal` refuses a payload with any other
34
+ * member, and a refused payload reads as no refusal — a momentary refusal would turn into a
35
+ * verdict. A producer therefore never adds a member without gating it on the peer's spoken
36
+ * interfaces version.
37
+ */
38
+ export const runtimeSessionRefusalContract = Object.freeze({
39
+ code: 'runtime-session-report-refused',
40
+ momentaryReasons: Object.freeze(['controller-deadline', 'controller-unavailable']),
41
+ /**
42
+ * A reason is one or more words of lowercase ASCII letters joined by single hyphens, at most
43
+ * this many characters. It names a check, never a value: no digit, case, dot, colon or other
44
+ * separator can carry an identifier, address, digest, count or credential across.
45
+ */
46
+ maximumReasonLength: 64,
47
+ });
48
+ const isRefusalReason = (valueArg) => (typeof valueArg === 'string'
49
+ && valueArg.length <= runtimeSessionRefusalContract.maximumReasonLength
50
+ && /^[a-z]+(?:-[a-z]+)*$/.test(valueArg));
51
+ const reasonRule = 'must be lowercase letter words joined by single hyphens, at most '
52
+ + `${runtimeSessionRefusalContract.maximumReasonLength} characters`;
53
+ /** Validates one runtime session refusal and names every reason it is refused. */
54
+ export const validateRuntimeSessionRefusal = (valueArg, pathArg = 'runtime session refusal') => {
55
+ const refusal = readExactDataRecord(valueArg, ['code', 'reason']);
56
+ if (refusal === null) {
57
+ return [`${pathArg} must use its exact schema`];
58
+ }
59
+ const errors = [];
60
+ if (refusal.code !== runtimeSessionRefusalContract.code) {
61
+ errors.push(`${pathArg} code must be ${runtimeSessionRefusalContract.code}`);
62
+ }
63
+ if (!isRefusalReason(refusal.reason)) {
64
+ errors.push(`${pathArg} reason ${reasonRule}`);
65
+ }
66
+ return errors;
67
+ };
68
+ /** The payload a controller or relay answers a refused request with; throws on a non-word reason. */
69
+ export const buildRuntimeSessionRefusal = (reasonArg) => {
70
+ if (!isRefusalReason(reasonArg)) {
71
+ throw new Error(`runtime session refusal reason ${reasonRule}`);
72
+ }
73
+ return Object.freeze({ code: runtimeSessionRefusalContract.code, reason: reasonArg });
74
+ };
75
+ /**
76
+ * The refusal inside an error payload, or `null` when the value is not exactly one.
77
+ *
78
+ * It reads a value that survived serialization, the way `protocol.readProtocolRefusal` does: own
79
+ * data properties only, exactly `code` and `reason`, nothing that runs.
80
+ */
81
+ export const readRuntimeSessionRefusal = (valueArg) => {
82
+ const refusal = readExactDataRecord(valueArg, ['code', 'reason']);
83
+ if (refusal === null || validateRuntimeSessionRefusal(refusal).length > 0) {
84
+ return null;
85
+ }
86
+ return Object.freeze({ code: runtimeSessionRefusalContract.code, reason: refusal.reason });
87
+ };
88
+ /**
89
+ * The momentary reason an error payload states, or `null` when it is a verdict: any other reason,
90
+ * a payload that is not exactly a refusal, or no payload at all.
91
+ */
92
+ export const readMomentaryRuntimeSessionRefusal = (valueArg) => {
93
+ const refusal = readRuntimeSessionRefusal(valueArg);
94
+ if (refusal === null) {
95
+ return null;
96
+ }
97
+ const momentary = runtimeSessionRefusalContract.momentaryReasons
98
+ .find((reasonArg) => reasonArg === refusal.reason);
99
+ return momentary ?? null;
100
+ };
101
+ /**
102
+ * How long each hop may hold a node's runtime session request, so the hop nearest the decision
103
+ * answers first and the node always reads a stated refusal rather than its own timeout.
104
+ */
105
+ export const runtimeSessionRequestBounds = Object.freeze({
106
+ /**
107
+ * Cloudly's budget for every node request, registration included. When it runs out Cloudly
108
+ * answers `controller-deadline`.
109
+ */
110
+ controllerBudgetMs: 6_000,
111
+ /**
112
+ * A relay's bound on a registration, from its arrival at the relay to the answer, including any
113
+ * wait for the relay's own Cloudly link. It exceeds `controllerBudgetMs` by more than a second so
114
+ * Cloudly's own `controller-deadline` reaches the node through the relay, and stays below
115
+ * TypedSocket's 10 s connection-restore deadline (`TYPEDSOCKET_MAX_CONNECTION_RESTORE_TIMEOUT_MS`,
116
+ * which a client can only lower) so the node reads an answer before its restore hook times out.
117
+ * When it runs out the relay answers `controller-unavailable`.
118
+ */
119
+ relayRegistrationBudgetMs: 9_000,
120
+ /**
121
+ * A relay's bound on forwarding a session read or report over its established Cloudly link.
122
+ * It exceeds `controllerBudgetMs` so Cloudly's answer arrives first; when it runs out the relay
123
+ * answers `controller-unavailable`.
124
+ */
125
+ relaySessionForwardMs: 8_000,
126
+ });
127
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicnVudGltZXNlc3Npb25yZWZ1c2FsLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHMvZGF0YS9ydW50aW1lc2Vzc2lvbnJlZnVzYWwudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLG1CQUFtQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFFbkU7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0dBbUNHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQztJQUN6RCxJQUFJLEVBQUUsZ0NBQWdDO0lBQ3RDLGdCQUFnQixFQUFFLE1BQU0sQ0FBQyxNQUFNLENBQUMsQ0FBQyxxQkFBcUIsRUFBRSx3QkFBd0IsQ0FBVSxDQUFDO0lBQzNGOzs7O09BSUc7SUFDSCxtQkFBbUIsRUFBRSxFQUFFO0NBQ2YsQ0FBQyxDQUFDO0FBY1osTUFBTSxlQUFlLEdBQUcsQ0FBQyxRQUFpQixFQUFzQixFQUFFLENBQUMsQ0FDakUsT0FBTyxRQUFRLEtBQUssUUFBUTtPQUN6QixRQUFRLENBQUMsTUFBTSxJQUFJLDZCQUE2QixDQUFDLG1CQUFtQjtPQUNwRSxzQkFBc0IsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLENBQ3pDLENBQUM7QUFFRixNQUFNLFVBQVUsR0FBRyxtRUFBbUU7TUFDbEYsR0FBRyw2QkFBNkIsQ0FBQyxtQkFBbUIsYUFBYSxDQUFDO0FBRXRFLGtGQUFrRjtBQUNsRixNQUFNLENBQUMsTUFBTSw2QkFBNkIsR0FBRyxDQUMzQyxRQUFpQixFQUNqQixPQUFPLEdBQUcseUJBQXlCLEVBQ3pCLEVBQUU7SUFDWixNQUFNLE9BQU8sR0FBRyxtQkFBbUIsQ0FBQyxRQUFRLEVBQUUsQ0FBQyxNQUFNLEVBQUUsUUFBUSxDQUFDLENBQUMsQ0FBQztJQUNsRSxJQUFJLE9BQU8sS0FBSyxJQUFJLEVBQUUsQ0FBQztRQUNyQixPQUFPLENBQUMsR0FBRyxPQUFPLDRCQUE0QixD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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serve.zone/interfaces",
3
- "version": "32.56.0",
3
+ "version": "32.58.0",
4
4
  "private": false,
5
5
  "description": "Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.",
6
6
  "exports": {
@@ -30,8 +30,8 @@
30
30
  "@git.zone/tsbuild": "^6.0.2",
31
31
  "@git.zone/tsdoc": "^3.0.2",
32
32
  "@git.zone/tsrun": "^3.0.3",
33
- "@git.zone/tstest": "^7.2.1",
34
- "@types/node": "26.6.4",
33
+ "@git.zone/tstest": "^8.0.0",
34
+ "@types/node": "26.6.5",
35
35
  "typescript": "^6.0.3"
36
36
  },
37
37
  "files": [
package/readme.md CHANGED
@@ -1452,6 +1452,64 @@ carries the same offer, judged in the same order before the JWT, and answers wit
1452
1452
  the server's own offer. Both registration methods reject HTTP transport. Sensitive requests and registry
1453
1453
  responses must be excluded from hooks, logs, diagnostics and durable journals.
1454
1454
 
1455
+ ### Runtime Session Refusals
1456
+
1457
+ A controller that does not take a node's registration, session read or report
1458
+ answers `TypedResponseError` with `data.IRuntimeSessionRefusal` as its
1459
+ `errorData`: exactly `{ code: 'runtime-session-report-refused', reason }`
1460
+ (`data.runtimeSessionRefusalContract.code`, a frozen wire value). The reason is a
1461
+ word naming the check that refused, never a value: lowercase ASCII letter words
1462
+ joined by single hyphens, at most 64 characters
1463
+ (`runtimeSessionRefusalContract.maximumReasonLength`), so no identifier, address,
1464
+ digest, count or credential can cross in it.
1465
+
1466
+ Two reasons are the controller's own momentary state
1467
+ (`runtimeSessionRefusalContract.momentaryReasons`): a registering node waits and
1468
+ offers the same registration again, keeping what it runs. Every other reason,
1469
+ and an error without this payload, is a verdict; on a registration it ends the
1470
+ node's session. Momentary against verdict governs only whether a node defers a
1471
+ registration: a node retries its session reads and reports regardless of the
1472
+ reason, so the reasons a report is refused with (`state-conflict`,
1473
+ `*-write-conflict`, a capacity limit such as `log-intake-saturated`) are
1474
+ unchanged by this contract.
1475
+
1476
+ On a registration today (Cloudly ≥33.19.1), Cloudly attaches the payload only
1477
+ when the refusal is no judgement of the node: its budget ran out
1478
+ (`controller-deadline`) or it is stopping (`controller-unavailable`). Every
1479
+ other failure, a transient one of its own included, is answered without a
1480
+ payload and so reads as a verdict. No relay states a refusal of its own yet.
1481
+
1482
+ The rule producers adopt:
1483
+
1484
+ | Reason | Stated by | When |
1485
+ | --- | --- | --- |
1486
+ | `controller-deadline` | Cloudly | the request ran out of `controllerBudgetMs` (stated today) |
1487
+ | `controller-unavailable` | Cloudly | it is stopping (stated today); any other transient failure of its own on a registration: database connection lost or unavailable, a registration write conflict, a race with a credential being applied |
1488
+ | `controller-unavailable` | relay | only when it cannot carry the request or its answer: no Cloudly link, or its own bound ran out |
1489
+ | any other word | Cloudly | a verdict: revoked or inactive credential, deleted, retiring or foreign node, invalid body |
1490
+
1491
+ A relay forwards every answer Cloudly gave untouched and states nothing but
1492
+ `controller-unavailable` itself. `data.buildRuntimeSessionRefusal(reason)` builds
1493
+ the payload and throws on a reason that is not a word;
1494
+ `data.validateRuntimeSessionRefusal` names every violation;
1495
+ `data.readRuntimeSessionRefusal(errorData)` answers the refusal or `null`, and
1496
+ `data.readMomentaryRuntimeSessionRefusal(errorData)` answers the momentary reason
1497
+ or `null` for a verdict.
1498
+
1499
+ The readers are exact-key. Pallet 36.3.14 reads `code` and `reason` and tolerates
1500
+ other members, but a reader built on this contract refuses a payload carrying
1501
+ any other member, and a refused payload reads as no refusal, which turns a
1502
+ momentary refusal into a verdict. A producer never adds a member without gating
1503
+ it on the peer's spoken interfaces version.
1504
+
1505
+ `data.runtimeSessionRequestBounds` orders the hops so the one nearest the
1506
+ decision answers first and a node always reads a stated refusal rather than its
1507
+ own timeout: Cloudly's `controllerBudgetMs` (6 s, every node request including
1508
+ registration); a relay's `relayRegistrationBudgetMs` (9 s from arrival to
1509
+ answer, more than a second beyond Cloudly's budget and below TypedSocket's 10 s
1510
+ connection-restore deadline); and a relay's `relaySessionForwardMs` (8 s) for a
1511
+ session read or report forwarded over its established Cloudly link.
1512
+
1455
1513
  ### Assignment-Bound Runtime Secrets
1456
1514
 
1457
1515
  `@serve.zone/interfaces/runtime` exports the Pallet assignment-secret contract.
@@ -3611,7 +3669,7 @@ The catalog validator judges every supported version config in full; no consumer
3611
3669
 
3612
3670
  - `image`, then the port model (`appstore.validateAppStoreVersionPortModel`): `port` or `targetPorts` is required, and `targetPorts`, `domains` and `publicPortMappings` are read as exactly their types and resolved through `data.normalizeServicePortConfig`.
3613
3671
  - `containerArgs` and the environment declarations.
3614
- - Storage (`appstore.validateAppStoreVersionStorage`): the exact config member set (`appstore.appStoreVersionConfigKeys`), `platformRequirements`, `storageClasses` and `storageRequests` (declared together, every class used), `backupExclusions`, the storage feature ids `requiresFeatures` must name, portable legacy `volumes`, and every environment, secret-file and mount destination used once.
3672
+ - Storage (`appstore.validateAppStoreVersionStorage`): the exact config member set (`appstore.appStoreVersionConfigKeys`), `platformRequirements`, `storageClasses` and `storageRequests` (declared together, every class used), `backupExclusions`, `databaseBackupExclusions`, the storage feature ids `requiresFeatures` must name, portable legacy `volumes`, and every environment, secret-file and mount destination used once.
3615
3673
  - Platform OIDC, then Docker `publishedPorts` (`appstore.validateAppStorePublishedPorts`).
3616
3674
 
3617
3675
  A version declares data it keeps only for observability, such as workload logs, as `backupExclusions` (`IAppStoreBackupExclusion[]`): a backup leaves those object keys out, and a restore neither brings them back nor removes them. Each entry names one objectStorage request of the version (`storageRequest`: an explicit request id, or `legacy-s3` when the version declares `platformRequirements.s3`; a filesystem request is refused), 1 to 16 `keyPrefixes` and a one-line `reason` an operator reads:
@@ -3630,6 +3688,29 @@ const cloudlyVersion: Partial<appstore.IAppStoreVersionConfig> = {
3630
3688
 
3631
3689
  Entries are in strictly increasing `storageRequest` order with each request at most once. A key prefix is 1 to 512 UTF-8 bytes, ends in `/`, does not start with `/`, has no empty, `.` or `..` segment and no control character; the prefixes of an entry are strictly increasing and none lies inside another. `appstore.appStoreBackupExclusionLimits` states the bounds, and `appstore.validateAppStoreBackupExclusionKeyPrefixes(value, label?)` is the one prefix-list rule: the version-config check applies it to each entry, and a reader of a list carrying such prefixes elsewhere, such as a backup manifest, judges it there too (an empty list means valid, otherwise one `Invalid <label>` reason). A version declaring any entry names `storage.object-storage.backup-exclusions` (`appStoreStorageFeatureIds.objectStorageBackupExclusions`) in `requiresFeatures`, so a host that does not leave the keys out of its backups lists the version without installing it, and an older host never reads the member.
3632
3690
 
3691
+ Database collections of the same kind, such as a workload log ledger, are declared as `databaseBackupExclusions` (`IAppStoreDatabaseBackupExclusion[]`): a backup exports those collections with their indexes and without their documents, so a restore brings them back empty. Each entry names one database the version provisions (`database`: `mongodb`, which requires `platformRequirements.mongodb: true`, the Corestore `database` resource), 1 to 64 `collections` and a one-line `reason`:
3692
+
3693
+ ```typescript
3694
+ const cloudlyVersion: Partial<appstore.IAppStoreVersionConfig> = {
3695
+ platformRequirements: { mongodb: true, s3: true },
3696
+ databaseBackupExclusions: [{
3697
+ database: 'mongodb',
3698
+ collections: [
3699
+ 'cloudly_runtime_log_attempts',
3700
+ 'cloudly_runtime_log_batches',
3701
+ 'cloudly_runtime_log_captures',
3702
+ 'cloudly_runtime_log_inline_payloads',
3703
+ 'cloudly_runtime_log_node_discards',
3704
+ 'cloudly_runtime_log_queue',
3705
+ ],
3706
+ reason: 'Workload log ledger; a restore brings back state, not past logs.',
3707
+ }],
3708
+ requiresFeatures: [appstore.appStoreStorageFeatureIds.databaseBackupExclusions],
3709
+ };
3710
+ ```
3711
+
3712
+ Entries are in strictly increasing `database` order with each database at most once. A collection name is 1 to 128 UTF-8 bytes, does not start with `.` or `system.` (a system collection is never excluded), and has no `/`, `\`, `$`, control character (C0, DEL or C1) or unpaired surrogate; the names of an entry are strictly increasing. `appstore.appStoreDatabaseBackupExclusionLimits` states the bounds, and `appstore.validateAppStoreDatabaseBackupExclusionCollections(value, label?)` is the one collection-list rule: the version-config check applies it to each entry, and a reader of such a list elsewhere, such as Corestore's export request, judges it there too. A version declaring any entry names `storage.database.backup-exclusions` (`appStoreStorageFeatureIds.databaseBackupExclusions`) in `requiresFeatures`, so a host whose database backups cannot leave documents out lists the version without installing it, and an older host never reads the member.
3713
+
3633
3714
  Each storage request is judged on its own by the storage contract behind `platform.normalizeStorageRequest`: members, capacity quantities and limits, protection, delivery and mount path, with that contract's messages and bounds, and a legacy volume's mount path as the request it becomes. The version config adds what only holds between requests: each request's class is declared, of its kind and backs a declared limit (`hardQuota`), backup or snapshots; ids, environment keys, secret files and mounts are used once; the legacy names `legacyFilesystem`, `legacyObjectStorage` and `legacy-s3` stay reserved. Two App Store narrowings apply on top: a request id is a stable lowercase identifier (it shares a namespace with the derived `legacy-volume-*` ids), and a file delivery targets one file directly below `/run/secrets`.
3634
3715
 
3635
3716
  Each of these validators stops at the first violation and answers it as a one-element list starting `Invalid <label>`, so a config is refused for the same reason everywhere.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/interfaces',
6
- version: '32.56.0',
6
+ version: '32.58.0',
7
7
  description: 'Shared TypeScript interfaces and TypedRequest contracts for the serve.zone ecosystem.'
8
8
  }
@@ -87,6 +87,7 @@ const versionConfigKeyList = [
87
87
  'storageClasses',
88
88
  'storageRequests',
89
89
  'backupExclusions',
90
+ 'databaseBackupExclusions',
90
91
  'publishedPorts',
91
92
  'platformRequirements',
92
93
  'platformOidc',
@@ -64,6 +64,11 @@ export const appStoreStorageFeatureIds = {
64
64
  objectStorageFile: 'storage.object-storage.file',
65
65
  /** The version declares `backupExclusions`; a consumer naming it leaves those keys out of backups. */
66
66
  objectStorageBackupExclusions: 'storage.object-storage.backup-exclusions',
67
+ /**
68
+ * The version declares `databaseBackupExclusions`; a consumer naming it exports those collections
69
+ * without their documents.
70
+ */
71
+ databaseBackupExclusions: 'storage.database.backup-exclusions',
67
72
  } as const;
68
73
 
69
74
  export type TAppStoreStorageFeatureId =
@@ -158,6 +163,40 @@ export interface IAppStoreBackupExclusion {
158
163
  reason: string;
159
164
  }
160
165
 
166
+ /**
167
+ * A database a version can declare backup exclusions for: `mongodb`, the document database
168
+ * `platformRequirements.mongodb` provisions (a Corestore `database` resource).
169
+ */
170
+ export type TAppStoreBackupExclusionDatabase = 'mongodb';
171
+
172
+ /** The bounds of `IAppStoreVersionConfig.databaseBackupExclusions`. */
173
+ export const appStoreDatabaseBackupExclusionLimits = Object.freeze({
174
+ /** At most one entry per database; a version provisions at most one (`mongodb`). */
175
+ maximumEntries: 1,
176
+ maximumCollections: 64,
177
+ /** Keeps a whole collection list inside one 16 KiB control document. */
178
+ maximumCollectionNameBytes: 128,
179
+ maximumReasonLength: 512,
180
+ } as const);
181
+
182
+ /**
183
+ * Collections of one database that the app keeps only for observability, such as its workload
184
+ * log ledger. A backup exports them with their indexes and without their documents; a restore
185
+ * therefore brings them back empty.
186
+ */
187
+ export interface IAppStoreDatabaseBackupExclusion {
188
+ /** The database: `mongodb`, which the version provisions through `platformRequirements.mongodb`. */
189
+ database: TAppStoreBackupExclusionDatabase;
190
+ /**
191
+ * 1 to 64 collection names in strictly increasing UTF-16 code-unit order. Each is 1 to 128 UTF-8
192
+ * bytes, does not start with `.` or `system.`, and has no `/`, `\`, `$`, control character or
193
+ * unpaired surrogate.
194
+ */
195
+ collections: string[];
196
+ /** One operator-readable line, e.g. "Workload log ledger; a restore brings back state, not past logs." */
197
+ reason: string;
198
+ }
199
+
161
200
  export interface IAppStoreInlineSource {
162
201
  type: 'inline';
163
202
  }
@@ -440,6 +479,13 @@ export interface IAppStoreVersionConfig {
440
479
  * `appStoreStorageFeatureIds.objectStorageBackupExclusions` in `requiresFeatures`.
441
480
  */
442
481
  backupExclusions?: IAppStoreBackupExclusion[];
482
+ /**
483
+ * Database collections the app keeps only for observability, in strictly increasing `database`
484
+ * order with each database at most once. A backup exports them without their documents; a
485
+ * restore brings them back empty. A version declaring any entry names
486
+ * `appStoreStorageFeatureIds.databaseBackupExclusions` in `requiresFeatures`.
487
+ */
488
+ databaseBackupExclusions?: IAppStoreDatabaseBackupExclusion[];
443
489
  publishedPorts?: IAppStorePublishedPort[];
444
490
  platformRequirements?: IAppStorePlatformRequirements;
445
491
  /**
@@ -15,8 +15,8 @@
15
15
  * `platform.normalizeStorageRequest`), reporting through this module's rejection. What this module
16
16
  * adds are the rules a request can only break inside a version config: its class is declared and of
17
17
  * its kind, the class backs a declared limit or protection, ids and destinations are used once, the
18
- * legacy names stay reserved, a backup exclusion names an object-storage request, and the storage
19
- * feature ids are named.
18
+ * legacy names stay reserved, a backup exclusion names an object-storage request, a database backup
19
+ * exclusion names a provisioned database, and the storage feature ids are named.
20
20
  */
21
21
  import { normalizeServiceAbsolutePath, validateServiceContainerArgs } from '../data/service.js';
22
22
  import { normalizeServicePortConfig } from '../data/serviceports.js';
@@ -34,13 +34,16 @@ import { createStorageContractReaders } from '../private/storagecontract.js';
34
34
  import type { TObjectStorageDelivery } from '../platform/storage.js';
35
35
  import {
36
36
  appStoreBackupExclusionLimits,
37
+ appStoreDatabaseBackupExclusionLimits,
37
38
  appStoreStorageFeatureIds,
38
39
  validateAppStoreEnvironmentDeclarations,
39
40
  validateAppStoreVersionPlatformOidc,
40
41
  } from './types.js';
41
42
  import type {
42
43
  IAppStoreBackupExclusion,
44
+ IAppStoreDatabaseBackupExclusion,
43
45
  IAppStoreVersionConfig,
46
+ TAppStoreBackupExclusionDatabase,
44
47
  TAppStoreStorageRequest,
45
48
  } from './types.js';
46
49
 
@@ -56,6 +59,7 @@ const versionConfigMemberList = [
56
59
  'storageClasses',
57
60
  'storageRequests',
58
61
  'backupExclusions',
62
+ 'databaseBackupExclusions',
59
63
  'publishedPorts',
60
64
  'platformRequirements',
61
65
  'platformOidc',
@@ -101,6 +105,12 @@ const legacyObjectStorageRequestId = 'legacy-s3';
101
105
  const knownStorageFeatureIds: readonly string[] = Object.values(appStoreStorageFeatureIds);
102
106
  const platformRequirementKeys = ['mongodb', 's3', 'clickhouse', 'valkey', 'mariadb', 'mail'] as const;
103
107
  const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
108
+ /** The C0 and C1 controls and DEL: what the database engine refuses in a namespace component. */
109
+ const collectionNameControlPattern = /[\u0000-\u001f\u007f-\u009f]/;
110
+ /** Each database a version can declare exclusions for, with the platform requirement provisioning it. */
111
+ const backupExclusionDatabaseRequirements: Readonly<Record<TAppStoreBackupExclusionDatabase, 'mongodb'>> = {
112
+ mongodb: 'mongodb',
113
+ };
104
114
  const utf8Encoder = new TextEncoder();
105
115
 
106
116
  /** Thrown inside this module only; every exported validator answers it as its result. */
@@ -612,6 +622,7 @@ function checkFeatureDeclaration(
612
622
  requiresFeaturesArg: unknown,
613
623
  explicitRequestsArg: readonly TAppStoreStorageRequest[],
614
624
  backupExclusionsArg: readonly IAppStoreBackupExclusion[],
625
+ databaseBackupExclusionsArg: readonly IAppStoreDatabaseBackupExclusion[],
615
626
  labelArg: string,
616
627
  ): void {
617
628
  const derivedFeatures = new Set<string>();
@@ -628,6 +639,9 @@ function checkFeatureDeclaration(
628
639
  if (backupExclusionsArg.length > 0) {
629
640
  derivedFeatures.add(appStoreStorageFeatureIds.objectStorageBackupExclusions);
630
641
  }
642
+ if (databaseBackupExclusionsArg.length > 0) {
643
+ derivedFeatures.add(appStoreStorageFeatureIds.databaseBackupExclusions);
644
+ }
631
645
 
632
646
  const declaredEntries = requiresFeaturesArg === undefined
633
647
  ? []
@@ -669,6 +683,19 @@ const hasUnpairedSurrogate = (valueArg: string): boolean => {
669
683
  return false;
670
684
  };
671
685
 
686
+ /** An exclusion's `reason`: one trimmed line an operator reads. */
687
+ function checkBackupExclusionReason(valueArg: unknown, maximumLengthArg: number, entryLabelArg: string): string {
688
+ if (typeof valueArg !== 'string'
689
+ || valueArg.length === 0
690
+ || valueArg.length > maximumLengthArg
691
+ || valueArg.trim() !== valueArg
692
+ || controlCharacterPattern.test(valueArg)
693
+ || hasUnpairedSurrogate(valueArg)) {
694
+ return reject(`Invalid ${entryLabelArg}.reason: must be one trimmed line of 1 to ${maximumLengthArg} characters`);
695
+ }
696
+ return valueArg;
697
+ }
698
+
672
699
  /** One object key prefix: a relative key path that names a whole subtree. */
673
700
  function checkBackupExclusionKeyPrefix(valueArg: unknown, labelArg: string): string {
674
701
  if (typeof valueArg !== 'string') return reject(`Invalid ${labelArg}: expected a string`);
@@ -741,16 +768,99 @@ function checkBackupExclusions(valueArg: unknown, labelArg: string): IAppStoreBa
741
768
  previousRequest = storageRequest;
742
769
 
743
770
  const keyPrefixes = checkBackupExclusionKeyPrefixes(entry.keyPrefixes, `${entryLabel}.keyPrefixes`);
771
+ return { storageRequest, keyPrefixes, reason: checkBackupExclusionReason(reason, maximumReasonLength, entryLabel) };
772
+ });
773
+ }
774
+
775
+ /**
776
+ * One collection name a database backup exclusion carries: a name the database engine stores
777
+ * (`database` resources run on NoSQLDB, a MongoDB-compatible engine) that is not a system collection.
778
+ */
779
+ function checkDatabaseBackupExclusionCollection(valueArg: unknown, labelArg: string): string {
780
+ const { maximumCollectionNameBytes } = appStoreDatabaseBackupExclusionLimits;
781
+ if (typeof valueArg !== 'string') return reject(`Invalid ${labelArg}: expected a string`);
782
+ if (collectionNameControlPattern.test(valueArg) || hasUnpairedSurrogate(valueArg)) {
783
+ reject(`Invalid ${labelArg}: must not contain a control character or an unpaired surrogate`);
784
+ }
785
+ const byteLength = utf8Encoder.encode(valueArg).byteLength;
786
+ if (byteLength < 1 || byteLength > maximumCollectionNameBytes) {
787
+ reject(`Invalid ${labelArg}: must be 1 to ${maximumCollectionNameBytes} UTF-8 bytes`);
788
+ }
789
+ if (valueArg.startsWith('.')) reject(`Invalid ${labelArg}: must not start with '.'`);
790
+ if (valueArg.startsWith('system.')) reject(`Invalid ${labelArg}: must not name a 'system.' collection`);
791
+ if (/[/\\$]/.test(valueArg)) reject(`Invalid ${labelArg}: must not contain '/', '\\' or '$'`);
792
+ return valueArg;
793
+ }
794
+
795
+ /**
796
+ * One exclusion's collection list: 1 to 64 names, strictly increasing.
797
+ * `validateAppStoreDatabaseBackupExclusionCollections` exports it, so every reader of a collection
798
+ * list judges it by this one rule.
799
+ */
800
+ function checkDatabaseBackupExclusionCollections(valueArg: unknown, labelArg: string): string[] {
801
+ const { maximumCollections } = appStoreDatabaseBackupExclusionLimits;
802
+ const collectionEntries = readDataElements(valueArg, maximumCollections);
803
+ if (collectionEntries === null || collectionEntries.length === 0) {
804
+ return reject(`Invalid ${labelArg}: expected an array of 1 to ${maximumCollections} collection names`);
805
+ }
806
+ const collections: string[] = [];
807
+ collectionEntries.forEach((collectionArg, indexArg) => {
808
+ const collection = checkDatabaseBackupExclusionCollection(collectionArg, `${labelArg}[${indexArg}]`);
809
+ const previousCollection = collections[collections.length - 1];
810
+ if (previousCollection !== undefined && compareWireTokens(previousCollection, collection) >= 0) {
811
+ reject(`Invalid ${labelArg}: must be strictly increasing`);
812
+ }
813
+ collections.push(collection);
814
+ });
815
+ return collections;
816
+ }
744
817
 
745
- if (typeof reason !== 'string'
746
- || reason.length === 0
747
- || reason.length > maximumReasonLength
748
- || reason.trim() !== reason
749
- || controlCharacterPattern.test(reason)
750
- || hasUnpairedSurrogate(reason)) {
751
- reject(`Invalid ${entryLabel}.reason: must be one trimmed line of 1 to ${maximumReasonLength} characters`);
818
+ /**
819
+ * The members of each database backup exclusion, its collections and the entry order. Whether the
820
+ * version provisions the database an entry names is judged with its platform requirements
821
+ * (`checkDatabaseBackupExclusionTargets`).
822
+ */
823
+ function checkDatabaseBackupExclusions(valueArg: unknown, labelArg: string): IAppStoreDatabaseBackupExclusion[] {
824
+ if (valueArg === undefined) return [];
825
+ const { maximumEntries, maximumReasonLength } = appStoreDatabaseBackupExclusionLimits;
826
+ const entries = readDataElements(valueArg, maximumEntries);
827
+ if (entries === null || entries.length === 0) {
828
+ return reject(`Invalid ${labelArg}: databaseBackupExclusions must contain between 1 and ${maximumEntries} entries`);
829
+ }
830
+ let previousDatabase: string | null = null;
831
+ return entries.map((entryArg, indexArg): IAppStoreDatabaseBackupExclusion => {
832
+ const entryLabel = `${labelArg} databaseBackupExclusions[${indexArg}]`;
833
+ const entry = readRecord(entryArg, entryLabel);
834
+ assertExactKeys(entry, ['database', 'collections', 'reason'], entryLabel);
835
+ const { database } = entry;
836
+ if (typeof database !== 'string' || !Object.hasOwn(backupExclusionDatabaseRequirements, database)) {
837
+ return reject(`Invalid ${entryLabel}.database: expected one of ${Object.keys(backupExclusionDatabaseRequirements).join(', ')}`);
838
+ }
839
+ if (previousDatabase !== null && compareWireTokens(previousDatabase, database) >= 0) {
840
+ reject(previousDatabase === database
841
+ ? `Invalid ${labelArg}: databaseBackupExclusions name database '${database}' more than once`
842
+ : `Invalid ${labelArg}: databaseBackupExclusions must be in strictly increasing database order`);
843
+ }
844
+ previousDatabase = database;
845
+ return {
846
+ database: database as TAppStoreBackupExclusionDatabase,
847
+ collections: checkDatabaseBackupExclusionCollections(entry.collections, `${entryLabel}.collections`),
848
+ reason: checkBackupExclusionReason(entry.reason, maximumReasonLength, entryLabel),
849
+ };
850
+ });
851
+ }
852
+
853
+ /** Each database backup exclusion names a database the version provisions. */
854
+ function checkDatabaseBackupExclusionTargets(
855
+ exclusionsArg: readonly IAppStoreDatabaseBackupExclusion[],
856
+ platformRequirementsArg: Record<string, unknown>,
857
+ labelArg: string,
858
+ ): void {
859
+ exclusionsArg.forEach((exclusionArg, indexArg) => {
860
+ const requirement = backupExclusionDatabaseRequirements[exclusionArg.database];
861
+ if (platformRequirementsArg[requirement] !== true) {
862
+ reject(`Invalid ${labelArg} databaseBackupExclusions[${indexArg}].database: '${exclusionArg.database}' requires platformRequirements.${requirement}`);
752
863
  }
753
- return { storageRequest, keyPrefixes, reason };
754
864
  });
755
865
  }
756
866
 
@@ -842,7 +952,15 @@ function checkStorage(configArg: Record<string, unknown>, labelArg: string): voi
842
952
  labelArg,
843
953
  );
844
954
  const backupExclusions = checkBackupExclusions(configArg.backupExclusions, labelArg);
845
- checkFeatureDeclaration(configArg.requiresFeatures, explicitRequests, backupExclusions, labelArg);
955
+ const databaseBackupExclusions = checkDatabaseBackupExclusions(configArg.databaseBackupExclusions, labelArg);
956
+ checkFeatureDeclaration(
957
+ configArg.requiresFeatures,
958
+ explicitRequests,
959
+ backupExclusions,
960
+ databaseBackupExclusions,
961
+ labelArg,
962
+ );
963
+ checkDatabaseBackupExclusionTargets(databaseBackupExclusions, platformRequirements, labelArg);
846
964
  const legacyS3 = platformRequirements.s3 === true;
847
965
  if (legacyS3 && explicitRequests.some((requestArg) => requestArg.kind === 'objectStorage')) {
848
966
  reject(`Invalid ${labelArg}: platformRequirements.s3 cannot coexist with explicit objectStorage requests`);
@@ -923,8 +1041,9 @@ export const validateAppStorePublishedPorts = (publishedPortsArg: unknown, label
923
1041
 
924
1042
  /**
925
1043
  * The config's member set and its storage: legacy `volumes`, `storageClasses`, `storageRequests`,
926
- * `platformRequirements`, `backupExclusions`, the storage feature ids `requiresFeatures` must name,
927
- * and every environment, secret-file and mount destination being used once.
1044
+ * `platformRequirements`, `backupExclusions`, `databaseBackupExclusions`, the storage feature ids
1045
+ * `requiresFeatures` must name, and every environment, secret-file and mount destination being used
1046
+ * once.
928
1047
  */
929
1048
  export const validateAppStoreVersionStorage = (configArg: unknown, labelArg = defaultLabel): string[] =>
930
1049
  firstRejection(() => checkStorage(readConfig(configArg, labelArg), labelArg));
@@ -941,6 +1060,18 @@ export const validateAppStoreBackupExclusionKeyPrefixes = (
941
1060
  labelArg = 'keyPrefixes',
942
1061
  ): string[] => firstRejection(() => checkBackupExclusionKeyPrefixes(valueArg, labelArg));
943
1062
 
1063
+ /**
1064
+ * One database backup exclusion's `collections`, the rule `validateAppStoreVersionStorage` applies
1065
+ * to each `IAppStoreDatabaseBackupExclusion`: 1 to 64 names in strictly increasing UTF-16
1066
+ * code-unit order, each 1 to 128 UTF-8 bytes, not starting with `.` or `system.`, without `/`,
1067
+ * `\`, `$`, a control character or an unpaired surrogate. A reader of a list that carries such
1068
+ * names elsewhere, such as Corestore's export request or a backup's provenance, judges it here.
1069
+ */
1070
+ export const validateAppStoreDatabaseBackupExclusionCollections = (
1071
+ valueArg: unknown,
1072
+ labelArg = 'collections',
1073
+ ): string[] => firstRejection(() => checkDatabaseBackupExclusionCollections(valueArg, labelArg));
1074
+
944
1075
  /**
945
1076
  * Every member rule of a version config, in the order a host applies them: image, port model,
946
1077
  * container arguments, environment declarations, storage, platform OIDC, published ports.
package/ts/data/index.ts CHANGED
@@ -66,6 +66,7 @@ export * from './runtimestorageclaim.js';
66
66
  export * from './runtimelocalstorageclaim.js';
67
67
  export * from './runtimelocalvolumearchive.js';
68
68
  export * from './runtimesession.js';
69
+ export * from './runtimesessionrefusal.js';
69
70
  export * from './runtimeterminalreceipt.js';
70
71
  export * from './secret.js';
71
72
  export * from './baremetal.js';