@toa.io/core 1.0.0-alpha.266 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
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.270](https://github.com/toa-io/toa/compare/v1.0.0-alpha.269...v1.0.0-alpha.270) (2026-08-31)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **core:** apply the input default on a remote call ([341699b](https://github.com/toa-io/toa/commit/341699b26c9299963a898368671633a9b3fb97ed))
12
+
13
+
14
+ ### Performance Improvements
15
+
16
+ * **core:** do not snapshot a record an operation cannot commit ([a5e695b](https://github.com/toa-io/toa/commit/a5e695b0b1d4b00a91e40336deecd476c9646c89))
17
+ * **core:** keep the parsed criteria of a query ([7efc694](https://github.com/toa-io/toa/commit/7efc694814c2a071ae7e48dbf1c6014db6025a9f))
18
+ * stop rebuilding per-call values that never change ([4202504](https://github.com/toa-io/toa/commit/4202504c33fb9ef9694f9995f3d0397d3a186438))
19
+
20
+
21
+
22
+
23
+
6
24
  # [1.0.0-alpha.266](https://github.com/toa-io/toa/compare/v1.0.0-alpha.265...v1.0.0-alpha.266) (2026-08-29)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toa.io/core",
3
- "version": "1.0.0-alpha.266",
3
+ "version": "1.0.0-alpha.270",
4
4
  "description": "Toa Core",
5
5
  "author": "temich <tema.gurtovoy@gmail.com>",
6
6
  "homepage": "https://github.com/toa-io/toa#readme",
@@ -24,8 +24,8 @@
24
24
  "@toa.io/generic": "1.0.0-alpha.254",
25
25
  "@toa.io/yaml": "1.0.0-alpha.254",
26
26
  "error-value": "0.3.0",
27
- "openspan": "1.0.0-alpha.264",
27
+ "openspan": "1.0.0-alpha.270",
28
28
  "uuid": "11.1.1"
29
29
  },
30
- "gitHead": "0d2b83ff9ed6bf6d9fee7bf00f979b9ee38e9713"
30
+ "gitHead": "94400bdd411b4c25074ffcf58184580ddd060268"
31
31
  }
package/src/call.js CHANGED
@@ -21,14 +21,16 @@ class Call extends Connector {
21
21
  }
22
22
 
23
23
  async invoke (request = {}) {
24
- request.input ??= null
25
-
26
24
  // the caller may have attributed the call itself, as the node bridge does
27
25
  if (this.#source !== undefined)
28
26
  request.source ??= this.#source
29
27
 
28
+ // fitting first lets the input schema supply its default;
29
+ // an operation that takes no input still has to send an explicit null
30
30
  this.#contract.fit(request)
31
31
 
32
+ request.input ??= null
33
+
32
34
  // avoid validation on the recipient's side
33
35
  request.authentic = true
34
36
 
package/src/component.js CHANGED
@@ -13,6 +13,9 @@ class Component extends Connector {
13
13
  /** @protected */
14
14
  kind = 'server'
15
15
 
16
+ /** @type {Record<string, object>} span options per endpoint */
17
+ #spans = {}
18
+
16
19
  constructor (locator, operations) {
17
20
  super()
18
21
 
@@ -23,8 +26,9 @@ class Component extends Connector {
23
26
  }
24
27
 
25
28
  async invoke (endpoint, request) {
26
- assert.ok(endpoint in this.operations,
27
- `Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
29
+ if (!(endpoint in this.operations))
30
+ // `assert.fail`, not `assert.ok`: the message is built only when it is needed
31
+ assert.fail(`Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
28
32
 
29
33
  // if the request carries no telemetry, the trace starts here
30
34
  const remote = request?.telemetry === undefined ? null : decode(request.telemetry)
@@ -38,14 +42,7 @@ class Component extends Connector {
38
42
 
39
43
  /** @private */
40
44
  async process (endpoint, request) {
41
- const options = { name: `${this.locator.id}.${endpoint}`, kind: this.kind }
42
-
43
- // the server span is emitted by the component itself,
44
- // while the client span belongs to the calling service and inherits it from the context
45
- if (this.kind === 'server')
46
- options.service = this.locator.id
47
-
48
- return console.span(options, async () => {
45
+ return console.span(this.span(endpoint), async () => {
49
46
  const reply = await this.operations[endpoint].invoke(request)
50
47
 
51
48
  if (reply?.exception !== undefined) {
@@ -60,6 +57,29 @@ class Component extends Connector {
60
57
  return reply
61
58
  })
62
59
  }
60
+
61
+ /**
62
+ * The span of an endpoint never changes, so it is built once. Not in the constructor:
63
+ * `kind` is a field of the subclass, and those are assigned after this one is built.
64
+ *
65
+ * @private
66
+ */
67
+ span (endpoint) {
68
+ let options = this.#spans[endpoint]
69
+
70
+ if (options === undefined) {
71
+ options = { name: `${this.locator.id}.${endpoint}`, kind: this.kind }
72
+
73
+ // the server span is emitted by the component itself, while the client span
74
+ // belongs to the calling service and inherits it from the context
75
+ if (this.kind === 'server')
76
+ options.service = this.locator.id
77
+
78
+ this.#spans[endpoint] = options
79
+ }
80
+
81
+ return options
82
+ }
63
83
  }
64
84
 
65
85
  exports.Component = Component
@@ -10,15 +10,28 @@ class Entity {
10
10
  #guards
11
11
  #origin = null
12
12
  #state
13
+ #mutable = true
13
14
 
14
- constructor (schema, argument, guards) {
15
+ /**
16
+ * @param {boolean} [mutable] whether the entity may be modified and committed
17
+ */
18
+ constructor (schema, argument, guards, mutable = true) {
15
19
  this.#schema = schema
16
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)
@@ -30,6 +43,9 @@ class Entity {
30
43
  }
31
44
 
32
45
  set (value, optional = false) {
46
+ if (!this.#mutable)
47
+ throw new Error('Entity acquired by a read-only operation cannot be modified')
48
+
33
49
  if (!optional)
34
50
  this.#guard(value)
35
51
 
@@ -22,12 +22,12 @@ class Factory {
22
22
  return new Entity(this.#schema, id, this.#guards)
23
23
  }
24
24
 
25
- object (record) {
26
- return new Entity(this.#schema, record, this.#guards)
25
+ object (record, mutable = true) {
26
+ return new Entity(this.#schema, record, this.#guards, mutable)
27
27
  }
28
28
 
29
- objects (recordset, init) {
30
- 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
31
 
32
32
  if (init !== undefined)
33
33
  for (const id of init)
package/src/operation.js CHANGED
@@ -7,6 +7,15 @@ const { Readable } = require('node:stream')
7
7
  class Operation extends Connector {
8
8
  scope
9
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
+
10
19
  #cascade
11
20
  #contracts
12
21
  #query
@@ -83,7 +92,7 @@ class Operation extends Connector {
83
92
  if (query === undefined)
84
93
  throw new RequestContractException('Request query is required')
85
94
 
86
- return this.scope[this.#scope](query)
95
+ return this.scope[this.#scope](query, this.mutable)
87
96
  }
88
97
  }
89
98
 
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)
@@ -26,7 +29,7 @@ class Query {
26
29
  if (id !== undefined) result.id = id
27
30
  if (ids !== undefined) result.ids = ids
28
31
  if (version !== undefined) result.version = version
29
- if (criteria !== undefined) result.criteria = parse.criteria(criteria, this.#properties)
32
+ if (criteria !== undefined) result.criteria = this.#criteria(criteria)
30
33
  if (search !== undefined) result.search = search
31
34
  if (options !== undefined) result.options = options
32
35
 
@@ -38,6 +41,31 @@ class Query {
38
41
 
39
42
  return parse.options(options, this.#properties, this.#system)
40
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
+ }
41
67
  }
42
68
 
69
+ const LIMIT = 1024
70
+
43
71
  exports.Query = Query
package/src/receiver.js CHANGED
@@ -33,6 +33,12 @@ class Receiver extends Connector {
33
33
 
34
34
  #bridge
35
35
 
36
+ /** @type {object} */
37
+ #delivery
38
+
39
+ /** @type {object} */
40
+ #processing
41
+
36
42
  constructor (definition, local, bridge) {
37
43
  super()
38
44
 
@@ -51,6 +57,27 @@ class Receiver extends Connector {
51
57
 
52
58
  this.depends(local)
53
59
  if (bridge !== undefined) this.depends(bridge)
60
+
61
+ /*
62
+ * The delivery span is created on behalf of the messaging destination, so that
63
+ * each consumer forms its own complete producer/consumer pair, and service graphs
64
+ * display fan-out correctly: producer -> destination -> each consumer
65
+ * (Tempo pairs spans one-to-one, thus multiple consumers can not pair
66
+ * with a single producer span, see grafana/tempo#5408)
67
+ */
68
+ this.#delivery = {
69
+ name: `${this.#label} deliver`,
70
+ kind: 'producer',
71
+ service: this.#destination,
72
+ attributes: { 'messaging.destination.name': this.#destination }
73
+ }
74
+
75
+ this.#processing = {
76
+ name: `${this.#label} process`,
77
+ kind: 'consumer',
78
+ service: local.locator.id,
79
+ attributes: { 'messaging.destination.name': this.#destination }
80
+ }
54
81
  }
55
82
 
56
83
  /** @hot */
@@ -77,28 +104,7 @@ class Receiver extends Connector {
77
104
  }
78
105
 
79
106
  async #process (request) {
80
- /*
81
- * The delivery span is created on behalf of the messaging destination, so that
82
- * each consumer forms its own complete producer/consumer pair, and service graphs
83
- * display fan-out correctly: producer -> destination -> each consumer
84
- * (Tempo pairs spans one-to-one, thus multiple consumers can not pair
85
- * with a single producer span, see grafana/tempo#5408)
86
- */
87
- const delivery = {
88
- name: `${this.#label} deliver`,
89
- kind: 'producer',
90
- service: this.#destination,
91
- attributes: { 'messaging.destination.name': this.#destination }
92
- }
93
-
94
- const options = {
95
- name: `${this.#label} process`,
96
- kind: 'consumer',
97
- service: this.#local.locator.id,
98
- attributes: { 'messaging.destination.name': this.#destination }
99
- }
100
-
101
- return console.span(delivery, async () => console.span(options, async () => {
107
+ return console.span(this.#delivery, async () => console.span(this.#processing, async () => {
102
108
  try {
103
109
  await this.#local.invoke(this.#endpoint, request)
104
110
  } catch (error) {
package/src/remote.js CHANGED
@@ -7,8 +7,9 @@ class Remote extends Component {
7
7
  kind = 'client'
8
8
 
9
9
  explain (endpoint) {
10
- assert.ok(endpoint in this.operations,
11
- `Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
10
+ if (!(endpoint in this.operations))
11
+ // `assert.fail`, not `assert.ok`: the message is built only when it is needed
12
+ assert.fail(`Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
12
13
 
13
14
  return this.operations[endpoint].explain()
14
15
  }
package/src/state.js CHANGED
@@ -24,7 +24,7 @@ class State {
24
24
  return this.#entities.fit(values)
25
25
  }
26
26
 
27
- async object (query) {
27
+ async object (query, mutable = true) {
28
28
  const record = await this.storage.get(query)
29
29
 
30
30
  if (record === null) {
@@ -35,15 +35,15 @@ class State {
35
35
 
36
36
  return null
37
37
  } else
38
- return this.#entities.object(record)
38
+ return this.#entities.object(record, mutable)
39
39
  }
40
40
 
41
- async objects (query) {
41
+ async objects (query, mutable = true) {
42
42
  const recordset = await this.storage.find(query)
43
43
  const missing = this.#associated && query.ids !== undefined && recordset.length < query.ids.length
44
44
  const init = missing ? query.ids.filter((id) => !recordset.some((record) => record.id === id)) : undefined
45
45
 
46
- return this.#entities.objects(recordset, init)
46
+ return this.#entities.objects(recordset, init, mutable)
47
47
  }
48
48
 
49
49
  async stream (query) {
@@ -69,7 +69,7 @@ class State {
69
69
  const record = await this.storage.ensure(query, properties, object.get())
70
70
 
71
71
  if (record.id !== blank.id) // exists
72
- return this.#entities.object(record)
72
+ return this.#entities.object(record, NOT_MUTABLE)
73
73
 
74
74
  const event = object.event(input)
75
75
 
@@ -132,4 +132,7 @@ class State {
132
132
  }
133
133
  }
134
134
 
135
+ /** an effect never commits what `ensure` hands it, see `Effect` */
136
+ const NOT_MUTABLE = false
137
+
135
138
  exports.State = State
package/src/transition.js CHANGED
@@ -5,6 +5,9 @@ const { Operation } = require('./operation')
5
5
  const { StateConcurrencyException, StateNotFoundException } = require('./exceptions')
6
6
 
7
7
  class Transition extends Operation {
8
+ /** a transition is the only operation that commits */
9
+ mutable = true
10
+
8
11
  #concurrency
9
12
 
10
13
  constructor (cascade, scope, contract, query, definition) {
@@ -14,6 +14,37 @@ describe('argument', () => {
14
14
 
15
15
  expect(entity.get()).toEqual(state)
16
16
  })
17
+
18
+ it('should snapshot the record it may commit', () => {
19
+ const record = fixtures.state()
20
+ const entity = new Entity(fixtures.schema, record)
21
+
22
+ expect(entity.get()).not.toBe(record)
23
+ expect(entity.event().origin).toBe(record)
24
+ })
25
+ })
26
+
27
+ describe('read-only', () => {
28
+ it('should take the record as it came', () => {
29
+ const record = fixtures.state()
30
+ const entity = new Entity(fixtures.schema, record, undefined, false)
31
+
32
+ // no pre-image to diff against, hence no copy of it
33
+ expect(entity.get()).toBe(record)
34
+ })
35
+
36
+ it('should still report a tombstone', () => {
37
+ const record = { ...fixtures.state(), _deleted: Date.now() }
38
+ const entity = new Entity(fixtures.schema, record, undefined, false)
39
+
40
+ expect(entity.deleted).toBe(true)
41
+ })
42
+
43
+ it('should refuse to be modified', () => {
44
+ const entity = new Entity(fixtures.schema, fixtures.state(), undefined, false)
45
+
46
+ expect(() => entity.set(entity.get())).toThrow('read-only')
47
+ })
17
48
  })
18
49
 
19
50
  describe('tombstone', () => {
@@ -30,7 +30,8 @@ it('should create instance', () => {
30
30
  const object = factory.object(fixtures.entity)
31
31
 
32
32
  expect(object).toBeInstanceOf(mock.Entity)
33
- expect(object.constructor).toHaveBeenCalledWith(fixtures.schema, fixtures.entity, expect.any(Function))
33
+ expect(object.constructor)
34
+ .toHaveBeenCalledWith(fixtures.schema, fixtures.entity, expect.any(Function), true)
34
35
  })
35
36
 
36
37
  it('should create set', () => {
@@ -39,7 +40,8 @@ it('should create set', () => {
39
40
  expect(objects).toBeInstanceOf(mock.EntitySet)
40
41
 
41
42
  const instances = fixtures.set.map((entity, index) => {
42
- expect(mock.Entity).toHaveBeenNthCalledWith(index + 1, fixtures.schema, entity, expect.any(Function))
43
+ expect(mock.Entity)
44
+ .toHaveBeenNthCalledWith(index + 1, fixtures.schema, entity, expect.any(Function), true)
43
45
 
44
46
  return mock.Entity.mock.instances[index]
45
47
  })
@@ -22,6 +22,23 @@ describe('criteria', () => {
22
22
  expect(query.criteria).toEqual(fixtures.samples.simple.parsed.criteria)
23
23
  })
24
24
 
25
+ it('should keep a parsed criteria', () => {
26
+ const instance = new Query(fixtures.samples.simple.properties)
27
+
28
+ const first = instance.parse(fixtures.samples.simple.query).criteria
29
+ const second = instance.parse(fixtures.samples.simple.query).criteria
30
+
31
+ expect(second).toBe(first)
32
+ })
33
+
34
+ it('should not keep an invalid criteria', () => {
35
+ const instance = new Query(fixtures.samples.simple.properties)
36
+ const query = { criteria: 'nonexistent==1' }
37
+
38
+ expect(() => instance.parse(query)).toThrow()
39
+ expect(() => instance.parse(query)).toThrow()
40
+ })
41
+
25
42
  it('should parse criteria with type coercion', () => {
26
43
  const instance = new Query(fixtures.samples.extended.properties)
27
44
  const query = instance.parse(fixtures.samples.extended.query)
@@ -16,7 +16,22 @@ it('should provide object', async () => {
16
16
 
17
17
  expect(fixtures.storage.get).toHaveBeenCalledWith(fixtures.query)
18
18
  expect(entity).toStrictEqual(fixtures.factory.object.mock.results[0].value)
19
- expect(fixtures.factory.object).toHaveBeenCalledWith(fixtures.storage.get.mock.results[0].value)
19
+ expect(fixtures.factory.object)
20
+ .toHaveBeenCalledWith(fixtures.storage.get.mock.results[0].value, true)
21
+ })
22
+
23
+ it('should provide read-only object', async () => {
24
+ await state.object(fixtures.query, false)
25
+
26
+ expect(fixtures.factory.object)
27
+ .toHaveBeenCalledWith(fixtures.storage.get.mock.results[0].value, false)
28
+ })
29
+
30
+ it('should provide read-only objects', async () => {
31
+ await state.objects(fixtures.query, false)
32
+
33
+ expect(fixtures.factory.objects)
34
+ .toHaveBeenCalledWith(fixtures.storage.find.mock.results[0].value, undefined, false)
20
35
  })
21
36
 
22
37
  it('should store entity', async () => {
package/types/entity.d.ts CHANGED
@@ -8,9 +8,9 @@ declare namespace toa.core {
8
8
  interface Factory {
9
9
  init(id: string): Entity
10
10
 
11
- object(record: Object): Entity
11
+ object(record: Object, mutable?: boolean): Entity
12
12
 
13
- objects(recordset: Object[]): Entity[]
13
+ objects(recordset: Object[], init?: string[], mutable?: boolean): Entity[]
14
14
 
15
15
  changeset(query: _storages.Query): Changeset
16
16
  }
package/types/state.d.ts CHANGED
@@ -16,9 +16,9 @@ declare namespace toa.core {
16
16
  interface State {
17
17
  init(id: string): _entity.Entity
18
18
 
19
- object(query: _storages.Query): Promise<_entity.Entity>
19
+ object(query: _storages.Query, mutable?: boolean): Promise<_entity.Entity>
20
20
 
21
- objects(query: _storages.Query): Promise<_entity.Entity[]>
21
+ objects(query: _storages.Query, mutable?: boolean): Promise<_entity.Entity[]>
22
22
 
23
23
  changeset(query: _storages.Query): _entity.Changeset
24
24