@toa.io/core 1.0.0-alpha.28 → 1.0.0-alpha.282

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 (98) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/package.json +8 -10
  3. package/src/assignment.js +5 -8
  4. package/src/call.js +35 -9
  5. package/src/cascade.js +9 -11
  6. package/src/component.js +61 -21
  7. package/src/composition.js +3 -7
  8. package/src/connector.js +29 -22
  9. package/src/context.js +8 -6
  10. package/src/contract/contract.js +18 -0
  11. package/src/contract/index.js +2 -7
  12. package/src/contract/reply.js +36 -15
  13. package/src/contract/request.js +20 -10
  14. package/src/contract/schemas/index.js +9 -5
  15. package/src/contract/schemas/query.yaml +14 -1
  16. package/src/contract/schemas/source.yaml +23 -0
  17. package/src/discovery.js +15 -13
  18. package/src/effect.js +15 -0
  19. package/src/emission.js +2 -6
  20. package/src/entities/changeset.js +7 -14
  21. package/src/entities/entity.js +75 -29
  22. package/src/entities/factory.js +21 -14
  23. package/src/entities/index.js +1 -5
  24. package/src/entities/newid.js +9 -0
  25. package/src/entities/set.js +13 -4
  26. package/src/event.js +21 -7
  27. package/src/exceptions.js +53 -39
  28. package/src/exposition.js +5 -8
  29. package/src/guard.js +13 -0
  30. package/src/index.js +28 -47
  31. package/src/locator.js +2 -6
  32. package/src/observation.js +3 -15
  33. package/src/operation.js +40 -14
  34. package/src/outbox/index.js +1 -0
  35. package/src/outbox/outbox.js +296 -0
  36. package/src/query/criteria.js +3 -7
  37. package/src/query/options.js +5 -8
  38. package/src/query.js +36 -7
  39. package/src/receiver.js +76 -18
  40. package/src/reflection.js +2 -6
  41. package/src/remote.js +9 -12
  42. package/src/state.js +89 -50
  43. package/src/transition.js +13 -25
  44. package/src/transmission.js +15 -10
  45. package/src/unmanaged.js +7 -0
  46. package/test/call.fixtures.js +8 -12
  47. package/test/call.test.js +21 -9
  48. package/test/component.fixtures.js +5 -8
  49. package/test/component.test.js +22 -10
  50. package/test/connector.fixtures.js +10 -8
  51. package/test/connector.test.js +59 -38
  52. package/test/context.fixtures.js +8 -12
  53. package/test/context.test.js +18 -7
  54. package/test/contract/conditions.test.js +11 -9
  55. package/test/contract/contract.fixtures.js +12 -16
  56. package/test/contract/request.test.js +84 -34
  57. package/test/discovery.test.js +64 -0
  58. package/test/emission.fixtures.js +6 -10
  59. package/test/emission.test.js +29 -12
  60. package/test/entities/entity.fixtures.js +7 -11
  61. package/test/entities/entity.test.js +70 -49
  62. package/test/entities/factory.fixtures.js +22 -10
  63. package/test/entities/factory.test.js +29 -15
  64. package/test/entities/set.fixtures.js +6 -8
  65. package/test/entities/set.test.js +6 -5
  66. package/test/event.fixtures.js +9 -14
  67. package/test/event.test.js +57 -31
  68. package/test/locator.test.js +25 -24
  69. package/test/outbox.test.js +121 -0
  70. package/test/query.fixtures.js +1 -5
  71. package/test/query.test.js +42 -14
  72. package/test/receiver.fixtures.js +9 -12
  73. package/test/receiver.test.js +45 -22
  74. package/test/reflection.test.js +7 -6
  75. package/test/state.fixtures.js +25 -30
  76. package/test/state.test.js +72 -19
  77. package/test/transmission.fixtures.js +4 -6
  78. package/test/transmission.test.js +33 -15
  79. package/types/atomicity.d.ts +58 -0
  80. package/types/bindings.d.ts +8 -6
  81. package/types/bridges.ts +5 -2
  82. package/types/component.d.ts +7 -4
  83. package/types/context.d.ts +6 -3
  84. package/types/entity.d.ts +4 -4
  85. package/types/event.d.ts +1 -1
  86. package/types/extensions.d.ts +35 -10
  87. package/types/index.ts +16 -13
  88. package/types/message.d.ts +1 -0
  89. package/types/operations.d.ts +6 -0
  90. package/types/outbox.d.ts +53 -0
  91. package/types/query.d.ts +2 -0
  92. package/types/receiver.d.ts +2 -2
  93. package/types/reflection.d.ts +1 -1
  94. package/types/remote.d.ts +18 -0
  95. package/types/request.d.ts +16 -1
  96. package/types/state.d.ts +11 -8
  97. package/types/storages.d.ts +36 -11
  98. package/src/contract/conditions.js +0 -21
@@ -1,25 +1,46 @@
1
- 'use strict'
1
+ import * as schemas from './schemas/index.js'
2
+ import { Contract } from './contract.js'
3
+ import { ResponseContractException } from '../exceptions.js'
2
4
 
3
- const schemas = require('./schemas')
4
- const { Conditions } = require('./conditions')
5
- const { ResponseContractException } = require('../exceptions')
6
-
7
- class Reply extends Conditions {
5
+ export class Reply extends Contract {
8
6
  static Exception = ResponseContractException
9
7
 
10
- /**
11
- * @returns {toa.schema.JSON}
12
- */
13
- static schema (output, error) {
8
+ static schema (output, errors) {
14
9
  const schema = { type: 'object', properties: {}, additionalProperties: false }
15
10
 
16
- if (output !== undefined) schema.properties.output = output
11
+ if (output !== undefined) {
12
+ /*
13
+ * A reply carries more than the operation declares — `_version` and the rest of the
14
+ * record's own fields — so what it is validated against is the declaration relaxed.
15
+ * On a copy: the declaration itself is what a component publishes when asked to
16
+ * explain, and relaxing that would publish a contract nobody wrote.
17
+ */
18
+ output = structuredClone(output)
19
+
20
+ if (output.type === 'object')
21
+ output.additionalProperties = true
22
+ else if (output.type === 'array' && output.items?.type === 'object')
23
+ output.items.additionalProperties = true
17
24
 
18
- if (error !== undefined) schema.properties.error = error
19
- else schema.properties.error = schemas.error
25
+ schema.properties.output = output
26
+ }
27
+
28
+ if (errors !== undefined)
29
+ schema.properties.error = {
30
+ type: 'object',
31
+ properties: {
32
+ code: {
33
+ enum: errors
34
+ },
35
+ message: {
36
+ type: 'string'
37
+ }
38
+ },
39
+ required: ['code']
40
+ }
41
+ else
42
+ schema.properties.error = schemas.error
20
43
 
21
44
  return schema
22
45
  }
23
46
  }
24
-
25
- exports.Reply = Reply
@@ -1,10 +1,19 @@
1
- 'use strict'
1
+ import * as schemas from './schemas/index.js'
2
+ import { RequestContractException } from '../exceptions.js'
3
+ import { Contract } from './contract.js'
2
4
 
3
- const schemas = require('./schemas')
4
- const { RequestContractException } = require('../exceptions')
5
- const { Conditions } = require('./conditions')
5
+ export class Request extends Contract {
6
+ /** @readonly */
7
+ discovery = {}
8
+
9
+ constructor (schema, definition) {
10
+ super(schema)
11
+
12
+ for (const key of ['input', 'output', 'errors'])
13
+ if (definition[key] !== undefined)
14
+ this.discovery[key] = definition[key]
15
+ }
6
16
 
7
- class Request extends Conditions {
8
17
  static Exception = RequestContractException
9
18
 
10
19
  /**
@@ -13,7 +22,11 @@ class Request extends Conditions {
13
22
  static schema (definition, entity) {
14
23
  const schema = {
15
24
  type: 'object',
16
- properties: { authentic: { type: 'boolean' } },
25
+ properties: {
26
+ authentic: { type: 'boolean' },
27
+ task: { type: 'boolean' },
28
+ source: structuredClone(schemas.source)
29
+ },
17
30
  additionalProperties: true
18
31
  }
19
32
 
@@ -22,9 +35,8 @@ class Request extends Conditions {
22
35
  if (definition.input !== undefined) {
23
36
  schema.properties.input = definition.input
24
37
  required.push('input')
25
- } else {
38
+ } else
26
39
  schema.properties.input = { type: 'null' }
27
- }
28
40
 
29
41
  if (entity === undefined)
30
42
  definition.query = false
@@ -63,5 +75,3 @@ class Request extends Conditions {
63
75
  return schema
64
76
  }
65
77
  }
66
-
67
- exports.Request = Request
@@ -1,7 +1,11 @@
1
- 'use strict'
1
+ import { resolve } from 'path'
2
+ import { readFileSync } from 'node:fs'
3
+ import { yaml } from '@toa.io/generic'
2
4
 
3
- const { resolve } = require('path')
4
- const { load } = require('@toa.io/yaml')
5
+ export const query = read(resolve(import.meta.dirname, './query.yaml'))
6
+ export const error = read(resolve(import.meta.dirname, './error.yaml'))
7
+ export const source = read(resolve(import.meta.dirname, './source.yaml'))
5
8
 
6
- exports.query = load.sync(resolve(__dirname, './query.yaml'))
7
- exports.error = load.sync(resolve(__dirname, './error.yaml'))
9
+ function read (path) {
10
+ return yaml.load(readFileSync(path, 'utf8'))
11
+ }
@@ -1,11 +1,22 @@
1
1
  type: object
2
2
  properties:
3
- id: { }
3
+ id:
4
+ type: string
5
+ ids:
6
+ type: array
7
+ uniqueItems: true
8
+ minItems: 1
9
+ items:
10
+ type: string
4
11
  version:
5
12
  type: integer
6
13
  minimum: 0
7
14
  criteria:
8
15
  type: string
16
+ search:
17
+ type: string
18
+ sample:
19
+ type: number
9
20
  omit:
10
21
  type: integer
11
22
  minimum: 0
@@ -27,4 +38,6 @@ properties:
27
38
  type: string
28
39
  not:
29
40
  const: id
41
+ deleted:
42
+ type: boolean
30
43
  additionalProperties: false
@@ -0,0 +1,23 @@
1
+ # Origin of a call, see toa.core.Source.
2
+ # Deliberately permissive on required properties: the contract throws on a mismatch,
3
+ # and a strict union would turn a partially stamped source into a failed business call.
4
+ # `additionalProperties: false` combined with `removeAdditional` is what matters here —
5
+ # it strips whatever a peer puts in, bounding the cardinality of the resulting map.
6
+ type: object
7
+ properties:
8
+ namespace:
9
+ type: string
10
+ maxLength: 64
11
+ component:
12
+ type: string
13
+ maxLength: 64
14
+ operation:
15
+ type: string
16
+ maxLength: 64
17
+ event:
18
+ type: string
19
+ maxLength: 64
20
+ service:
21
+ type: string
22
+ maxLength: 64
23
+ additionalProperties: false
package/src/discovery.js CHANGED
@@ -1,9 +1,7 @@
1
- 'use strict'
1
+ import { console } from 'openspan'
2
+ import { Connector } from './connector.js'
2
3
 
3
- const { console } = require('@toa.io/console')
4
- const { Connector } = require('./connector')
5
-
6
- class Discovery extends Connector {
4
+ export class Discovery extends Connector {
7
5
  #lookup
8
6
  #lookups
9
7
 
@@ -25,20 +23,24 @@ class Discovery extends Connector {
25
23
  this.depends(this.#lookups[id])
26
24
  }
27
25
 
28
- console.debug(`Sending lookup request to '${id}'`)
26
+ const since = Date.now()
27
+
28
+ // the wait is unbounded by design, as a dependency may still be starting,
29
+ // so repeating is the only way a component stuck on one stays visible
30
+ const warning = setInterval(() => {
31
+ const waiting = Math.round((Date.now() - since) / 1000)
29
32
 
30
- const warning = () => console.warn(`Waiting for lookup response from '${id}'...`)
31
- const timeout = setTimeout(warning, TIMEOUT)
33
+ console.warn('Waiting for lookup response', { component: id, waiting })
34
+ }, INTERVAL)
35
+
36
+ warning.unref()
32
37
 
33
38
  const output = await this.#lookups[id].invoke()
34
39
 
35
- console.debug(`Lookup response from '${id}' received`)
36
- clearTimeout(timeout)
40
+ clearInterval(warning)
37
41
 
38
42
  return output
39
43
  }
40
44
  }
41
45
 
42
- const TIMEOUT = 5000
43
-
44
- exports.Discovery = Discovery
46
+ const INTERVAL = 5000
package/src/effect.js ADDED
@@ -0,0 +1,15 @@
1
+ import { Observation } from './observation.js'
2
+
3
+ export class Effect extends Observation {
4
+
5
+ async acquire (store) {
6
+ const { query, entity, input } = store.request
7
+
8
+ if (entity === undefined)
9
+ return super.acquire(store)
10
+
11
+ store.scope = await this.scope.ensure(query, entity, input)
12
+ store.state = store.scope.get()
13
+ }
14
+
15
+ }
package/src/emission.js CHANGED
@@ -1,8 +1,6 @@
1
- 'use strict'
1
+ import { Connector } from './connector.js'
2
2
 
3
- const { Connector } = require('./connector')
4
-
5
- class Emission extends Connector {
3
+ export class Emission extends Connector {
6
4
  #events
7
5
 
8
6
  constructor (events) {
@@ -19,5 +17,3 @@ class Emission extends Connector {
19
17
  await Promise.all(emission)
20
18
  }
21
19
  }
22
-
23
- exports.Emission = Emission
@@ -1,13 +1,6 @@
1
- 'use strict'
1
+ import { EntityContractException } from '../exceptions.js'
2
2
 
3
- const {
4
- merge,
5
- overwrite,
6
- newid
7
- } = require('@toa.io/generic')
8
- const { EntityContractException } = require('../exceptions')
9
-
10
- class Changeset {
3
+ export class Changeset {
11
4
  /** @type {toa.core.Query} */
12
5
  query
13
6
 
@@ -18,7 +11,7 @@ class Changeset {
18
11
  this.query = query
19
12
 
20
13
  this.#schema = schema
21
- this.#state = schema.system()
14
+ this.#state = {}
22
15
  }
23
16
 
24
17
  get () {
@@ -26,10 +19,12 @@ class Changeset {
26
19
  }
27
20
 
28
21
  set (value) {
29
- const error = this.#schema.adapt(value)
22
+ const error = this.#schema.match(value)
30
23
 
31
- if (error !== null) throw new EntityContractException(error)
24
+ if (error !== null)
25
+ throw new EntityContractException(error, value)
32
26
 
27
+ delete value._version
33
28
  value._updated = Date.now()
34
29
 
35
30
  this.#state = value
@@ -39,5 +34,3 @@ class Changeset {
39
34
  return this.#state
40
35
  }
41
36
  }
42
-
43
- exports.Changeset = Changeset
@@ -1,24 +1,34 @@
1
- 'use strict'
1
+ import { EntityContractException, EntityGuardException } from '../exceptions.js'
2
+ import { newid } from './newid.js'
2
3
 
3
- const {
4
- difference,
5
- newid
6
- } = require('@toa.io/generic')
7
-
8
- const { EntityContractException } = require('../exceptions')
9
-
10
- class Entity {
4
+ export class Entity {
5
+ deleted = false
11
6
  #schema
7
+ #guards
12
8
  #origin = null
13
9
  #state
10
+ #mutable = true
14
11
 
15
- constructor (schema, argument) {
12
+ /**
13
+ * @param {boolean} [mutable] whether the entity may be modified and committed
14
+ */
15
+ constructor (schema, argument, guards, mutable = true) {
16
16
  this.#schema = schema
17
+ this.#guards = guards
17
18
 
18
19
  if (typeof argument === 'object') {
19
- const object = structuredClone(argument)
20
- this.set(object)
21
- this.#origin = argument
20
+ /*
21
+ * The origin is the pre-image a commit diffs the new state against. An operation
22
+ * that cannot commit has nothing to diff, so it takes the record as it came from
23
+ * the storage instead of paying for a deep copy of every record it read.
24
+ */
25
+ this.#mutable = mutable
26
+
27
+ if (mutable) {
28
+ this.#set(structuredClone(argument))
29
+ this.#origin = argument
30
+ } else
31
+ this.#set(argument)
22
32
  } else {
23
33
  const id = argument === undefined ? newid() : argument
24
34
  this.#init(id)
@@ -29,37 +39,75 @@ class Entity {
29
39
  return this.#state
30
40
  }
31
41
 
32
- set (value) {
33
- const error = this.#schema.fit(value)
42
+ set (value, optional = false) {
43
+ if (!this.#mutable)
44
+ throw new Error('Entity acquired by a read-only operation cannot be modified')
45
+
46
+ if (!optional)
47
+ this.#guard(value)
48
+
49
+ const error = optional ? this.#schema.fitOptional(value) : this.#schema.fit(value)
34
50
 
35
- if (error !== null) throw new EntityContractException(error)
51
+ if (error !== null)
52
+ throw new EntityContractException(error, value)
36
53
 
54
+ this.#revive(value)
37
55
  this.#set(value)
38
56
  }
39
57
 
40
- event () {
58
+ event (input = undefined) {
41
59
  return {
42
60
  origin: this.#origin,
43
61
  state: this.#state,
44
- changeset: this.#origin === null ? this.#state : difference(this.#origin, this.#state)
62
+ trailers: this.#state._trailers,
63
+ input
45
64
  }
46
65
  }
47
66
 
48
67
  #init (id) {
49
- const value = {
50
- ...this.#schema.defaults({ id }),
51
- _version: 0,
52
- _created: Date.now()
68
+ const value = { id, _version: 0 }
69
+
70
+ this.set(value, true)
71
+ }
72
+
73
+ #guard (value) {
74
+ if (this.#guards === undefined)
75
+ return
76
+
77
+ for (const guard of this.#guards) {
78
+ const ok = guard.fit(value, this.#origin)
79
+
80
+ if (ok === false)
81
+ throw new EntityGuardException(guard.name, value)
53
82
  }
83
+ }
54
84
 
55
- this.#set(value)
85
+ // deletion is only expressed as a new _deleted timestamp,
86
+ // so committing over a tombstone without touching it means revival
87
+ #revive (value) {
88
+ if (this.#origin?._deleted == null || value._deleted !== this.#origin._deleted)
89
+ return
90
+
91
+ value._deleted = null
92
+ this.deleted = false
56
93
  }
57
94
 
58
95
  #set (value) {
59
- Object.defineProperty(value, 'id', {
60
- writable: false,
61
- configurable: false
62
- })
96
+ if (!('_trailers' in value))
97
+ Object.defineProperty(value, '_trailers', {
98
+ writable: false,
99
+ configurable: false,
100
+ enumerable: false,
101
+ value: {}
102
+ })
103
+
104
+ if (!('_created' in value)) {
105
+ value._created = Date.now()
106
+ value._updated ??= value._created
107
+ }
108
+
109
+ if ('_deleted' in value && value._deleted !== null)
110
+ this.deleted = true
63
111
 
64
112
  if (this.#state !== undefined) {
65
113
  value._updated = Date.now()
@@ -69,5 +117,3 @@ class Entity {
69
117
  this.#state = value
70
118
  }
71
119
  }
72
-
73
- exports.Entity = Entity
@@ -1,26 +1,35 @@
1
- 'use strict'
1
+ import { newid } from './newid.js'
2
+ import { Entity } from './entity.js'
3
+ import { EntitySet } from './set.js'
4
+ import { Changeset } from './changeset.js'
2
5
 
3
- const { Entity } = require('./entity')
4
- const { EntitySet } = require('./set')
5
- const { Changeset } = require('./changeset')
6
-
7
- class Factory {
6
+ export class Factory {
8
7
  #schema
8
+ #guards
9
9
 
10
- constructor (schema) {
10
+ constructor (schema, guards) {
11
11
  this.#schema = schema
12
+ this.#guards = guards
13
+ }
14
+
15
+ fit (values) {
16
+ this.#schema.validate({ id: newid(), ...values }, 'Entity')
12
17
  }
13
18
 
14
19
  init (id) {
15
- return new Entity(this.#schema, id)
20
+ return new Entity(this.#schema, id, this.#guards)
16
21
  }
17
22
 
18
- object (record) {
19
- return new Entity(this.#schema, record)
23
+ object (record, mutable = true) {
24
+ return new Entity(this.#schema, record, this.#guards, mutable)
20
25
  }
21
26
 
22
- objects (recordset) {
23
- const set = recordset.map((record) => this.object(record))
27
+ objects (recordset, init, mutable = true) {
28
+ const set = recordset.map((record) => this.object(record, mutable))
29
+
30
+ if (init !== undefined)
31
+ for (const id of init)
32
+ set.unshift(this.init(id))
24
33
 
25
34
  return new EntitySet(set)
26
35
  }
@@ -29,5 +38,3 @@ class Factory {
29
38
  return new Changeset(this.#schema, query)
30
39
  }
31
40
  }
32
-
33
- exports.Factory = Factory
@@ -1,5 +1 @@
1
- 'use strict'
2
-
3
- const { Factory } = require('./factory')
4
-
5
- exports.Factory = Factory
1
+ export { Factory } from './factory.js'
@@ -0,0 +1,9 @@
1
+ import * as uuid from 'uuid'
2
+
3
+ export function newid () {
4
+ const buf = Buffer.alloc(16)
5
+
6
+ uuid.v7(undefined, buf)
7
+
8
+ return buf.toString('hex')
9
+ }
@@ -1,6 +1,6 @@
1
- 'use strict'
1
+ import { SystemException } from '../exceptions.js'
2
2
 
3
- class EntitySet {
3
+ export class EntitySet {
4
4
  #set
5
5
 
6
6
  constructor (set) {
@@ -10,6 +10,15 @@ class EntitySet {
10
10
  get () {
11
11
  return this.#set.map((entity) => entity.get())
12
12
  }
13
- }
14
13
 
15
- exports.EntitySet = EntitySet
14
+ set (values) {
15
+ if (values.length !== this.#set.length)
16
+ throw new SystemException('Objects array must not be modified')
17
+
18
+ values.forEach((value, index) => this.#set[index].set(value))
19
+ }
20
+
21
+ events (input = undefined) {
22
+ return this.#set.map((entity) => entity.event(input))
23
+ }
24
+ }
package/src/event.js CHANGED
@@ -1,22 +1,25 @@
1
- 'use strict'
2
-
3
- const { Connector } = require('./connector')
1
+ import { console, current, encode } from 'openspan'
2
+ import { Connector } from './connector.js'
4
3
 
5
4
  /**
6
5
  * @implements {toa.core.Event}
7
6
  */
8
- class Event extends Connector {
7
+ export class Event extends Connector {
9
8
  /** @type {toa.core.bindings.Emitter} */
10
9
  #emitter
11
10
  #bridge
12
11
  #conditioned
13
12
  #subjective
14
13
 
14
+ /** @type {string} */
15
+ #label
16
+
15
17
  constructor (definition, emitter, bridge = undefined) {
16
18
  super()
17
19
 
18
20
  this.#conditioned = definition.conditioned
19
21
  this.#subjective = definition.subjective
22
+ this.#label = definition.label ?? 'event'
20
23
  this.#emitter = emitter
21
24
  this.#bridge = bridge
22
25
 
@@ -32,9 +35,20 @@ class Event extends Connector {
32
35
  /** @type {toa.core.Message} */
33
36
  const message = { payload }
34
37
 
35
- await this.#emitter.emit(message)
38
+ const options = {
39
+ name: `${this.#label} publish`,
40
+ kind: 'producer',
41
+ attributes: { 'messaging.destination.name': this.#label }
42
+ }
43
+
44
+ await console.span(options, async () => {
45
+ const context = current()
46
+
47
+ if (context !== undefined)
48
+ message.telemetry = encode(context)
49
+
50
+ await this.#emitter.emit(message)
51
+ })
36
52
  }
37
53
  }
38
54
  }
39
-
40
- exports.Event = Event