@toa.io/norm 1.0.0-alpha.32 → 1.0.0-alpha.321

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/package.json +9 -7
  3. package/src/.component/.expand/bridge.js +2 -6
  4. package/src/.component/.expand/entity.js +2 -9
  5. package/src/.component/.expand/events.js +2 -6
  6. package/src/.component/.expand/extensions.js +7 -9
  7. package/src/.component/.expand/index.js +7 -19
  8. package/src/.component/.expand/operations.js +2 -9
  9. package/src/.component/.expand/receivers.js +4 -7
  10. package/src/.component/.expand/version.js +69 -28
  11. package/src/.component/.normalize/entity.js +7 -0
  12. package/src/.component/.normalize/events.js +16 -4
  13. package/src/.component/.normalize/index.js +4 -9
  14. package/src/.component/.normalize/operations.js +13 -6
  15. package/src/.component/.normalize/receivers.js +4 -5
  16. package/src/.component/collapse.js +60 -9
  17. package/src/.component/defaults.js +5 -11
  18. package/src/.component/dependencies.js +14 -10
  19. package/src/.component/dereference.js +5 -49
  20. package/src/.component/expand.js +3 -9
  21. package/src/.component/extensions.js +9 -10
  22. package/src/.component/index.js +10 -21
  23. package/src/.component/merge.js +25 -15
  24. package/src/.component/migrations.js +57 -0
  25. package/src/.component/normalize.js +10 -6
  26. package/src/.component/schema.yaml +165 -73
  27. package/src/.component/validate.js +63 -17
  28. package/src/.context/.dependencies/connectors.js +5 -9
  29. package/src/.context/.dependencies/describe.js +4 -5
  30. package/src/.context/.dependencies/extensions.js +93 -20
  31. package/src/.context/.dependencies/index.js +3 -9
  32. package/src/.context/.dependencies/resolve.js +44 -7
  33. package/src/.context/complete.js +1 -5
  34. package/src/.context/converge.js +31 -0
  35. package/src/.context/dependencies.js +43 -6
  36. package/src/.context/dereference.js +11 -6
  37. package/src/.context/evict.js +38 -0
  38. package/src/.context/expand.js +11 -6
  39. package/src/.context/index.js +8 -15
  40. package/src/.context/normalize.js +9 -7
  41. package/src/.context/schema.yaml +183 -11
  42. package/src/.context/validate.js +11 -14
  43. package/src/component.js +33 -14
  44. package/src/context.js +44 -15
  45. package/src/definition.js +63 -0
  46. package/src/entity.js +15 -0
  47. package/src/index.js +7 -9
  48. package/src/manifest.js +154 -0
  49. package/src/map.js +24 -0
  50. package/src/shortcuts.js +11 -15
  51. package/test/component/collapse.fixtures.js +30 -39
  52. package/test/component/collapse.test.js +90 -10
  53. package/test/component/dependencies.test.js +16 -14
  54. package/test/component/dereference.fixtures.js +10 -98
  55. package/test/component/dereference.test.js +10 -14
  56. package/test/component/dummies/extension/index.js +1 -5
  57. package/test/component/dummies/migrations/conflicting/migrations/0001-one.yaml +2 -0
  58. package/test/component/dummies/migrations/conflicting/migrations/0001-one.yml +2 -0
  59. package/test/component/dummies/migrations/malformed/migrations/0001-one.yaml +1 -0
  60. package/test/component/dummies/migrations/ordered/migrations/0001-first.yml +4 -0
  61. package/test/component/dummies/migrations/ordered/migrations/0002-second.yaml +4 -0
  62. package/test/component/dummies/migrations/ordered/migrations/0010-third.json +1 -0
  63. package/test/component/dummies/migrations/stateless/migrations/0001-one.yaml +2 -0
  64. package/test/component/expand.fixtures.js +13 -13
  65. package/test/component/expand.test.js +17 -13
  66. package/test/component/migrations.test.js +62 -0
  67. package/test/component/normalize.fixtures.js +3 -7
  68. package/test/component/normalize.test.js +94 -28
  69. package/test/component/validate.fixtures.js +17 -15
  70. package/test/component/validate.test.js +289 -109
  71. package/test/context/complete.fixtures.js +2 -8
  72. package/test/context/complete.test.js +14 -8
  73. package/test/context/converge.test.js +57 -0
  74. package/test/context/dereference.fixtures.js +3 -9
  75. package/test/context/dereference.test.js +18 -7
  76. package/test/context/evict.fixtures.js +39 -0
  77. package/test/context/evict.test.js +73 -0
  78. package/test/context/expand.fixtures.js +1 -5
  79. package/test/context/expand.test.js +36 -8
  80. package/test/context/normalize.fixtures.js +2 -7
  81. package/test/context/normalize.test.js +33 -9
  82. package/test/context/validate.fixtures.js +1 -6
  83. package/test/context/validate.test.js +173 -35
  84. package/test/definition.test.js +35 -0
  85. package/test/dummies/bare/index.js +1 -0
  86. package/test/dummies/defined/definition.js +1 -0
  87. package/test/dummies/defined/index.js +1 -0
  88. package/test/dummies/defined/package.json +1 -0
  89. package/test/manifest.test.js +126 -0
  90. package/test/shortcuts.fixtures.js +4 -9
  91. package/test/shortcuts.test.js +17 -15
  92. package/test/system.test.js +31 -0
  93. package/types/component.d.ts +31 -4
  94. package/types/context/declaration.d.ts +34 -3
  95. package/types/context.d.ts +27 -13
  96. package/types/entity.d.ts +7 -0
  97. package/types/index.d.ts +18 -3
  98. package/src/.component/.expand/properties.js +0 -14
  99. package/src/.context/.dependencies/load.js +0 -36
  100. package/test/context/dependencies.load.test.js +0 -24
@@ -6,12 +6,10 @@ properties:
6
6
  version:
7
7
  type: string
8
8
  name:
9
- $ref: 'definitions#/definitions/label'
10
- description:
11
9
  type: string
12
- packages:
10
+ pattern: ^([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?)(-([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?))*$
11
+ description:
13
12
  type: string
14
- default: components/*
15
13
  build:
16
14
  type: object
17
15
  properties:
@@ -42,28 +40,202 @@ properties:
42
40
  minItems: 1
43
41
  items:
44
42
  type: string
45
- default:
46
- - linux/amd64
47
- - linux/arm/v7
48
- - linux/arm64
49
- required: [base, platforms]
43
+ services:
44
+ description: >
45
+ Where an extension service's image comes from. `build`, which absence means,
46
+ builds it into `registry.base` the way a composition is built. `published` uses
47
+ the image the extension ships, which is neither built nor pushed.
48
+ enum: [build, published]
49
+ # required: [base]
50
50
  compositions:
51
51
  type: array
52
52
  minItems: 1
53
53
  items:
54
54
  type: object
55
+ additionalProperties: false
55
56
  properties:
56
57
  name:
57
- $ref: 'definitions#/definitions/token'
58
+ type: string
59
+ pattern: ^[a-zA-Z]([a-zA-Z0-9]{1,31})?$
58
60
  image:
59
61
  type: string
60
62
  components:
61
63
  type: array
62
64
  minItems: 1
63
65
  items:
64
- $ref: 'definitions#/definitions/locator'
66
+ type: string
67
+ pattern: ^([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?)(\.([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?))$
68
+ services:
69
+ description: >
70
+ Extension services this composition runs in its own pod, named by shortcut or
71
+ package reference, rather than each as its own deployment. A service listed here
72
+ keeps its own Service and Ingress; what changes is the pod they select.
73
+ type: array
74
+ minItems: 1
75
+ items:
76
+ type: string
77
+ minLength: 1
78
+ resources:
79
+ $ref: '#/definitions/resources'
80
+ replicas:
81
+ type: integer
82
+ minimum: 1
65
83
  required: [name, components]
84
+ evicted:
85
+ description: >
86
+ What Toa does not deploy for this context, even where a composition lists it: it stays
87
+ part of the context, and is deployed by other means. A component named
88
+ here is in no pod, and what only it declared — an extension, a storage, a binding — is
89
+ not deployed either. A service named here gets no Deployment, Service or Ingress, and
90
+ no composition runs it.
91
+ type: object
92
+ additionalProperties: false
93
+ minProperties: 1
94
+ properties:
95
+ components:
96
+ type: array
97
+ minItems: 1
98
+ items:
99
+ type: string
100
+ pattern: ^([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?)(\.([a-zA-Z]+([_a-zA-Z0-9]*[a-zA-Z0-9]+)?))$
101
+ services:
102
+ type: array
103
+ minItems: 1
104
+ items:
105
+ type: string
106
+ minLength: 1
107
+ atomicity:
108
+ description: >
109
+ Where the replicas of a component decide together: who owns what, what they have spent
110
+ between them, and which of them holds a name while it works. Absent or unreachable,
111
+ nothing is owned and whoever asked stands down; metering and locking are refused.
112
+ type: object
113
+ additionalProperties: false
114
+ properties:
115
+ redis:
116
+ description: >
117
+ One address, or an odd number of them — independent servers, not the nodes of a
118
+ cluster. A lock is taken on a quorum of them; the registry and the meter use the
119
+ first, because each of them counts on a single key.
120
+ oneOf:
121
+ - type: string
122
+ - type: array
123
+ minItems: 1
124
+ items:
125
+ type: string
126
+ interval:
127
+ description: >
128
+ How often a replica registers, in milliseconds. It has to comfortably exceed a round
129
+ trip to Redis and the jitter around it, and nothing is owned until two consecutive
130
+ intervals have agreed — so this is also how long a replica owns nothing after it
131
+ joins, restarts or stalls.
132
+ type: integer
133
+ minimum: 100
134
+ required: [redis]
135
+ outbox:
136
+ type: object
137
+ additionalProperties: false
138
+ properties:
139
+ interval:
140
+ description: >
141
+ The pump's cycle, in milliseconds. One cycle reads what is unpublished, publishes it
142
+ and marks it. In steady state it reads nothing, so this is budgeted for how fast a rare
143
+ failure heals rather than for how fast events flow.
144
+ type: integer
145
+ minimum: 100
146
+ batch:
147
+ description: >
148
+ How many rows one read brings back. A page that comes back full is read on from,
149
+ so this bounds a read rather than a cycle.
150
+ type: integer
151
+ minimum: 1
152
+ retention:
153
+ description: >
154
+ Seconds a published row is kept as a change log before it expires. A row that never
155
+ made it out has no expiry and is never reaped.
156
+ type: integer
157
+ minimum: 0
158
+ inbox:
159
+ type: object
160
+ additionalProperties: false
161
+ properties:
162
+ retention:
163
+ description: >
164
+ Seconds a call is remembered, which is the window a duplicate of it is caught in. Budget
165
+ it for how late a duplicate can arrive — a redelivery, the attempts the broker library
166
+ makes of a message, whatever a client retries on — rather than for how long the change
167
+ matters. Every call of an operation that declares `once: true` is a record for this
168
+ long; an operation that states a number of its own is kept for that instead. Ten minutes
169
+ at least, because the broker alone repeats a call for about that long.
170
+ type: integer
171
+ minimum: 600
172
+ addressed:
173
+ type: object
174
+ additionalProperties: false
175
+ properties:
176
+ timeout:
177
+ description: >
178
+ Milliseconds an addressed call waits for its reply where it names no timeout of its own.
179
+ Nothing else ends a call to a process that has gone, so every addressed call waits for a
180
+ set time.
181
+ type: integer
182
+ minimum: 1
183
+ events:
184
+ description: >
185
+ Events of this context that are consumed outside it. An event is otherwise published only
186
+ where something in the context receives it.
187
+ type: array
188
+ items:
189
+ type: string
190
+ pattern: ^[a-zA-Z][a-zA-Z0-9]*\.[a-zA-Z][a-zA-Z0-9]*\.[a-zA-Z][a-zA-Z0-9]*$
191
+ mono:
192
+ type: object
193
+ additionalProperties: false
194
+ properties:
195
+ replicas:
196
+ type: integer
197
+ minimum: 1
198
+ resources:
199
+ $ref: '#/definitions/resources'
200
+ ingress:
201
+ type: object
202
+ additionalProperties: false
203
+ properties:
204
+ hosts:
205
+ type: array
206
+ minItems: 1
207
+ items:
208
+ type: string
209
+ class:
210
+ type: string
211
+ annotations:
212
+ type: object
213
+ default:
214
+ type: boolean
66
215
  annotations:
67
216
  type: object
217
+ resources:
218
+ $ref: '#/definitions/resources'
68
219
  required: [runtime, name, registry]
69
220
  additionalProperties: false
221
+
222
+ definitions:
223
+ # What a deployment may take. `null` deploys it without any, which is a decision
224
+ # rather than an omission — every deployment has to make one.
225
+ resources:
226
+ type: object
227
+ nullable: true
228
+ additionalProperties: false
229
+ properties:
230
+ cpu:
231
+ type: array
232
+ items:
233
+ type: string
234
+ minItems: 2
235
+ maxItems: 2
236
+ memory:
237
+ type: array
238
+ items:
239
+ type: string
240
+ minItems: 2
241
+ maxItems: 2
@@ -1,19 +1,16 @@
1
- 'use strict'
1
+ import { resolve } from 'node:path'
2
2
 
3
- const { resolve } = require('node:path')
3
+ import { readFileSync } from 'node:fs'
4
+ import { yaml } from '@toa.io/generic'
5
+ import * as schemas from '@toa.io/schemas'
4
6
 
5
- const { load } = require('@toa.io/yaml')
6
- const { Schema } = require('@toa.io/schema')
7
+ const path = resolve(import.meta.dirname, 'schema.yaml')
8
+ const object = yaml.load(readFileSync(path, 'utf8'))
9
+ const schema = schemas.schema(object)
7
10
 
8
- const path = resolve(__dirname, 'schema.yaml')
9
- const object = load.sync(path)
10
- const schema = new Schema(object)
11
-
12
- /**
13
- * @param {toa.norm.context.Declaration} context
14
- */
15
- const validate = (context) => {
16
- schema.validate(context)
11
+ export const validate = (context) => {
12
+ schema.validate(context, CONTEXT)
17
13
  }
18
14
 
19
- exports.validate = validate
15
+ /** The file a reader has to edit, since nothing else in the message names it. */
16
+ const CONTEXT = 'context.toa.yaml'
package/src/component.js CHANGED
@@ -1,28 +1,28 @@
1
- 'use strict'
1
+ import { join } from 'node:path'
2
2
 
3
- const { join } = require('node:path')
3
+ import { readFile } from 'node:fs/promises'
4
+ import { yaml as jsyaml } from '@toa.io/generic'
5
+ import { find } from '@toa.io/generic'
6
+ import { Locator } from '@toa.io/core'
4
7
 
5
- const { load: yaml } = require('@toa.io/yaml')
6
- const { directory: { find } } = require('@toa.io/filesystem')
7
- const { Locator } = require('@toa.io/core')
8
-
9
- const {
8
+ import {
10
9
  expand,
11
10
  merge,
11
+ migrations,
12
12
  validate,
13
13
  collapse,
14
14
  dereference,
15
15
  defaults,
16
16
  normalize,
17
17
  extensions
18
- } = require('./.component')
18
+ } from './.component/index.js'
19
19
 
20
- const component = async (path) => {
20
+ export const component = async (path) => {
21
21
  const manifest = await load(path)
22
22
 
23
- normalize(manifest, path)
24
- validate(manifest)
25
- extensions(manifest)
23
+ await normalize(manifest, path)
24
+ await validate(manifest)
25
+ await extensions(manifest)
26
26
 
27
27
  manifest.locator = new Locator(manifest.name, manifest.namespace)
28
28
 
@@ -33,14 +33,22 @@ const load = async (path, base, proto = false) => {
33
33
  if (base !== undefined) path = find(path, base, MANIFEST)
34
34
 
35
35
  const file = join(path, MANIFEST)
36
- const manifest = /** @type {toa.norm.Component} */ await yaml(file) ?? {}
36
+ const manifest = (await read(file)) ?? {}
37
37
 
38
38
  manifest.path = path
39
39
 
40
+ const anonymous = manifest.name === undefined
41
+
40
42
  defaults(manifest, proto)
41
43
  await expand(manifest)
42
44
 
43
45
  await merge(path, manifest)
46
+ await migrations(path, manifest)
47
+
48
+ // an inherited migration is recorded under the prototype's name, and a generated one is not
49
+ // the same on the next machine
50
+ if (proto && anonymous && manifest.entity?.migrations !== undefined)
51
+ throw new Error(`Prototype at '${path}' declares migrations, so it has to be named`)
44
52
 
45
53
  if (manifest.prototype !== null) {
46
54
  const prototype = await load(manifest.prototype, path, true)
@@ -56,4 +64,15 @@ const load = async (path, base, proto = false) => {
56
64
 
57
65
  const MANIFEST = 'manifest.toa.yaml'
58
66
 
59
- exports.component = component
67
+ /**
68
+ * Reads a YAML file, resolving anchors into distinct objects so that
69
+ * mutating one node cannot reach another.
70
+ *
71
+ * @param {string} path
72
+ * @return {Promise<object>}
73
+ */
74
+ async function read(path) {
75
+ const object = jsyaml.load(await readFile(path, 'utf8'))
76
+
77
+ return jsyaml.load(jsyaml.dump(object, { noRefs: true, lineWidth: -1 }))
78
+ }
package/src/context.js CHANGED
@@ -1,27 +1,35 @@
1
- 'use strict'
1
+ import { resolve } from 'node:path'
2
+ import { convolve, environment as variables } from '@toa.io/generic'
3
+ import glob from 'fast-glob'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { yaml as jsyaml } from '@toa.io/generic'
2
6
 
3
- const { resolve } = require('node:path')
4
- const { convolve } = require('@toa.io/generic')
5
- const { directory: { glob } } = require('@toa.io/filesystem')
6
- const { load } = require('@toa.io/yaml')
7
+ import { component } from './component.js'
7
8
 
8
- const { component } = require('./component')
9
-
10
- const {
9
+ import {
11
10
  dependencies,
12
11
  normalize,
13
12
  complete,
14
13
  dereference,
14
+ evict,
15
15
  expand,
16
16
  validate
17
- } = require('./.context')
17
+ } from './.context/index.js'
18
18
 
19
- const context = async (root, environment = process.env.TOA_ENV) => {
19
+ export const context = async (
20
+ root,
21
+ environment = variables.get('TOA_ENV'),
22
+ options = {}
23
+ ) => {
20
24
  const path = resolve(root, CONTEXT)
21
- const context = /** @type {toa.norm.Context} */ await load(path)
22
- const pattern = resolve(root, context.packages)
25
+ const context = /** @type {toa.norm.Context} */ await read(path)
26
+
27
+ const names = environment?.split(':')
23
28
 
24
- context.environment = environment
29
+ if (names !== undefined && names.length > 1 && names.some((name) => name.length === 0))
30
+ throw new Error(`Environment '${environment}' contains an empty name.`)
31
+
32
+ context.environment = names?.[0]
25
33
 
26
34
  convolve(context, environment)
27
35
  expand(context)
@@ -29,9 +37,16 @@ const context = async (root, environment = process.env.TOA_ENV) => {
29
37
 
30
38
  validate(context)
31
39
 
32
- const paths = await glob(pattern)
40
+ const paths = await glob(resolve(root, COMPONENTS), GLOB)
33
41
 
34
42
  context.components = await Promise.all(paths.map(component))
43
+
44
+ // before the dependencies, which mark what only evicted components require.
45
+ // `evicted: false` leaves that off: a local run of a component still needs its variables
46
+ if (options.evicted !== false) evict(context)
47
+
48
+ // what a context declares of every component that stores anything, its own and the ones its
49
+ // extensions bring, is given to them where those are known: inside `dependencies`
35
50
  context.dependencies = await dependencies(context)
36
51
 
37
52
  dereference(context)
@@ -41,5 +56,19 @@ const context = async (root, environment = process.env.TOA_ENV) => {
41
56
  }
42
57
 
43
58
  const CONTEXT = 'context.toa.yaml'
59
+ const COMPONENTS = 'components/*'
60
+
61
+ const GLOB = { onlyDirectories: true, absolute: true }
44
62
 
45
- exports.context = context
63
+ /**
64
+ * Reads a YAML file, resolving anchors into distinct objects so that
65
+ * mutating one node cannot reach another.
66
+ *
67
+ * @param {string} path
68
+ * @return {Promise<object>}
69
+ */
70
+ async function read(path) {
71
+ const object = jsyaml.load(await readFile(path, 'utf8'))
72
+
73
+ return jsyaml.load(jsyaml.dump(object, { noRefs: true, lineWidth: -1 }))
74
+ }
@@ -0,0 +1,63 @@
1
+ import { createRequire } from 'node:module'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import * as definitions from '@toa.io/definitions'
6
+
7
+ // import.meta.resolve takes no paths, and a dependency is named rather than
8
+ // written as a path
9
+ const require = createRequire(import.meta.url)
10
+
11
+ /**
12
+ * What a package declares, read without running it.
13
+ *
14
+ * A first-party package's definition is in `@toa.io/definitions`. Another package's is
15
+ * its `definition.js`, or its entry where it ships none — so an extension that exports
16
+ * its `manifest` and `deployment` beside its `Factory` is read as it always was.
17
+ *
18
+ * @param {string} reference a package name, or a directory
19
+ * @returns {Promise<toa.norm.Definition>}
20
+ */
21
+ export function definition(reference) {
22
+ cache[reference] ??= load(reference)
23
+
24
+ return cache[reference]
25
+ }
26
+
27
+ const cache = {}
28
+
29
+ async function load(reference) {
30
+ const known = await definitions.definition(reference)
31
+
32
+ if (known !== undefined) return { name: reference, module: known }
33
+
34
+ // `package#key` is a declaration of its own that a package claims beside its main one
35
+ const [path, key] = reference.split('#')
36
+ const name = (metadata(path)?.name ?? path) + (key === undefined ? '' : '#' + key)
37
+ const module = await import(pathToFileURL(resolve(path)).href)
38
+
39
+ if (key === undefined) return { name, module }
40
+
41
+ if (module.keys?.[key] === undefined)
42
+ throw new Error(`'${reference}' names a key that '${path}' does not claim`)
43
+
44
+ return { name, module: module.keys[key] }
45
+ }
46
+
47
+ function resolve(reference) {
48
+ try {
49
+ return require.resolve(join(reference, 'definition.js'))
50
+ } catch {
51
+ return require.resolve(reference)
52
+ }
53
+ }
54
+
55
+ function metadata(reference) {
56
+ try {
57
+ return JSON.parse(
58
+ readFileSync(require.resolve(join(reference, 'package.json')), 'utf8')
59
+ )
60
+ } catch {
61
+ return null
62
+ }
63
+ }
package/src/entity.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What a component declares about its records is a property map and a list of the properties a
3
+ * whole one has. A validator wants a schema, so it is assembled here rather than declared: one
4
+ * that a stored record must fit, and one for a changeset, which is whatever subset of the same
5
+ * properties an assignment writes.
6
+ */
7
+ export const schema = (entity) =>
8
+ entity.required === undefined
9
+ ? changeset(entity)
10
+ : { type: 'object', properties: entity.properties, required: entity.required }
11
+
12
+ export const changeset = (entity) => ({
13
+ type: 'object',
14
+ properties: entity.properties
15
+ })
package/src/index.js CHANGED
@@ -1,9 +1,7 @@
1
- 'use strict'
2
-
3
- const { context } = require('./context')
4
- const { component } = require('./component')
5
-
6
- exports.shortcuts = require('./shortcuts')
7
-
8
- exports.context = context
9
- exports.component = component
1
+ export { context } from './context.js'
2
+ export { component } from './component.js'
3
+ export { map } from './map.js'
4
+ export { plain, revive, NORMALIZED } from './manifest.js'
5
+ export { definition } from './definition.js'
6
+ export * as shortcuts from './shortcuts.js'
7
+ export * as entity from './entity.js'
@@ -0,0 +1,154 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, parse, relative, sep } from 'node:path'
3
+
4
+ import { Locator } from '@toa.io/core'
5
+ import { find } from '@toa.io/generic'
6
+
7
+ /**
8
+ * A normalised manifest, as it is carried in a file beside the component it is of.
9
+ *
10
+ * A build normalises every component to tag its image; the process that runs it normalised them
11
+ * again at start, reading every operation module's source through its bridge to do it. What the
12
+ * build derived is written instead, and the two cannot disagree: the sources it was read from are
13
+ * what the image's tag digests, and the version of Toa that read them is in that tag as well.
14
+ *
15
+ * What cannot be carried is where things are. A workspace and an image put a component in one
16
+ * directory and Toa's own packages in another, so every path is written as what it is of rather
17
+ * than as where it was — the component itself, or the package that holds it — and is resolved
18
+ * again on the other side, the way norm resolved it.
19
+ *
20
+ * A key with no value is not carried: a file has no way to say `undefined`, and nothing reads
21
+ * one apart from a key that is not there.
22
+ */
23
+ export function plain(manifest) {
24
+ const { locator, packages, path, ...rest } = manifest
25
+
26
+ // what is carried is written, not the manifest the caller goes on using, and it is written
27
+ // as a file holds it: a key with no value does not survive and is not meant to
28
+ const declared = JSON.parse(JSON.stringify(rest))
29
+
30
+ walk(declared, (item) => {
31
+ item.path = carried(item.path, path)
32
+ })
33
+
34
+ local(declared, path)
35
+
36
+ return declared
37
+ }
38
+
39
+ /** What `plain` wrote, read back beside the component it is of. */
40
+ export function revive(declared, path) {
41
+ const manifest = { ...structuredClone(declared), path }
42
+
43
+ walk(manifest, (item) => {
44
+ item.path = resolved(item.path, path)
45
+ })
46
+
47
+ manifest.locator = new Locator(manifest.name, manifest.namespace)
48
+
49
+ return manifest
50
+ }
51
+
52
+ /**
53
+ * Everything in a manifest that says where a module of it is: what an inherited one declares is
54
+ * in the prototype it came from, and what the component declares is in the component.
55
+ */
56
+ function walk(manifest, rewrite) {
57
+ for (const property of ROOTED)
58
+ for (const item of Object.values(manifest[property] ?? {}))
59
+ if (item.path !== undefined) rewrite(item)
60
+
61
+ for (let prototype = manifest.prototype; prototype != null; prototype = prototype.prototype)
62
+ if (prototype.path !== undefined) rewrite(prototype)
63
+ }
64
+
65
+ /** Where it is written. A workspace has none: it is a build's output, read by a process. */
66
+ export const NORMALIZED = 'manifest.toa.json'
67
+
68
+ /** Where norm says a module is, beside what it says about it. */
69
+ const ROOTED = ['events', 'receivers', 'guards']
70
+
71
+ /** The component's own directory, which is where it is read back. */
72
+ const SELF = '.'
73
+
74
+ function carried(path, root) {
75
+ if (path === root) return SELF
76
+ if (path.startsWith(root + sep)) return SELF + '/' + relative(root, path).split(sep).join('/')
77
+
78
+ return specifier(path)
79
+ }
80
+
81
+ function resolved(path, root) {
82
+ if (path === SELF) return root
83
+ if (path.startsWith(SELF + '/')) return join(root, path.slice(2))
84
+
85
+ return find(path, root, MANIFEST)
86
+ }
87
+
88
+ /**
89
+ * A directory in a package, named the way a manifest would name it, so that it is found again
90
+ * wherever the package is installed. A prototype that is a directory of the application's own is
91
+ * refused here rather than in the container: an image carries components and nothing between
92
+ * them, so nothing would be there to find, whether it is normalised here or there.
93
+ */
94
+ function specifier(path) {
95
+ const root = above(path)
96
+
97
+ if (root !== undefined) {
98
+ const { name, private: hidden } = JSON.parse(
99
+ readFileSync(join(root, 'package.json'), 'utf8')
100
+ )
101
+
102
+ if (name !== undefined && hidden !== true)
103
+ return [name, ...relative(root, path).split(sep)].filter(Boolean).join('/')
104
+ }
105
+
106
+ throw new Error(
107
+ `'${path}' is in no package, so an image cannot carry what is in it: ` +
108
+ 'what a component inherits is reached by the reference of the package it is in.'
109
+ )
110
+ }
111
+
112
+ /** The directory of the package a path is in. */
113
+ function above(path) {
114
+ const { root } = parse(path)
115
+
116
+ let current = path
117
+
118
+ while (current !== root) {
119
+ if (existsSync(join(current, 'package.json'))) return current
120
+
121
+ current = dirname(current)
122
+ }
123
+
124
+ return undefined
125
+ }
126
+
127
+ /**
128
+ * Nothing that is carried may say where it was on the machine that read it. What is checked is
129
+ * what can be told apart from a value of the application's own — an HTTP route is `/accounts`
130
+ * and is a path to nobody — so: anything inside the component, and any directory a manifest
131
+ * declares a component or a prototype in. This is what catches a path norm starts writing
132
+ * somewhere this file does not know about, at the build rather than at the start of a container
133
+ * that cannot find it.
134
+ */
135
+ function local(value, root, at = '') {
136
+ if (typeof value === 'string') {
137
+ if (value === root || value.startsWith(root + sep) || component(value))
138
+ throw new Error(`'${at}' carries a path of the machine that read it: ${value}`)
139
+
140
+ return
141
+ }
142
+
143
+ if (value === null || typeof value !== 'object') return
144
+
145
+ for (const [key, item] of Object.entries(value))
146
+ local(item, root, at === '' ? key : `${at}.${key}`)
147
+ }
148
+
149
+ /** Whether a value is a directory a component or a prototype is declared in. */
150
+ function component(value) {
151
+ return isAbsolute(value) && existsSync(join(value, MANIFEST))
152
+ }
153
+
154
+ const MANIFEST = 'manifest.toa.yaml'