@toa.io/norm 1.0.0-alpha.313 → 1.0.0-alpha.315

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 CHANGED
@@ -3,6 +3,31 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [1.0.0-alpha.315](https://github.com/toa-io/toa/compare/v1.0.0-alpha.314...v1.0.0-alpha.315) (2026-09-23)
7
+
8
+ * feat(map)!: leave out of a contract what the runtime gives every component ([b81837a](https://github.com/toa-io/toa/commit/b81837a3bc4771d7ee1c10fe613c9a1d7501bf1a))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * the deployed component map is `/etc/toa/.map.json.gz`.
13
+
14
+
15
+ # [1.0.0-alpha.314](https://github.com/toa-io/toa/compare/v1.0.0-alpha.313...v1.0.0-alpha.314) (2026-09-19)
16
+
17
+ * feat(core)!: let an operation state how long its calls are remembered ([af177c2](https://github.com/toa-io/toa/commit/af177c2ab3d00d475840b5ef9e612f04e82b72bd))
18
+ * feat(core)!: rename trailers to the TRAILERS system property ([8d59ef9](https://github.com/toa-io/toa/commit/8d59ef90d04b7329578203fe10bd21fe41dfe2f5))
19
+
20
+ ### Features
21
+
22
+ * **core:** a reference may name a declaration its package claims ([8040f60](https://github.com/toa-io/toa/commit/8040f60976549ab90497675bb54e664261f5b6bb))
23
+
24
+ ### BREAKING CHANGES
25
+
26
+ * `inbox.retention` under 600 is refused. See
27
+ migrations/313.md.
28
+ * `state._trailers` is `state.TRAILERS`; see migrations/313.md.
29
+
30
+
6
31
  # [1.0.0-alpha.313](https://github.com/toa-io/toa/compare/v1.0.0-alpha.312...v1.0.0-alpha.313) (2026-09-18)
7
32
 
8
33
  **Note:** Version bump only for package @toa.io/norm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/norm",
3
- "version": "1.0.0-alpha.313",
3
+ "version": "1.0.0-alpha.315",
4
4
  "type": "module",
5
5
  "description": "Toa declarations normalization and validation",
6
6
  "author": "temich <tema.gurtovoy@gmail.com>",
@@ -21,12 +21,12 @@
21
21
  "test": "echo \"Error: run tests from root\" && exit 1"
22
22
  },
23
23
  "dependencies": {
24
- "@toa.io/core": "1.0.0-alpha.312",
25
- "@toa.io/definitions": "1.0.0-alpha.313",
24
+ "@toa.io/core": "1.0.0-alpha.315",
25
+ "@toa.io/definitions": "1.0.0-alpha.315",
26
26
  "@toa.io/generic": "1.0.0-alpha.311",
27
- "@toa.io/prototype": "1.0.0-alpha.301",
27
+ "@toa.io/prototype": "1.0.0-alpha.315",
28
28
  "@toa.io/schemas": "1.0.0-alpha.311",
29
29
  "fast-glob": "3.3.3"
30
30
  },
31
- "gitHead": "7da3b2627ceeefd1fa19d5c8851be88d928523b6"
31
+ "gitHead": "d88ed286bf44bf95b5f3d9db6e7d4621261dbb99"
32
32
  }
@@ -7,7 +7,7 @@ export function extensions(manifest) {
7
7
 
8
8
  const SHORTCUTS = {
9
9
  exposition: '@toa.io/extensions.exposition',
10
- realtime: '@toa.io/extensions.realtime',
10
+ realtime: '@toa.io/extensions.exposition#realtime',
11
11
  configuration: '@toa.io/extensions.configuration',
12
12
  state: '@toa.io/extensions.state',
13
13
  stash: '@toa.io/extensions.stash',
@@ -101,7 +101,7 @@ properties:
101
101
  type: object
102
102
  propertyNames:
103
103
  not:
104
- enum: [id, VERSION, CREATED, UPDATED, DELETED, REGION]
104
+ enum: [id, VERSION, CREATED, UPDATED, DELETED, REGION, TRAILERS]
105
105
  migrations:
106
106
  type: array
107
107
  items:
@@ -157,7 +157,12 @@ properties:
157
157
  enum: [none, retry]
158
158
  once:
159
159
  # the same call arriving twice changes state once; see documentation/inbox.md
160
- type: boolean
160
+ # a number is how many seconds a call is remembered, which a deployment's
161
+ # `inbox.retention` is the default for; ten minutes at least, because the broker
162
+ # alone repeats a call for about that long. One type list rather than `oneOf`: the
163
+ # manifest is validated with coercion, and a branch would coerce `true` into `1`
164
+ type: [boolean, integer]
165
+ minimum: 600
161
166
  stateful:
162
167
  # served by every process under an address of its own, which a call names; see documentation/stateful.md
163
168
  type: boolean
@@ -25,6 +25,10 @@ export const validate = async (manifest) => {
25
25
  const entity = (manifest) => {
26
26
  const { properties, required, blank } = manifest.entity
27
27
 
28
+ // what an algorithm writes beside the record for the event of its commit; never stored
29
+ if (properties.TRAILERS !== undefined)
30
+ throw new Error("System property 'TRAILERS' cannot be declared")
31
+
28
32
  for (const name of required ?? [])
29
33
  if (properties[name] === undefined)
30
34
  throw new Error(`Entity requires property '${name}', which is not defined`)
@@ -164,9 +164,11 @@ properties:
164
164
  Seconds a call is remembered, which is the window a duplicate of it is caught in. Budget
165
165
  it for how late a duplicate can arrive — a redelivery, the attempts the broker library
166
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` is a record for this long.
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.
168
170
  type: integer
169
- minimum: 1
171
+ minimum: 600
170
172
  addressed:
171
173
  type: object
172
174
  additionalProperties: false
package/src/definition.js CHANGED
@@ -31,10 +31,17 @@ async function load(reference) {
31
31
 
32
32
  if (known !== undefined) return { name: reference, module: known }
33
33
 
34
- const name = metadata(reference)?.name ?? reference
35
- const module = await import(pathToFileURL(resolve(reference)).href)
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)
36
38
 
37
- return { name, module }
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] }
38
45
  }
39
46
 
40
47
  function resolve(reference) {
package/src/shortcuts.js CHANGED
@@ -43,7 +43,7 @@ export const SHORTCUTS = {
43
43
  bash: '@toa.io/bridges.bash',
44
44
  mongodb: '@toa.io/storages.mongodb',
45
45
  exposition: '@toa.io/extensions.exposition',
46
- realtime: '@toa.io/extensions.realtime',
46
+ realtime: '@toa.io/extensions.exposition#realtime',
47
47
  configuration: '@toa.io/extensions.configuration',
48
48
  stash: '@toa.io/extensions.stash',
49
49
  storages: '@toa.io/extensions.storages',
@@ -115,6 +115,13 @@ describe('entity', () => {
115
115
  }
116
116
  await assert.doesNotReject(validate(manifest))
117
117
  })
118
+
119
+ it('should not declare TRAILERS', async () => {
120
+ manifest.entity.properties.TRAILERS = { type: 'object' }
121
+ await assert.rejects(validate(manifest), (error) =>
122
+ /System property 'TRAILERS' cannot be declared/.test(error.message)
123
+ )
124
+ })
118
125
  })
119
126
 
120
127
  describe('required', () => {
@@ -146,6 +153,13 @@ describe('entity', () => {
146
153
  /must NOT be valid/.test(error.message)
147
154
  )
148
155
  })
156
+
157
+ it('should not name TRAILERS', async () => {
158
+ manifest.entity.blank = { TRAILERS: {} }
159
+ await assert.rejects(validate(manifest), (error) =>
160
+ /must NOT be valid/.test(error.message)
161
+ )
162
+ })
149
163
  })
150
164
  })
151
165
 
@@ -257,6 +271,37 @@ describe('operations', () => {
257
271
  })
258
272
  })
259
273
 
274
+ describe('once', () => {
275
+ it('should be true, false or a number of seconds', async () => {
276
+ for (const once of [true, false, 600, 86400]) {
277
+ manifest.operations.add.once = once
278
+ await assert.doesNotReject(validate(manifest), `once: ${once}`)
279
+ }
280
+ })
281
+
282
+ it('should stay true rather than be coerced into a number', async () => {
283
+ manifest.operations.add.once = true
284
+ await validate(manifest)
285
+
286
+ assert.strictEqual(manifest.operations.add.once, true)
287
+ })
288
+
289
+ it('should not be a window shorter than ten minutes', async () => {
290
+ manifest.operations.add.once = 599
291
+ await assert.rejects(validate(manifest), (error) => /must be >= 600/.test(error.message))
292
+ })
293
+
294
+ it('should not be a fraction of a second', async () => {
295
+ manifest.operations.add.once = 600.5
296
+ await assert.rejects(validate(manifest))
297
+ })
298
+
299
+ it('should throw for an operation that takes none', async () => {
300
+ manifest.operations.get.once = 600
301
+ await assert.rejects(validate(manifest))
302
+ })
303
+ })
304
+
260
305
  describe('concurrency', () => {
261
306
  it('should be required for transitions', async () => {
262
307
  delete manifest.operations.add.concurrency
@@ -29,7 +29,7 @@ export const context = {
29
29
  {
30
30
  name: 'foo',
31
31
  components: ['a.b', 'b.a'],
32
- services: ['@toa.io/extensions.exposition', '@toa.io/extensions.realtime']
32
+ services: ['@toa.io/extensions.exposition', '@toa.io/extensions.cadence']
33
33
  },
34
34
  {
35
35
  name: 'bar',
@@ -25,7 +25,10 @@ it('should mark an evicted component and keep it', () => {
25
25
  evict(context)
26
26
 
27
27
  assert.deepStrictEqual(
28
- context.components.map((component) => [component.locator.id, component.evicted === true]),
28
+ context.components.map((component) => [
29
+ component.locator.id,
30
+ component.evicted === true
31
+ ]),
29
32
  [
30
33
  ['a.b', true],
31
34
  ['b.a', false],
@@ -56,14 +59,12 @@ it('should drop an evicted service from a composition that runs it', () => {
56
59
 
57
60
  evict(context)
58
61
 
59
- assert.deepStrictEqual(context.compositions[0].services, [
60
- '@toa.io/extensions.realtime'
61
- ])
62
+ assert.deepStrictEqual(context.compositions[0].services, ['@toa.io/extensions.cadence'])
62
63
  })
63
64
 
64
65
  it('should leave no services where every one is evicted', () => {
65
66
  context.evicted = {
66
- services: ['@toa.io/extensions.exposition', '@toa.io/extensions.realtime']
67
+ services: ['@toa.io/extensions.exposition', '@toa.io/extensions.cadence']
67
68
  }
68
69
 
69
70
  evict(context)
@@ -84,6 +84,21 @@ it('should require registry url', () => {
84
84
  )
85
85
  })
86
86
 
87
+ describe('inbox', () => {
88
+ it('should allow a retention of ten minutes or more', () => {
89
+ context.inbox = { retention: 600 }
90
+ assert.doesNotThrow(() => validate(context))
91
+ })
92
+
93
+ it('should refuse a retention shorter than ten minutes', () => {
94
+ context.inbox = { retention: 599 }
95
+ assert.throws(
96
+ () => validate(context),
97
+ (error) => /must be >= 600/.test(error.message)
98
+ )
99
+ })
100
+ })
101
+
87
102
  it('should allow mono replicas and resources', () => {
88
103
  context.mono = {
89
104
  replicas: 2,
@@ -0,0 +1,31 @@
1
+ import { createRequire } from 'node:module'
2
+ import { dirname, join } from 'node:path'
3
+ import { readFileSync } from 'node:fs'
4
+ import { it } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+
7
+ import { yaml as jsyaml } from '@toa.io/generic'
8
+ import { contract } from '@toa.io/core'
9
+
10
+ /*
11
+ * A contract leaves out what the root prototype gives every entity, and `@toa.io/core` is what
12
+ * puts it back — where the prototype is not installed, so it holds a copy of that declaration.
13
+ * This is what keeps the copy the same as what a component is actually collapsed with.
14
+ */
15
+
16
+ const path = dirname(createRequire(import.meta.url).resolve('@toa.io/prototype'))
17
+ const prototype = jsyaml.load(readFileSync(join(path, 'manifest.toa.yaml'), 'utf8'))
18
+
19
+ it('should state the system properties the prototype gives', () => {
20
+ const { id, ...system } = prototype.entity.properties
21
+
22
+ assert.deepStrictEqual(system, contract.SYSTEM)
23
+ assert.deepStrictEqual(id, contract.ID)
24
+ })
25
+
26
+ it('should require every one of them, and nothing else', () => {
27
+ assert.deepStrictEqual(prototype.entity.required, [
28
+ 'id',
29
+ ...Object.keys(contract.SYSTEM)
30
+ ])
31
+ })