@decohere/config 0.0.1-dev.116 → 0.0.1-dev.118

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 ADDED
@@ -0,0 +1,40 @@
1
+ # Config
2
+
3
+ Application configuration for Decohere: data sources that feed the configuration service, starting with YAML files read from the environment.
4
+
5
+ ## YAML files
6
+
7
+ `YamlDataSource` reads two files from `CONFIG_DIR` (default `config`):
8
+
9
+ 1. `config.yaml`: production, and the base for every environment.
10
+ 2. `config.<NODE_ENV>.yaml`, if it exists, such as `config.dev.yaml`. An unset `NODE_ENV` means `production`, so a missing variable never loads development settings.
11
+
12
+ ### How the files merge
13
+
14
+ The environment file only states what it changes:
15
+
16
+ - **Objects** merge key by key.
17
+ - **Lists of named entries** merge by `name`. Every entry is a mapping with a string `name`, as in `database.dataSources`. An entry with a name the base already has merges into that entry, and a new name is added at the end. The base keeps its order. A file that repeats a name in such a list is rejected, because the merge couldn't tell the entries apart.
18
+ - **Any other list**, and a scalar, replaces the earlier value. An empty list (`[]`) clears it.
19
+
20
+ ```yaml
21
+ # config.yaml
22
+ database:
23
+ dataSources:
24
+ - name: api
25
+ host: ${API_DB_HOST}
26
+ password: ${API_DB_PASSWORD}
27
+ synchronize: false
28
+
29
+ # config.dev.yaml: only what development changes
30
+ database:
31
+ dataSources:
32
+ - name: api
33
+ synchronize: true
34
+ ```
35
+
36
+ ### Placeholders
37
+
38
+ `${NAME}` reads an environment variable, and `${NAME:default}` gives it a default. Placeholders are resolved **after** the merge. That way an environment file can replace a production `${VAR}` without the variable being set. Placeholders still unset after the merge fail together, and the error names every variable and where it was used. A variable set to the text `null` means no value.
39
+
40
+ Secrets come only from the process environment ([ADR 0002](../../docs/adr/0002-secrets-from-the-environment.md)). Nothing here reads a `.env` file; a CLI loads one only with an explicit `--env-file`.
@@ -38,16 +38,61 @@ function resolveConfigEnv(nodeEnv) {
38
38
  function isTree(value) {
39
39
  return value != null && typeof value === "object" && !Array.isArray(value);
40
40
  }
41
- /** Objects merge key by key; a list or a scalar in the later file replaces the earlier value. */ function merge(base, override) {
41
+ /** A non-empty list whose every entry is a mapping with a string `name`, such as `dataSources`. */ function isNamedList(value) {
42
+ return Array.isArray(value) && value.length > 0 && value.every((item)=>isTree(item) && typeof item.name === "string");
43
+ }
44
+ /** Entries with the same name merge in place; new names are added at the end. */ function mergeNamedLists(base, override) {
45
+ const merged = [
46
+ ...base
47
+ ];
48
+ for (const entry of override){
49
+ const index = merged.findIndex((item)=>item.name === entry.name);
50
+ if (index === -1) {
51
+ merged.push(entry);
52
+ } else {
53
+ merged[index] = merge(merged[index], entry);
54
+ }
55
+ }
56
+ return merged;
57
+ }
58
+ /**
59
+ * Objects merge key by key, and so do lists of named entries (matched by `name`), so an
60
+ * environment file only states what it changes. Any other list, and a scalar, in the later file
61
+ * replaces the earlier value.
62
+ */ function merge(base, override) {
42
63
  const merged = {
43
64
  ...base
44
65
  };
45
66
  for (const [key, value] of Object.entries(override)){
46
67
  const existing = merged[key];
47
- merged[key] = isTree(existing) && isTree(value) ? merge(existing, value) : value;
68
+ if (isTree(existing) && isTree(value)) {
69
+ merged[key] = merge(existing, value);
70
+ } else if (isNamedList(existing) && isNamedList(value)) {
71
+ merged[key] = mergeNamedLists(existing, value);
72
+ } else {
73
+ merged[key] = value;
74
+ }
48
75
  }
49
76
  return merged;
50
77
  }
78
+ /** A named list that repeats a name can't be merged by name, so say which file and where. */ function assertUniqueNames(value, source, path) {
79
+ if (isNamedList(value)) {
80
+ const seen = new Set();
81
+ for (const item of value){
82
+ if (seen.has(item.name)) {
83
+ throw new _errors.ConfigurationError(`Config file ${source}: ${path} lists the name "${String(item.name)}" more than once`);
84
+ }
85
+ seen.add(item.name);
86
+ }
87
+ }
88
+ if (Array.isArray(value)) {
89
+ value.forEach((item, index)=>assertUniqueNames(item, source, `${path}[${index}]`));
90
+ } else if (isTree(value)) {
91
+ for (const [key, item] of Object.entries(value)){
92
+ assertUniqueNames(item, source, path === "" ? key : `${path}.${key}`);
93
+ }
94
+ }
95
+ }
51
96
  function resolveString(value, path, missing) {
52
97
  let hasPlaceholder = false;
53
98
  const resolved = value.replace(PLACEHOLDER, (_match, name, fallback)=>{
@@ -98,6 +143,7 @@ function parseSource({ name, text }) {
98
143
  if (!isTree(parsed)) {
99
144
  throw new _errors.ConfigurationError(`Config file ${name} must contain a mapping at the top level`);
100
145
  }
146
+ assertUniqueNames(parsed, name, "");
101
147
  return parsed;
102
148
  }
103
149
  function resolveYamlConfig(sources) {
@@ -17,16 +17,61 @@ const FILE_EXT = "yaml";
17
17
  function isTree(value) {
18
18
  return value != null && typeof value === "object" && !Array.isArray(value);
19
19
  }
20
- /** Objects merge key by key; a list or a scalar in the later file replaces the earlier value. */ function merge(base, override) {
20
+ /** A non-empty list whose every entry is a mapping with a string `name`, such as `dataSources`. */ function isNamedList(value) {
21
+ return Array.isArray(value) && value.length > 0 && value.every((item)=>isTree(item) && typeof item.name === "string");
22
+ }
23
+ /** Entries with the same name merge in place; new names are added at the end. */ function mergeNamedLists(base, override) {
24
+ const merged = [
25
+ ...base
26
+ ];
27
+ for (const entry of override){
28
+ const index = merged.findIndex((item)=>item.name === entry.name);
29
+ if (index === -1) {
30
+ merged.push(entry);
31
+ } else {
32
+ merged[index] = merge(merged[index], entry);
33
+ }
34
+ }
35
+ return merged;
36
+ }
37
+ /**
38
+ * Objects merge key by key, and so do lists of named entries (matched by `name`), so an
39
+ * environment file only states what it changes. Any other list, and a scalar, in the later file
40
+ * replaces the earlier value.
41
+ */ function merge(base, override) {
21
42
  const merged = {
22
43
  ...base
23
44
  };
24
45
  for (const [key, value] of Object.entries(override)){
25
46
  const existing = merged[key];
26
- merged[key] = isTree(existing) && isTree(value) ? merge(existing, value) : value;
47
+ if (isTree(existing) && isTree(value)) {
48
+ merged[key] = merge(existing, value);
49
+ } else if (isNamedList(existing) && isNamedList(value)) {
50
+ merged[key] = mergeNamedLists(existing, value);
51
+ } else {
52
+ merged[key] = value;
53
+ }
27
54
  }
28
55
  return merged;
29
56
  }
57
+ /** A named list that repeats a name can't be merged by name, so say which file and where. */ function assertUniqueNames(value, source, path) {
58
+ if (isNamedList(value)) {
59
+ const seen = new Set();
60
+ for (const item of value){
61
+ if (seen.has(item.name)) {
62
+ throw new ConfigurationError(`Config file ${source}: ${path} lists the name "${String(item.name)}" more than once`);
63
+ }
64
+ seen.add(item.name);
65
+ }
66
+ }
67
+ if (Array.isArray(value)) {
68
+ value.forEach((item, index)=>assertUniqueNames(item, source, `${path}[${index}]`));
69
+ } else if (isTree(value)) {
70
+ for (const [key, item] of Object.entries(value)){
71
+ assertUniqueNames(item, source, path === "" ? key : `${path}.${key}`);
72
+ }
73
+ }
74
+ }
30
75
  function resolveString(value, path, missing) {
31
76
  let hasPlaceholder = false;
32
77
  const resolved = value.replace(PLACEHOLDER, (_match, name, fallback)=>{
@@ -77,6 +122,7 @@ function parseSource({ name, text }) {
77
122
  if (!isTree(parsed)) {
78
123
  throw new ConfigurationError(`Config file ${name} must contain a mapping at the top level`);
79
124
  }
125
+ assertUniqueNames(parsed, name, "");
80
126
  return parsed;
81
127
  }
82
128
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/config",
3
- "version": "0.0.1-dev.116",
3
+ "version": "0.0.1-dev.118",
4
4
  "description": "Database ORM abstractions and repository patterns built on TypeORM",
5
5
  "keywords": [
6
6
  "typescript",
@@ -25,9 +25,9 @@
25
25
  "test": "echo \"Error: no test specified\" && exit 1"
26
26
  },
27
27
  "dependencies": {
28
- "@decohere/core": "0.0.1-dev.116",
29
- "@decohere/crucible": "0.0.1-dev.116",
30
- "@decohere/http": "0.0.1-dev.116",
28
+ "@decohere/core": "0.0.1-dev.118",
29
+ "@decohere/crucible": "0.0.1-dev.118",
30
+ "@decohere/http": "0.0.1-dev.118",
31
31
  "yaml": "^2.9.1"
32
32
  },
33
33
  "devDependencies": {}