@serve.zone/interfaces 32.57.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.
@@ -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.