@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.
- package/CHANGELOG.md +305 -0
- package/package.json +9 -7
- package/src/.component/.expand/bridge.js +2 -6
- package/src/.component/.expand/entity.js +2 -9
- package/src/.component/.expand/events.js +2 -6
- package/src/.component/.expand/extensions.js +7 -9
- package/src/.component/.expand/index.js +7 -19
- package/src/.component/.expand/operations.js +2 -9
- package/src/.component/.expand/receivers.js +4 -7
- package/src/.component/.expand/version.js +69 -28
- package/src/.component/.normalize/entity.js +7 -0
- package/src/.component/.normalize/events.js +16 -4
- package/src/.component/.normalize/index.js +4 -9
- package/src/.component/.normalize/operations.js +13 -6
- package/src/.component/.normalize/receivers.js +4 -5
- package/src/.component/collapse.js +60 -9
- package/src/.component/defaults.js +5 -11
- package/src/.component/dependencies.js +14 -10
- package/src/.component/dereference.js +5 -49
- package/src/.component/expand.js +3 -9
- package/src/.component/extensions.js +9 -10
- package/src/.component/index.js +10 -21
- package/src/.component/merge.js +25 -15
- package/src/.component/migrations.js +57 -0
- package/src/.component/normalize.js +10 -6
- package/src/.component/schema.yaml +165 -73
- package/src/.component/validate.js +63 -17
- package/src/.context/.dependencies/connectors.js +5 -9
- package/src/.context/.dependencies/describe.js +4 -5
- package/src/.context/.dependencies/extensions.js +93 -20
- package/src/.context/.dependencies/index.js +3 -9
- package/src/.context/.dependencies/resolve.js +44 -7
- package/src/.context/complete.js +1 -5
- package/src/.context/converge.js +31 -0
- package/src/.context/dependencies.js +43 -6
- package/src/.context/dereference.js +11 -6
- package/src/.context/evict.js +38 -0
- package/src/.context/expand.js +11 -6
- package/src/.context/index.js +8 -15
- package/src/.context/normalize.js +9 -7
- package/src/.context/schema.yaml +183 -11
- package/src/.context/validate.js +11 -14
- package/src/component.js +33 -14
- package/src/context.js +44 -15
- package/src/definition.js +63 -0
- package/src/entity.js +15 -0
- package/src/index.js +7 -9
- package/src/manifest.js +154 -0
- package/src/map.js +24 -0
- package/src/shortcuts.js +11 -15
- package/test/component/collapse.fixtures.js +30 -39
- package/test/component/collapse.test.js +90 -10
- package/test/component/dependencies.test.js +16 -14
- package/test/component/dereference.fixtures.js +10 -98
- package/test/component/dereference.test.js +10 -14
- package/test/component/dummies/extension/index.js +1 -5
- package/test/component/dummies/migrations/conflicting/migrations/0001-one.yaml +2 -0
- package/test/component/dummies/migrations/conflicting/migrations/0001-one.yml +2 -0
- package/test/component/dummies/migrations/malformed/migrations/0001-one.yaml +1 -0
- package/test/component/dummies/migrations/ordered/migrations/0001-first.yml +4 -0
- package/test/component/dummies/migrations/ordered/migrations/0002-second.yaml +4 -0
- package/test/component/dummies/migrations/ordered/migrations/0010-third.json +1 -0
- package/test/component/dummies/migrations/stateless/migrations/0001-one.yaml +2 -0
- package/test/component/expand.fixtures.js +13 -13
- package/test/component/expand.test.js +17 -13
- package/test/component/migrations.test.js +62 -0
- package/test/component/normalize.fixtures.js +3 -7
- package/test/component/normalize.test.js +94 -28
- package/test/component/validate.fixtures.js +17 -15
- package/test/component/validate.test.js +289 -109
- package/test/context/complete.fixtures.js +2 -8
- package/test/context/complete.test.js +14 -8
- package/test/context/converge.test.js +57 -0
- package/test/context/dereference.fixtures.js +3 -9
- package/test/context/dereference.test.js +18 -7
- package/test/context/evict.fixtures.js +39 -0
- package/test/context/evict.test.js +73 -0
- package/test/context/expand.fixtures.js +1 -5
- package/test/context/expand.test.js +36 -8
- package/test/context/normalize.fixtures.js +2 -7
- package/test/context/normalize.test.js +33 -9
- package/test/context/validate.fixtures.js +1 -6
- package/test/context/validate.test.js +173 -35
- package/test/definition.test.js +35 -0
- package/test/dummies/bare/index.js +1 -0
- package/test/dummies/defined/definition.js +1 -0
- package/test/dummies/defined/index.js +1 -0
- package/test/dummies/defined/package.json +1 -0
- package/test/manifest.test.js +126 -0
- package/test/shortcuts.fixtures.js +4 -9
- package/test/shortcuts.test.js +17 -15
- package/test/system.test.js +31 -0
- package/types/component.d.ts +31 -4
- package/types/context/declaration.d.ts +34 -3
- package/types/context.d.ts +27 -13
- package/types/entity.d.ts +7 -0
- package/types/index.d.ts +18 -3
- package/src/.component/.expand/properties.js +0 -14
- package/src/.context/.dependencies/load.js +0 -36
- package/test/context/dependencies.load.test.js +0 -24
package/src/.context/schema.yaml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/.context/validate.js
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
6
|
-
const
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
import { join } from 'node:path'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
17
|
+
} from './.context/index.js'
|
|
18
18
|
|
|
19
|
-
const context = async (
|
|
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
|
|
22
|
-
|
|
25
|
+
const context = /** @type {toa.norm.Context} */ await read(path)
|
|
26
|
+
|
|
27
|
+
const names = environment?.split(':')
|
|
23
28
|
|
|
24
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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'
|
package/src/manifest.js
ADDED
|
@@ -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'
|