@toa.io/core 1.0.0-alpha.27 → 1.0.0-alpha.270

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 (63) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/package.json +7 -9
  3. package/src/call.js +21 -1
  4. package/src/cascade.js +7 -5
  5. package/src/component.js +59 -15
  6. package/src/composition.js +1 -1
  7. package/src/connector.js +28 -17
  8. package/src/context.js +6 -0
  9. package/src/contract/contract.js +22 -0
  10. package/src/contract/reply.js +26 -9
  11. package/src/contract/request.js +19 -5
  12. package/src/contract/schemas/index.js +1 -0
  13. package/src/contract/schemas/query.yaml +14 -1
  14. package/src/contract/schemas/source.yaml +23 -0
  15. package/src/discovery.js +13 -7
  16. package/src/effect.js +19 -0
  17. package/src/entities/changeset.js +5 -8
  18. package/src/entities/entity.js +76 -24
  19. package/src/entities/factory.js +17 -6
  20. package/src/entities/newid.js +11 -0
  21. package/src/entities/set.js +13 -0
  22. package/src/event.js +19 -1
  23. package/src/exceptions.js +26 -19
  24. package/src/exposition.js +3 -2
  25. package/src/guard.js +17 -0
  26. package/src/index.js +6 -0
  27. package/src/observation.js +1 -9
  28. package/src/operation.js +38 -8
  29. package/src/query/options.js +3 -2
  30. package/src/query.js +32 -2
  31. package/src/receiver.js +73 -11
  32. package/src/remote.js +8 -7
  33. package/src/state.js +74 -35
  34. package/src/transition.js +11 -19
  35. package/src/transmission.js +12 -3
  36. package/src/unmanaged.js +11 -0
  37. package/test/component.test.js +4 -3
  38. package/test/connector.fixtures.js +8 -0
  39. package/test/connector.test.js +20 -0
  40. package/test/contract/conditions.test.js +5 -5
  41. package/test/contract/request.test.js +46 -7
  42. package/test/discovery.test.js +56 -0
  43. package/test/entities/entity.test.js +54 -40
  44. package/test/entities/factory.test.js +5 -3
  45. package/test/event.test.js +4 -4
  46. package/test/query.test.js +17 -0
  47. package/test/receiver.fixtures.js +1 -0
  48. package/test/state.test.js +13 -12
  49. package/types/bindings.d.ts +7 -5
  50. package/types/bridges.ts +3 -0
  51. package/types/component.d.ts +4 -1
  52. package/types/context.d.ts +3 -0
  53. package/types/entity.d.ts +2 -2
  54. package/types/extensions.d.ts +7 -4
  55. package/types/index.ts +2 -1
  56. package/types/message.d.ts +1 -0
  57. package/types/operations.d.ts +6 -0
  58. package/types/query.d.ts +2 -0
  59. package/types/remote.d.ts +18 -0
  60. package/types/request.d.ts +15 -0
  61. package/types/state.d.ts +2 -2
  62. package/types/storages.d.ts +11 -9
  63. package/src/contract/conditions.js +0 -21
@@ -1,10 +1,5 @@
1
1
  'use strict'
2
2
 
3
- const {
4
- merge,
5
- overwrite,
6
- newid
7
- } = require('@toa.io/generic')
8
3
  const { EntityContractException } = require('../exceptions')
9
4
 
10
5
  class Changeset {
@@ -18,7 +13,7 @@ class Changeset {
18
13
  this.query = query
19
14
 
20
15
  this.#schema = schema
21
- this.#state = schema.system()
16
+ this.#state = {}
22
17
  }
23
18
 
24
19
  get () {
@@ -26,10 +21,12 @@ class Changeset {
26
21
  }
27
22
 
28
23
  set (value) {
29
- const error = this.#schema.adapt(value)
24
+ const error = this.#schema.match(value)
30
25
 
31
- if (error !== null) throw new EntityContractException(error)
26
+ if (error !== null)
27
+ throw new EntityContractException(error, value)
32
28
 
29
+ delete value._version
33
30
  value._updated = Date.now()
34
31
 
35
32
  this.#state = value
@@ -1,24 +1,37 @@
1
1
  'use strict'
2
2
 
3
- const {
4
- difference,
5
- newid
6
- } = require('@toa.io/generic')
7
-
8
- const { EntityContractException } = require('../exceptions')
3
+ const { difference } = require('@toa.io/generic')
4
+ const { EntityContractException, EntityGuardException } = require('../exceptions')
5
+ const { newid } = require('./newid')
9
6
 
10
7
  class Entity {
8
+ deleted = false
11
9
  #schema
10
+ #guards
12
11
  #origin = null
13
12
  #state
13
+ #mutable = true
14
14
 
15
- constructor (schema, argument) {
15
+ /**
16
+ * @param {boolean} [mutable] whether the entity may be modified and committed
17
+ */
18
+ constructor (schema, argument, guards, mutable = true) {
16
19
  this.#schema = schema
20
+ this.#guards = guards
17
21
 
18
22
  if (typeof argument === 'object') {
19
- const object = structuredClone(argument)
20
- this.set(object)
21
- this.#origin = argument
23
+ /*
24
+ * The origin is the pre-image a commit diffs the new state against. An operation
25
+ * that cannot commit has nothing to diff, so it takes the record as it came from
26
+ * the storage instead of paying for a deep copy of every record it read.
27
+ */
28
+ this.#mutable = mutable
29
+
30
+ if (mutable) {
31
+ this.#set(structuredClone(argument))
32
+ this.#origin = argument
33
+ } else
34
+ this.#set(argument)
22
35
  } else {
23
36
  const id = argument === undefined ? newid() : argument
24
37
  this.#init(id)
@@ -29,37 +42,76 @@ class Entity {
29
42
  return this.#state
30
43
  }
31
44
 
32
- set (value) {
33
- const error = this.#schema.fit(value)
45
+ set (value, optional = false) {
46
+ if (!this.#mutable)
47
+ throw new Error('Entity acquired by a read-only operation cannot be modified')
48
+
49
+ if (!optional)
50
+ this.#guard(value)
34
51
 
35
- if (error !== null) throw new EntityContractException(error)
52
+ const error = optional ? this.#schema.fitOptional(value) : this.#schema.fit(value)
36
53
 
54
+ if (error !== null)
55
+ throw new EntityContractException(error, value)
56
+
57
+ this.#revive(value)
37
58
  this.#set(value)
38
59
  }
39
60
 
40
- event () {
61
+ event (input = undefined) {
41
62
  return {
42
63
  origin: this.#origin,
43
64
  state: this.#state,
44
- changeset: this.#origin === null ? this.#state : difference(this.#origin, this.#state)
65
+ changeset: this.#origin === null ? this.#state : difference(this.#origin, this.#state),
66
+ trailers: this.#state._trailers,
67
+ input
45
68
  }
46
69
  }
47
70
 
48
71
  #init (id) {
49
- const value = {
50
- ...this.#schema.defaults({ id }),
51
- _version: 0,
52
- _created: Date.now()
72
+ const value = { id, _version: 0 }
73
+
74
+ this.set(value, true)
75
+ }
76
+
77
+ #guard (value) {
78
+ if (this.#guards === undefined)
79
+ return
80
+
81
+ for (const guard of this.#guards) {
82
+ const ok = guard.fit(value, this.#origin)
83
+
84
+ if (ok === false)
85
+ throw new EntityGuardException(guard.name, value)
53
86
  }
87
+ }
54
88
 
55
- this.#set(value)
89
+ // deletion is only expressed as a new _deleted timestamp,
90
+ // so committing over a tombstone without touching it means revival
91
+ #revive (value) {
92
+ if (this.#origin?._deleted == null || value._deleted !== this.#origin._deleted)
93
+ return
94
+
95
+ value._deleted = null
96
+ this.deleted = false
56
97
  }
57
98
 
58
99
  #set (value) {
59
- Object.defineProperty(value, 'id', {
60
- writable: false,
61
- configurable: false
62
- })
100
+ if (!('_trailers' in value))
101
+ Object.defineProperty(value, '_trailers', {
102
+ writable: false,
103
+ configurable: false,
104
+ enumerable: false,
105
+ value: {}
106
+ })
107
+
108
+ if (!('_created' in value)) {
109
+ value._created = Date.now()
110
+ value._updated ??= value._created
111
+ }
112
+
113
+ if ('_deleted' in value && value._deleted !== null)
114
+ this.deleted = true
63
115
 
64
116
  if (this.#state !== undefined) {
65
117
  value._updated = Date.now()
@@ -1,26 +1,37 @@
1
1
  'use strict'
2
2
 
3
+ const { newid } = require('./newid')
3
4
  const { Entity } = require('./entity')
4
5
  const { EntitySet } = require('./set')
5
6
  const { Changeset } = require('./changeset')
6
7
 
7
8
  class Factory {
8
9
  #schema
10
+ #guards
9
11
 
10
- constructor (schema) {
12
+ constructor (schema, guards) {
11
13
  this.#schema = schema
14
+ this.#guards = guards
15
+ }
16
+
17
+ fit (values) {
18
+ this.#schema.validate({ id: newid(), ...values }, 'Entity')
12
19
  }
13
20
 
14
21
  init (id) {
15
- return new Entity(this.#schema, id)
22
+ return new Entity(this.#schema, id, this.#guards)
16
23
  }
17
24
 
18
- object (record) {
19
- return new Entity(this.#schema, record)
25
+ object (record, mutable = true) {
26
+ return new Entity(this.#schema, record, this.#guards, mutable)
20
27
  }
21
28
 
22
- objects (recordset) {
23
- const set = recordset.map((record) => this.object(record))
29
+ objects (recordset, init, mutable = true) {
30
+ const set = recordset.map((record) => this.object(record, mutable))
31
+
32
+ if (init !== undefined)
33
+ for (const id of init)
34
+ set.unshift(this.init(id))
24
35
 
25
36
  return new EntitySet(set)
26
37
  }
@@ -0,0 +1,11 @@
1
+ const uuid = require('uuid')
2
+
3
+ function newid () {
4
+ const buf = Buffer.alloc(16)
5
+
6
+ uuid.v7(undefined, buf)
7
+
8
+ return buf.toString('hex')
9
+ }
10
+
11
+ module.exports = { newid }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { SystemException } = require("../exceptions")
4
+
3
5
  class EntitySet {
4
6
  #set
5
7
 
@@ -10,6 +12,17 @@ class EntitySet {
10
12
  get () {
11
13
  return this.#set.map((entity) => entity.get())
12
14
  }
15
+
16
+ set (values) {
17
+ if (values.length !== this.#set.length)
18
+ throw new SystemException('Objects array must not be modified')
19
+
20
+ values.forEach((value, index) => this.#set[index].set(value))
21
+ }
22
+
23
+ events (input = undefined) {
24
+ return this.#set.map((entity) => entity.event(input))
25
+ }
13
26
  }
14
27
 
15
28
  exports.EntitySet = EntitySet
package/src/event.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { console, current, encode } = require('openspan')
3
4
  const { Connector } = require('./connector')
4
5
 
5
6
  /**
@@ -12,11 +13,15 @@ class Event extends Connector {
12
13
  #conditioned
13
14
  #subjective
14
15
 
16
+ /** @type {string} */
17
+ #label
18
+
15
19
  constructor (definition, emitter, bridge = undefined) {
16
20
  super()
17
21
 
18
22
  this.#conditioned = definition.conditioned
19
23
  this.#subjective = definition.subjective
24
+ this.#label = definition.label ?? 'event'
20
25
  this.#emitter = emitter
21
26
  this.#bridge = bridge
22
27
 
@@ -32,7 +37,20 @@ class Event extends Connector {
32
37
  /** @type {toa.core.Message} */
33
38
  const message = { payload }
34
39
 
35
- await this.#emitter.emit(message)
40
+ const options = {
41
+ name: `${this.#label} publish`,
42
+ kind: 'producer',
43
+ attributes: { 'messaging.destination.name': this.#label }
44
+ }
45
+
46
+ await console.span(options, async () => {
47
+ const context = current()
48
+
49
+ if (context !== undefined)
50
+ message.telemetry = encode(context)
51
+
52
+ await this.#emitter.emit(message)
53
+ })
36
54
  }
37
55
  }
38
56
  }
package/src/exceptions.js CHANGED
@@ -4,7 +4,6 @@ const { swap } = require('@toa.io/generic')
4
4
 
5
5
  const codes = {
6
6
  System: 0,
7
- NotImplemented: 10,
8
7
 
9
8
  Contract: 200,
10
9
  RequestSyntax: 201,
@@ -12,6 +11,7 @@ const codes = {
12
11
  RequestConflict: 203,
13
12
  ResponseContract: 211,
14
13
  EntityContract: 212,
14
+ EntityGuard: 213,
15
15
  QuerySyntax: 221,
16
16
 
17
17
  State: 300,
@@ -32,9 +32,12 @@ class Exception {
32
32
  code
33
33
  message
34
34
 
35
- constructor (code, message) {
35
+ constructor (code, message, cause) {
36
36
  this.code = code
37
37
  this.message = message
38
+
39
+ if (cause !== undefined)
40
+ this.cause = cause
38
41
  }
39
42
  }
40
43
 
@@ -49,31 +52,30 @@ class SystemException extends Exception {
49
52
  }
50
53
 
51
54
  class ContractException extends Exception {
52
- keyword
53
- property
54
- schema
55
- path
56
-
57
- constructor (code, error) {
58
- super(code || codes.Contract, error.message)
59
-
60
- this.keyword = error.keyword
61
- this.property = error.property
62
- this.schema = error.schema
63
- this.path = error.path
55
+ constructor (code, error, cause) {
56
+ super(code || codes.Contract, typeof error === 'string' ? error : error?.message, cause)
57
+
58
+ if (typeof error === 'object' && error !== null)
59
+ for (const k of ['keyword', 'property', 'schema', 'path', 'params'])
60
+ if (k in error)
61
+ this[k] = error[k]
64
62
  }
65
63
  }
66
64
 
67
65
  class RequestContractException extends ContractException {
68
- constructor (error) { super(codes.RequestContract, error) }
66
+ constructor (error, cause) { super(codes.RequestContract, error, cause) }
69
67
  }
70
68
 
71
69
  class ResponseContractException extends ContractException {
72
- constructor (error) { super(codes.ResponseContract, error) }
70
+ constructor (error, cause) { super(codes.ResponseContract, error, cause) }
73
71
  }
74
72
 
75
73
  class EntityContractException extends ContractException {
76
- constructor (error) { super(codes.EntityContract, error) }
74
+ constructor (error, cause) { super(codes.EntityContract, error, cause) }
75
+ }
76
+
77
+ class EntityGuardException extends ContractException {
78
+ constructor (name, cause) { super(codes.EntityGuard, name, cause) }
77
79
  }
78
80
 
79
81
  // #region exports
@@ -82,14 +84,19 @@ exports.SystemException = SystemException
82
84
  exports.RequestContractException = RequestContractException
83
85
  exports.ResponseContractException = ResponseContractException
84
86
  exports.EntityContractException = EntityContractException
87
+ exports.EntityGuardException = EntityGuardException
85
88
 
86
89
  for (const [name, code] of Object.entries(codes)) {
87
90
  const classname = name + 'Exception'
88
91
 
89
92
  if (exports[classname] === undefined) {
90
93
  exports[classname] = class extends Exception {
91
- constructor (message) {
92
- super(code, message ?? classname)
94
+ constructor (message, cause) {
95
+ message = message
96
+ ? `${classname}: ${message}`
97
+ : classname
98
+
99
+ super(code, message ?? classname, cause)
93
100
  }
94
101
  }
95
102
  }
package/src/exposition.js CHANGED
@@ -20,8 +20,9 @@ class Exposition extends Connector {
20
20
  }
21
21
 
22
22
  const expose = (manifest) => {
23
- const { namespace, name, operations, events, entity } = manifest
24
- return { namespace, name, operations, events, entity }
23
+ const { namespace, name, entity, operations, events } = manifest
24
+
25
+ return { namespace, name, entity, operations, events }
25
26
  }
26
27
 
27
28
  exports.Exposition = Exposition
package/src/guard.js ADDED
@@ -0,0 +1,17 @@
1
+ 'use strict'
2
+
3
+ class Guard {
4
+ name
5
+ #bridge
6
+
7
+ constructor (name, bridge) {
8
+ this.name = name
9
+ this.#bridge = bridge
10
+ }
11
+
12
+ fit (state, origin) {
13
+ return this.#bridge.fit(state, origin)
14
+ }
15
+ }
16
+
17
+ exports.Guard = Guard
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ const { Composition } = require('./composition')
5
5
  const { Connector } = require('./connector')
6
6
  const { Context } = require('./context')
7
7
  const { Discovery } = require('./discovery')
8
+ const { Effect } = require('./effect')
8
9
  const { Emission } = require('./emission')
9
10
  const { Event } = require('./event')
10
11
  const { Exposition } = require('./exposition')
@@ -19,6 +20,8 @@ const { Component } = require('./component')
19
20
  const { State } = require('./state')
20
21
  const { Transition } = require('./transition')
21
22
  const { Transmission } = require('./transmission')
23
+ const { Unmanaged } = require('./unmanaged')
24
+ const { Guard } = require('./guard')
22
25
 
23
26
  exports.entities = require('./entities')
24
27
  exports.exceptions = require('./exceptions')
@@ -32,6 +35,7 @@ exports.Composition = Composition
32
35
  exports.Connector = Connector
33
36
  exports.Context = Context
34
37
  exports.Discovery = Discovery
38
+ exports.Effect = Effect
35
39
  exports.Emission = Emission
36
40
  exports.Event = Event
37
41
  exports.Exposition = Exposition
@@ -45,3 +49,5 @@ exports.Remote = Remote
45
49
  exports.State = State
46
50
  exports.Transition = Transition
47
51
  exports.Transmission = Transmission
52
+ exports.Unmanaged = Unmanaged
53
+ exports.Guard = Guard
@@ -3,16 +3,8 @@
3
3
  const { Operation } = require('./operation')
4
4
 
5
5
  class Observation extends Operation {
6
- async acquire (store) {
7
- const scope = await this.query(store.request.query)
8
- const state = scope === null ? null : scope.get()
9
-
10
- store.scope = scope
11
- store.state = state
12
- }
13
-
14
6
  async run (store) {
15
- if (store.scope === null) store.reply = null
7
+ if (store.scope === null || (store.scope?.deleted === true && store.request.query?.options?.deleted !== true)) store.reply = null
16
8
  else await super.run(store)
17
9
  }
18
10
  }
package/src/operation.js CHANGED
@@ -1,11 +1,21 @@
1
1
  'use strict'
2
2
 
3
3
  const { Connector } = require('./connector')
4
- const { SystemException } = require('./exceptions')
4
+ const { SystemException, RequestContractException } = require('./exceptions')
5
+ const { Readable } = require('node:stream')
5
6
 
6
7
  class Operation extends Connector {
7
8
  scope
8
9
 
10
+ /**
11
+ * Whether what this operation acquires may be modified and committed. Only a
12
+ * transition commits, and only a commit needs the pre-image an entity keeps
13
+ * to diff the new state against.
14
+ *
15
+ * @protected
16
+ */
17
+ mutable = false
18
+
9
19
  #cascade
10
20
  #contracts
11
21
  #query
@@ -26,8 +36,15 @@ class Operation extends Connector {
26
36
 
27
37
  async invoke (request) {
28
38
  try {
29
- if (request.authentic !== true) this.#contracts.request.fit(request)
30
- if ('query' in request) request.query = this.#query.parse(request.query)
39
+ if (request.authentic !== true)
40
+ this.#contracts.request.fit(request)
41
+
42
+ if ('query' in request)
43
+ request.query = this.#query.parse(request.query)
44
+
45
+ // validate entity
46
+ if ('entity' in request)
47
+ this.scope.fit(request.entity)
31
48
 
32
49
  const store = { request }
33
50
 
@@ -47,14 +64,24 @@ class Operation extends Connector {
47
64
  return store.reply
48
65
  }
49
66
 
50
- async acquire () {}
67
+ async acquire (store) {
68
+ if (this.#scope === 'none')
69
+ return
70
+
71
+ const scope = await this.query(store.request.query)
72
+ const raw = scope === null || scope instanceof Readable
73
+
74
+ store.scope = scope
75
+ store.state = raw ? scope : scope.get()
76
+ }
51
77
 
52
78
  async run (store) {
53
79
  const { request, state } = store
54
- // noinspection UnnecessaryLocalVariableJS
55
- const reply = await this.#cascade.run(request.input, state) || {}
80
+ const reply = await this.#cascade.run(request.input, state)
56
81
 
57
- // this.#contracts.reply.fit(reply)
82
+ // validate reply only on local environments
83
+ if (process.env.TOA_ENV === 'local' && !(reply instanceof Readable))
84
+ this.#contracts.reply.fit(reply)
58
85
 
59
86
  store.reply = reply
60
87
  }
@@ -62,7 +89,10 @@ class Operation extends Connector {
62
89
  async commit () {}
63
90
 
64
91
  async query (query) {
65
- return this.scope[this.#scope](query)
92
+ if (query === undefined)
93
+ throw new RequestContractException('Request query is required')
94
+
95
+ return this.scope[this.#scope](query, this.mutable)
66
96
  }
67
97
  }
68
98
 
@@ -33,8 +33,9 @@ const projection = (projection, properties) => {
33
33
  }
34
34
  }
35
35
 
36
- if (projection.includes('_version') === false)
37
- projection.push('_version')
36
+ for (const property of ['_version', '_created', '_updated', '_deleted'])
37
+ if (!projection.includes(property))
38
+ projection.push(property)
38
39
  }
39
40
 
40
41
  exports.options = options
package/src/query.js CHANGED
@@ -7,6 +7,9 @@ class Query {
7
7
  #properties
8
8
  #system
9
9
 
10
+ /** @type {Map<string, object>} parsed criteria by their expression */
11
+ #asts = new Map()
12
+
10
13
  constructor (properties) {
11
14
  this.#properties = properties
12
15
  this.#system = Object.keys(properties).filter((key) => properties[key].system === true)
@@ -19,13 +22,15 @@ class Query {
19
22
  parse (query) {
20
23
  /** @type {toa.core.storages.Query} */
21
24
  const result = {}
22
- const { id, version, criteria, ...rest } = query
25
+ const { id, ids, version, criteria, search, ...rest } = query
23
26
 
24
27
  const options = this.#options(rest)
25
28
 
26
29
  if (id !== undefined) result.id = id
30
+ if (ids !== undefined) result.ids = ids
27
31
  if (version !== undefined) result.version = version
28
- if (criteria !== undefined) result.criteria = parse.criteria(criteria, this.#properties)
32
+ if (criteria !== undefined) result.criteria = this.#criteria(criteria)
33
+ if (search !== undefined) result.search = search
29
34
  if (options !== undefined) result.options = options
30
35
 
31
36
  return result
@@ -36,6 +41,31 @@ class Query {
36
41
 
37
42
  return parse.options(options, this.#properties, this.#system)
38
43
  }
44
+
45
+ /**
46
+ * Lexing an RSQL expression is the most expensive thing this class does, and the same
47
+ * expression arrives over and over: a route builds it from its own declaration and the
48
+ * request parameters. The tree is only read downstream — a storage builds a fresh query
49
+ * from it — so it is kept rather than parsed again.
50
+ *
51
+ * Expressions come from the client, hence the bound. An invalid one throws before it
52
+ * reaches the cache.
53
+ */
54
+ #criteria (criteria) {
55
+ const known = this.#asts.get(criteria)
56
+
57
+ if (known !== undefined) return known
58
+
59
+ const ast = parse.criteria(criteria, this.#properties)
60
+
61
+ if (this.#asts.size >= LIMIT) this.#asts.clear()
62
+
63
+ this.#asts.set(criteria, ast)
64
+
65
+ return ast
66
+ }
39
67
  }
40
68
 
69
+ const LIMIT = 1024
70
+
41
71
  exports.Query = Query