@toa.io/core 1.0.0-alpha.292 → 1.0.0-alpha.293
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/package.json +10 -4
- package/CHANGELOG.md +0 -43
- package/source/assignment.ts +0 -27
- package/source/call.ts +0 -63
- package/source/cascade.ts +0 -32
- package/source/component.ts +0 -83
- package/source/composition.ts +0 -27
- package/source/connector.ts +0 -217
- package/source/context.ts +0 -68
- package/source/contract/contract.ts +0 -23
- package/source/contract/index.ts +0 -2
- package/source/contract/reply.ts +0 -56
- package/source/contract/request.ts +0 -140
- package/source/contract/schemas.ts +0 -39
- package/source/discovery.ts +0 -53
- package/source/effect.ts +0 -14
- package/source/emission.ts +0 -21
- package/source/entities/changeset.ts +0 -37
- package/source/entities/entity.ts +0 -137
- package/source/entities/factory.ts +0 -52
- package/source/entities/index.ts +0 -4
- package/source/entities/newid.ts +0 -9
- package/source/entities/set.ts +0 -27
- package/source/event.ts +0 -57
- package/source/exceptions.ts +0 -133
- package/source/exposition.ts +0 -33
- package/source/guard.ts +0 -16
- package/source/index.ts +0 -36
- package/source/locator.ts +0 -37
- package/source/observation.ts +0 -16
- package/source/operation.ts +0 -139
- package/source/outbox/index.ts +0 -1
- package/source/outbox/outbox.ts +0 -341
- package/source/query/criteria.ts +0 -90
- package/source/query/options.ts +0 -35
- package/source/query.ts +0 -67
- package/source/receiver.ts +0 -120
- package/source/reflection.ts +0 -20
- package/source/remote.ts +0 -20
- package/source/state.ts +0 -179
- package/source/transition.ts +0 -79
- package/source/transmission.ts +0 -38
- package/source/types/atomicity.ts +0 -63
- package/source/types/bindings.ts +0 -59
- package/source/types/bridges.ts +0 -54
- package/source/types/extensions.ts +0 -89
- package/source/types/index.ts +0 -14
- package/source/types/message.ts +0 -5
- package/source/types/operations.ts +0 -31
- package/source/types/outbox.ts +0 -35
- package/source/types/receiver.ts +0 -10
- package/source/types/request.ts +0 -54
- package/source/types/state.ts +0 -9
- package/source/types/storages.ts +0 -134
- package/source/unmanaged.ts +0 -8
- package/test/call.fixtures.js +0 -25
- package/test/call.test.js +0 -79
- package/test/component.fixtures.js +0 -18
- package/test/component.test.js +0 -57
- package/test/connector.fixtures.js +0 -56
- package/test/connector.test.js +0 -266
- package/test/context.fixtures.js +0 -14
- package/test/context.test.js +0 -40
- package/test/contract/conditions.test.js +0 -32
- package/test/contract/contract.fixtures.js +0 -28
- package/test/contract/request.test.js +0 -196
- package/test/discovery.test.js +0 -71
- package/test/emission.fixtures.js +0 -13
- package/test/emission.test.js +0 -60
- package/test/entities/entity.fixtures.js +0 -24
- package/test/entities/entity.test.js +0 -137
- package/test/entities/factory.fixtures.js +0 -31
- package/test/entities/factory.test.js +0 -99
- package/test/entities/set.fixtures.js +0 -9
- package/test/entities/set.test.js +0 -13
- package/test/event.fixtures.js +0 -24
- package/test/event.test.js +0 -162
- package/test/locator.test.js +0 -120
- package/test/outbox.test.js +0 -202
- package/test/query.fixtures.js +0 -96
- package/test/query.test.js +0 -183
- package/test/receiver.fixtures.js +0 -20
- package/test/receiver.test.js +0 -144
- package/test/reflection.test.js +0 -31
- package/test/state.fixtures.js +0 -49
- package/test/state.test.js +0 -162
- package/test/transmission.fixtures.js +0 -13
- package/test/transmission.test.js +0 -77
- package/tsconfig.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
package/source/outbox/outbox.ts
DELETED
|
@@ -1,341 +0,0 @@
|
|
|
1
|
-
import { console } from 'openspan'
|
|
2
|
-
import { Connector } from '../connector.js'
|
|
3
|
-
import { newid } from '../entities/newid.js'
|
|
4
|
-
import { environment } from '@toa.io/generic'
|
|
5
|
-
import type { Emission } from '../emission.js'
|
|
6
|
-
import type { Atom } from '../types/atomicity.js'
|
|
7
|
-
import type { Storage } from '../types/storages.js'
|
|
8
|
-
import type { Row } from '../types/outbox.js'
|
|
9
|
-
import type { Event } from '../types/state.js'
|
|
10
|
-
|
|
11
|
-
export interface Options {
|
|
12
|
-
interval?: number
|
|
13
|
-
batch?: number
|
|
14
|
-
gap?: number
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Owns the intent to publish. A row is built before the write so that the storage can commit
|
|
19
|
-
* it in the same transaction as the entity; publication then happens off the operation's path,
|
|
20
|
-
* and anything that fails to publish is recovered from the row.
|
|
21
|
-
*
|
|
22
|
-
* The mechanism is a safety net: in a healthy system the row is written, published within
|
|
23
|
-
* milliseconds by the same process, and marked published on that process's next tick.
|
|
24
|
-
*
|
|
25
|
-
* A storage that cannot commit a row atomically has no outbox, and this degrades to the
|
|
26
|
-
* inline emission it replaces.
|
|
27
|
-
*/
|
|
28
|
-
export class Outbox extends Connector {
|
|
29
|
-
readonly #emission: Emission
|
|
30
|
-
readonly #storage: Storage | undefined
|
|
31
|
-
readonly #atom: Atom
|
|
32
|
-
|
|
33
|
-
readonly #gap: number
|
|
34
|
-
readonly #interval: number
|
|
35
|
-
readonly #batch: number
|
|
36
|
-
readonly #defer: boolean
|
|
37
|
-
|
|
38
|
-
/** ids this process has published, held until a cycle marks them */
|
|
39
|
-
readonly #published = new Set<string>()
|
|
40
|
-
|
|
41
|
-
/** in-flight publications, awaited (with a bound) on close */
|
|
42
|
-
readonly #inflight = new Set<Promise<void>>()
|
|
43
|
-
|
|
44
|
-
/** rows this replica is publishing right now, so a cycle does not pick them up again */
|
|
45
|
-
readonly #publishing = new Set<string>()
|
|
46
|
-
|
|
47
|
-
#timer: NodeJS.Timeout | undefined
|
|
48
|
-
#off: (() => void) | undefined
|
|
49
|
-
#pumping = false
|
|
50
|
-
#closing = false
|
|
51
|
-
|
|
52
|
-
// eslint-disable-next-line max-params
|
|
53
|
-
public constructor(
|
|
54
|
-
emission: Emission,
|
|
55
|
-
storage: Storage | undefined,
|
|
56
|
-
atom: Atom,
|
|
57
|
-
options: Options = {}
|
|
58
|
-
) {
|
|
59
|
-
super()
|
|
60
|
-
|
|
61
|
-
this.#emission = emission
|
|
62
|
-
this.#storage = storage
|
|
63
|
-
this.#atom = atom
|
|
64
|
-
|
|
65
|
-
this.#interval = number('TOA_OUTBOX_INTERVAL', options.interval, INTERVAL)
|
|
66
|
-
this.#batch = number('TOA_OUTBOX_BATCH', options.batch, BATCH)
|
|
67
|
-
this.#gap = options.gap ?? this.#interval * K
|
|
68
|
-
this.#defer = environment.get('TOA_OUTBOX_DEFER') === '1'
|
|
69
|
-
|
|
70
|
-
this.depends(emission)
|
|
71
|
-
this.depends(atom)
|
|
72
|
-
|
|
73
|
-
if (storage !== undefined) this.depends(storage)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** whether the storage can commit a row atomically with the entity */
|
|
77
|
-
public get durable(): boolean {
|
|
78
|
-
return this.#storage?.outbox !== undefined
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* An assignment's images are the write's own, so it hands over an event with neither, and
|
|
83
|
-
* the storage fills them in.
|
|
84
|
-
*/
|
|
85
|
-
public row(event: Partial<Event>): Row {
|
|
86
|
-
return {
|
|
87
|
-
id: newid(),
|
|
88
|
-
lane: this.#lane(),
|
|
89
|
-
published: false,
|
|
90
|
-
pending: Date.now() + this.#gap,
|
|
91
|
-
event: event as Event
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Hands a committed row over. Awaited by the caller only on the legacy path — with an
|
|
97
|
-
* outbox this returns at once and the broker leaves the operation's path.
|
|
98
|
-
*/
|
|
99
|
-
public publish(row: Row): Promise<void> | void {
|
|
100
|
-
// without a durable outbox this is the inline path, and the caller awaits the emission
|
|
101
|
-
if (!this.durable) return this.#emission.emit(row.event)
|
|
102
|
-
|
|
103
|
-
/*
|
|
104
|
-
* A publication started while the pump is closing would outlive the emitters it needs,
|
|
105
|
-
* and `comq` waits on a connection that is going rather than failing. The row is already
|
|
106
|
-
* durable, so leaving it is exactly what it is for.
|
|
107
|
-
*/
|
|
108
|
-
if (
|
|
109
|
-
this.#closing ||
|
|
110
|
-
this.#defer ||
|
|
111
|
-
this.#inflight.size >= INFLIGHT ||
|
|
112
|
-
this.#published.size >= PUBLISHED
|
|
113
|
-
)
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
void this.#publish(row)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
protected override async open(): Promise<void> {
|
|
120
|
-
if (!this.durable) return
|
|
121
|
-
|
|
122
|
-
if (this.#defer)
|
|
123
|
-
console.warn(
|
|
124
|
-
'Outbox immediate publication is deferred; events are published by the pump only'
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
this.#timer = setInterval(() => {
|
|
128
|
-
this.#tick()
|
|
129
|
-
}, this.#interval)
|
|
130
|
-
this.#timer.unref()
|
|
131
|
-
|
|
132
|
-
/*
|
|
133
|
-
* A lane changing hands is exactly when rows stranded in it become this replica's to
|
|
134
|
-
* publish, and the cycle would not notice for up to an interval. Being told costs a cycle
|
|
135
|
-
* that finds nothing in the usual case, where the claim arrives once and never changes.
|
|
136
|
-
*/
|
|
137
|
-
this.#off = this.#atom.onassigned(() => {
|
|
138
|
-
this.#tick()
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
protected override async close(): Promise<void> {
|
|
143
|
-
this.#closing = true
|
|
144
|
-
|
|
145
|
-
this.#off?.()
|
|
146
|
-
|
|
147
|
-
if (this.#timer !== undefined) clearInterval(this.#timer)
|
|
148
|
-
|
|
149
|
-
await this.#drain()
|
|
150
|
-
await this.#mark()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Publishes one row and swallows the failure: the row stays unpublished and comes back on a
|
|
155
|
-
* later cycle, which is the whole point of having written it.
|
|
156
|
-
*
|
|
157
|
-
* There is no timeout here on purpose. A publication is a confirmed write to a durable
|
|
158
|
-
* exchange, and `comq` waits for the broker to come back rather than failing — abandoning
|
|
159
|
-
* it would not stop it, it would only mean the row is published twice once it lands. What
|
|
160
|
-
* bounds this instead is the in-flight cap and the drain on close.
|
|
161
|
-
*
|
|
162
|
-
*/
|
|
163
|
-
async #publish(row: Row): Promise<void> {
|
|
164
|
-
this.#publishing.add(row.id)
|
|
165
|
-
|
|
166
|
-
const publishing = this.#emission.emit(row.event)
|
|
167
|
-
|
|
168
|
-
this.#inflight.add(publishing)
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
await publishing
|
|
172
|
-
|
|
173
|
-
this.#published.add(row.id)
|
|
174
|
-
} catch (error) {
|
|
175
|
-
console.warn('Event publication failed', { row: row.id, error })
|
|
176
|
-
} finally {
|
|
177
|
-
this.#inflight.delete(publishing)
|
|
178
|
-
this.#publishing.delete(row.id)
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* `comq` retries a publish for as long as the broker is down rather than rejecting, so an
|
|
184
|
-
* unbounded drain outlives any grace period.
|
|
185
|
-
*
|
|
186
|
-
*/
|
|
187
|
-
async #drain(): Promise<void> {
|
|
188
|
-
if (this.#inflight.size === 0) return
|
|
189
|
-
|
|
190
|
-
await Promise.race([Promise.allSettled([...this.#inflight]), delay(DRAIN)])
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Reads what is due, publishes it, and marks everything this process has sent — what it just
|
|
195
|
-
* published and what the immediate path published since the last cycle. One cycle at a time.
|
|
196
|
-
*
|
|
197
|
-
*/
|
|
198
|
-
#tick(): void {
|
|
199
|
-
if (this.#pumping) return
|
|
200
|
-
|
|
201
|
-
this.#pumping = true
|
|
202
|
-
|
|
203
|
-
void this.#pump().finally(() => (this.#pumping = false))
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async #pump(): Promise<void> {
|
|
207
|
-
let page: Row[]
|
|
208
|
-
let after: string | undefined
|
|
209
|
-
|
|
210
|
-
do {
|
|
211
|
-
page = await this.#read(after)
|
|
212
|
-
|
|
213
|
-
if (page.length === 0) break
|
|
214
|
-
|
|
215
|
-
// so a page is never read twice
|
|
216
|
-
after = page[page.length - 1]?.id
|
|
217
|
-
|
|
218
|
-
/*
|
|
219
|
-
* A row is unpublished in the database until a cycle marks it, so a page includes what
|
|
220
|
-
* this replica is sending right now and what a failed marking left behind. Only this
|
|
221
|
-
* process knows either.
|
|
222
|
-
*/
|
|
223
|
-
const rows = page.filter(
|
|
224
|
-
(row) => !this.#published.has(row.id) && !this.#publishing.has(row.id)
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
if (rows.length > 0) {
|
|
228
|
-
console.info('Outbox recovering unpublished events', { count: rows.length })
|
|
229
|
-
|
|
230
|
-
// every row is given its chance; what the broker refused stays unpublished and comes
|
|
231
|
-
// back on a later cycle
|
|
232
|
-
await Promise.allSettled(rows.map(async (row) => this.#publish(row)))
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// a full page is a page that may have been cut short
|
|
236
|
-
} while (page.length === this.#batch)
|
|
237
|
-
|
|
238
|
-
await this.#mark()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* One page of what is due. In a healthy system the first one is empty, every cycle — a row is
|
|
243
|
-
* due only if the process that wrote it failed to publish or died before marking it.
|
|
244
|
-
*
|
|
245
|
-
* Reading is suspended, not stopped, while this replica does not know which lanes are its
|
|
246
|
-
* own: the cycle keeps running and keeps marking, and reading resumes as soon as an
|
|
247
|
-
* assignment arrives. Reading without an assignment would be a different guarantee, where
|
|
248
|
-
* every replica publishes every stranded row.
|
|
249
|
-
*
|
|
250
|
-
* @param after the last id of the page before, so a page is never read twice
|
|
251
|
-
*/
|
|
252
|
-
async #read(after?: string): Promise<Row[]> {
|
|
253
|
-
const lanes = this.#atom.slots(LANES)
|
|
254
|
-
|
|
255
|
-
if (lanes === null || lanes.length === 0) return []
|
|
256
|
-
|
|
257
|
-
return this.#storage!
|
|
258
|
-
.outbox!.pending(lanes, Date.now(), this.#batch, after)
|
|
259
|
-
.catch((error) => {
|
|
260
|
-
console.warn('Outbox read failed', { error })
|
|
261
|
-
|
|
262
|
-
return []
|
|
263
|
-
})
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* One batched write for many events, which is why the ids are held in memory rather than
|
|
268
|
-
* marked one by one. Ids that fail to be marked are kept and retried; a row that is never
|
|
269
|
-
* marked is simply published again, which is within the contract.
|
|
270
|
-
*
|
|
271
|
-
*/
|
|
272
|
-
async #mark(): Promise<void> {
|
|
273
|
-
if (this.#published.size === 0) return
|
|
274
|
-
|
|
275
|
-
const ids = [...this.#published]
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
await this.#storage!.outbox!.settle(ids)
|
|
279
|
-
|
|
280
|
-
for (const id of ids) this.#published.delete(id)
|
|
281
|
-
} catch (error) {
|
|
282
|
-
console.warn('Outbox marking failed', { count: ids.length, error })
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* A lane this replica currently owns, so that in steady state it settles its own rows
|
|
288
|
-
* before it ever reads them. Any lane at all when it owns none: the row still has to be
|
|
289
|
-
* written, and whoever ends up owning that lane will pump it.
|
|
290
|
-
*
|
|
291
|
-
*/
|
|
292
|
-
#lane(): number {
|
|
293
|
-
const owned = this.#atom.slots(LANES)
|
|
294
|
-
|
|
295
|
-
return owned === null || owned.length === 0
|
|
296
|
-
? Math.floor(Math.random() * LANES)
|
|
297
|
-
: owned[Math.floor(Math.random() * owned.length)]
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function number(
|
|
302
|
-
variable: string,
|
|
303
|
-
declared: number | undefined,
|
|
304
|
-
fallback: number
|
|
305
|
-
): number {
|
|
306
|
-
if (declared !== undefined) return declared
|
|
307
|
-
|
|
308
|
-
const value = Number(environment.get(variable))
|
|
309
|
-
|
|
310
|
-
return Number.isNaN(value) || value <= 0 ? fallback : value
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async function delay(ms: number): Promise<void> {
|
|
314
|
-
return new Promise((resolve) => {
|
|
315
|
-
setTimeout(resolve, ms).unref()
|
|
316
|
-
})
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Constant, never configuration: rows carry their lane, so lowering this would leave rows in
|
|
321
|
-
* lanes nobody reads any more. It is also the ceiling on replicas of one component, and a
|
|
322
|
-
* power of two so that the common replica counts divide evenly.
|
|
323
|
-
*/
|
|
324
|
-
export const LANES = 128
|
|
325
|
-
|
|
326
|
-
/** one cycle reads, publishes and marks; in steady state it finds nothing to read */
|
|
327
|
-
const INTERVAL = 5000
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* `gap = interval * K`. Not a steady-state necessity — a replica writes into a lane it owns
|
|
331
|
-
* and marks what it published — but a guard for when a lane changes hands between the write
|
|
332
|
-
* and the settle. Two cycles of separation, plus one of margin.
|
|
333
|
-
*/
|
|
334
|
-
const K = 3
|
|
335
|
-
|
|
336
|
-
/** how many rows one read brings back; the pump reads on while a page comes back full */
|
|
337
|
-
const BATCH = 200
|
|
338
|
-
|
|
339
|
-
const DRAIN = 10_000
|
|
340
|
-
const INFLIGHT = 1000
|
|
341
|
-
const PUBLISHED = 10_000
|
package/source/query/criteria.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { parse } from '@rsql/parser'
|
|
2
|
-
import { QuerySyntaxException } from '../exceptions.js'
|
|
3
|
-
import type { Node } from '../types/storages.js'
|
|
4
|
-
|
|
5
|
-
/** What a component declares about the properties a criteria may select on. */
|
|
6
|
-
export type Properties = Record<string, { type: string }>
|
|
7
|
-
|
|
8
|
-
export function criteria(expression: string, properties?: Properties): Node {
|
|
9
|
-
let ast: Node
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
ast = parse(expression) as unknown as Node
|
|
13
|
-
} catch (e) {
|
|
14
|
-
throw new QuerySyntaxException((e as Error).message)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
if (properties !== undefined) read(ast, properties)
|
|
18
|
-
|
|
19
|
-
return ast
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function read(node: Node, properties: Properties): void {
|
|
23
|
-
if (
|
|
24
|
-
node.type === 'COMPARISON' &&
|
|
25
|
-
node.left?.type === 'SELECTOR' &&
|
|
26
|
-
node.right?.type === 'VALUE'
|
|
27
|
-
) {
|
|
28
|
-
const selector = node.left.selector as string
|
|
29
|
-
const property = properties[selector]
|
|
30
|
-
|
|
31
|
-
if (property === undefined) {
|
|
32
|
-
throw new QuerySyntaxException(`Criteria selector '${selector}' is not defined`)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const cast = CAST[property.type]
|
|
36
|
-
|
|
37
|
-
// `=in=` and `=out=` carry a list, and casting that as one value gives whatever
|
|
38
|
-
// a comma-separated string reads as
|
|
39
|
-
if (cast !== undefined)
|
|
40
|
-
node.right.value = Array.isArray(node.right.value)
|
|
41
|
-
? node.right.value.map((value: string) => cast(value, selector))
|
|
42
|
-
: cast(node.right.value as string, selector)
|
|
43
|
-
} else {
|
|
44
|
-
if (node.left !== undefined) read(node.left, properties)
|
|
45
|
-
if (node.right !== undefined) read(node.right, properties)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* A criteria arrives as text, so a value is read as what the property it selects on holds. What
|
|
51
|
-
* cannot be read as that is refused rather than passed on: `parseInt` answers `NaN` for a word
|
|
52
|
-
* and `12` for `12kg`, and a storage asked to match either finds nothing and says nothing —
|
|
53
|
-
* a mistyped filter reads as an empty result.
|
|
54
|
-
*
|
|
55
|
-
* A string is left as it came, because every text is one: what a criteria may compare a string
|
|
56
|
-
* to is not the type's to say.
|
|
57
|
-
*/
|
|
58
|
-
const CAST: Record<string, (value: string, selector: string) => unknown> = {
|
|
59
|
-
number: (value, selector) => finite(value, selector, 'a number'),
|
|
60
|
-
|
|
61
|
-
integer: (value, selector) => {
|
|
62
|
-
const number = finite(value, selector, 'an integer')
|
|
63
|
-
|
|
64
|
-
if (!Number.isInteger(number)) refuse(selector, 'an integer', value)
|
|
65
|
-
|
|
66
|
-
return number
|
|
67
|
-
},
|
|
68
|
-
|
|
69
|
-
boolean: (value, selector) => {
|
|
70
|
-
if (value === 'true') return true
|
|
71
|
-
if (value === 'false') return false
|
|
72
|
-
|
|
73
|
-
return refuse(selector, 'a boolean', value)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function finite(value: string, selector: string, expected: string): number {
|
|
78
|
-
// `Number` reads a blank string as zero, and a criteria that says nothing says nothing
|
|
79
|
-
const number = value.trim() === '' ? Number.NaN : Number(value)
|
|
80
|
-
|
|
81
|
-
if (!Number.isFinite(number)) refuse(selector, expected, value)
|
|
82
|
-
|
|
83
|
-
return number
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function refuse(selector: string, expected: string, value: string): never {
|
|
87
|
-
throw new QuerySyntaxException(
|
|
88
|
-
`Criteria selector '${selector}' takes ${expected}, and '${value}' is not one`
|
|
89
|
-
)
|
|
90
|
-
}
|
package/source/query/options.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { QuerySyntaxException } from '../exceptions.js'
|
|
2
|
-
import type { Options } from '../types/storages.js'
|
|
3
|
-
import type { Properties } from './criteria.js'
|
|
4
|
-
|
|
5
|
-
export function options(given: Record<string, any>, properties: Properties): Options {
|
|
6
|
-
if (given.sort !== undefined) given.sort = sort(given.sort, properties)
|
|
7
|
-
|
|
8
|
-
if (given.projection !== undefined) projection(given.projection, properties)
|
|
9
|
-
|
|
10
|
-
return given as Options
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function sort(sort: string[], properties: Properties): Array<[string, string]> {
|
|
14
|
-
const result: Array<[string, string]> = []
|
|
15
|
-
|
|
16
|
-
for (const sorting of sort) {
|
|
17
|
-
const [property, direction] = sorting.split(':')
|
|
18
|
-
|
|
19
|
-
if (properties[property] === undefined)
|
|
20
|
-
throw new QuerySyntaxException(`Sort property '${property}' is not defined`)
|
|
21
|
-
|
|
22
|
-
result.push([property, direction ?? 'asc'])
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return result
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function projection(projection: string[], properties: Properties): void {
|
|
29
|
-
for (const property of projection)
|
|
30
|
-
if (properties[property] === undefined)
|
|
31
|
-
throw new QuerySyntaxException(`Projection property '${property}' is not defined`)
|
|
32
|
-
|
|
33
|
-
for (const property of ['VERSION', 'CREATED', 'UPDATED', 'DELETED'])
|
|
34
|
-
if (!projection.includes(property)) projection.push(property)
|
|
35
|
-
}
|
package/source/query.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { empty } from '@toa.io/generic'
|
|
2
|
-
import * as criteria from './query/criteria.js'
|
|
3
|
-
import * as options from './query/options.js'
|
|
4
|
-
import type { Properties } from './query/criteria.js'
|
|
5
|
-
import type { Node, Options, Query as Parsed } from './types/storages.js'
|
|
6
|
-
import type { Query as Requested } from './types/request.js'
|
|
7
|
-
|
|
8
|
-
const parse = { ...criteria, ...options }
|
|
9
|
-
|
|
10
|
-
/** Translates the query a request carries into the one a storage is given. */
|
|
11
|
-
export class Query {
|
|
12
|
-
readonly #properties: Properties
|
|
13
|
-
|
|
14
|
-
/** parsed criteria by their expression */
|
|
15
|
-
readonly #asts = new Map<string, Node>()
|
|
16
|
-
|
|
17
|
-
public constructor(properties: Properties) {
|
|
18
|
-
this.#properties = properties
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
public parse(query: Requested): Parsed {
|
|
22
|
-
const result: Parsed = {}
|
|
23
|
-
const { id, ids, version, criteria, search, ...rest } = query
|
|
24
|
-
|
|
25
|
-
const options = this.#options(rest)
|
|
26
|
-
|
|
27
|
-
if (id !== undefined) result.id = id
|
|
28
|
-
if (ids !== undefined) result.ids = ids
|
|
29
|
-
if (version !== undefined) result.version = version
|
|
30
|
-
if (criteria !== undefined) result.criteria = this.#criteria(criteria)
|
|
31
|
-
if (search !== undefined) result.search = search
|
|
32
|
-
if (options !== undefined) result.options = options
|
|
33
|
-
|
|
34
|
-
return result
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
#options(given: Record<string, any>): Options | undefined {
|
|
38
|
-
if (empty(given)) return undefined
|
|
39
|
-
|
|
40
|
-
return parse.options(given, this.#properties)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Lexing an RSQL expression is the most expensive thing this class does, and the same
|
|
45
|
-
* expression arrives over and over: a route builds it from its own declaration and the
|
|
46
|
-
* request parameters. The tree is only read downstream — a storage builds a fresh query
|
|
47
|
-
* from it — so it is kept rather than parsed again.
|
|
48
|
-
*
|
|
49
|
-
* Expressions come from the client, hence the bound. An invalid one throws before it
|
|
50
|
-
* reaches the cache.
|
|
51
|
-
*/
|
|
52
|
-
#criteria(criteria: string): Node {
|
|
53
|
-
const known = this.#asts.get(criteria)
|
|
54
|
-
|
|
55
|
-
if (known !== undefined) return known
|
|
56
|
-
|
|
57
|
-
const ast = parse.criteria(criteria, this.#properties)
|
|
58
|
-
|
|
59
|
-
if (this.#asts.size >= LIMIT) this.#asts.clear()
|
|
60
|
-
|
|
61
|
-
this.#asts.set(criteria, ast)
|
|
62
|
-
|
|
63
|
-
return ast
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const LIMIT = 1024
|
package/source/receiver.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { console, decode, run, type SpanOptions } from 'openspan'
|
|
2
|
-
import { add } from '@toa.io/generic'
|
|
3
|
-
import { Connector } from './connector.js'
|
|
4
|
-
import type { Component } from './component.js'
|
|
5
|
-
import type { Receiver as Bridge } from './types/bridges.js'
|
|
6
|
-
import type { Message } from './types/message.js'
|
|
7
|
-
import type { Request, Source } from './types/request.js'
|
|
8
|
-
|
|
9
|
-
export interface Definition {
|
|
10
|
-
conditioned?: boolean
|
|
11
|
-
adaptive?: boolean
|
|
12
|
-
operation: string
|
|
13
|
-
label?: string
|
|
14
|
-
destination?: string
|
|
15
|
-
arguments?: unknown[]
|
|
16
|
-
origin?: Source
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class Receiver extends Connector {
|
|
20
|
-
readonly #conditioned: boolean | undefined
|
|
21
|
-
readonly #adaptive: boolean | undefined
|
|
22
|
-
readonly #endpoint: string
|
|
23
|
-
readonly #label: string
|
|
24
|
-
readonly #destination: string
|
|
25
|
-
readonly #arguments: unknown[] | undefined
|
|
26
|
-
readonly #origin: Source | undefined
|
|
27
|
-
readonly #local: Component
|
|
28
|
-
readonly #bridge: Bridge | undefined
|
|
29
|
-
readonly #delivery: SpanOptions
|
|
30
|
-
readonly #processing: SpanOptions
|
|
31
|
-
|
|
32
|
-
public constructor(definition: Definition, local: Component, bridge?: Bridge) {
|
|
33
|
-
super()
|
|
34
|
-
|
|
35
|
-
const { conditioned, adaptive, operation } = definition
|
|
36
|
-
|
|
37
|
-
this.#conditioned = conditioned
|
|
38
|
-
this.#adaptive = adaptive
|
|
39
|
-
this.#endpoint = operation
|
|
40
|
-
this.#label = definition.label ?? operation
|
|
41
|
-
this.#destination = definition.destination ?? this.#label
|
|
42
|
-
this.#arguments = definition.arguments
|
|
43
|
-
this.#origin = definition.origin
|
|
44
|
-
|
|
45
|
-
this.#local = local
|
|
46
|
-
this.#bridge = bridge
|
|
47
|
-
|
|
48
|
-
this.depends(local)
|
|
49
|
-
if (bridge !== undefined) this.depends(bridge)
|
|
50
|
-
|
|
51
|
-
/*
|
|
52
|
-
* The delivery span is created on behalf of the messaging destination, so that
|
|
53
|
-
* each consumer forms its own complete producer/consumer pair, and service graphs
|
|
54
|
-
* display fan-out correctly: producer -> destination -> each consumer
|
|
55
|
-
* (Tempo pairs spans one-to-one, thus multiple consumers can not pair
|
|
56
|
-
* with a single producer span, see grafana/tempo#5408)
|
|
57
|
-
*/
|
|
58
|
-
this.#delivery = {
|
|
59
|
-
name: `${this.#label} deliver`,
|
|
60
|
-
kind: 'producer',
|
|
61
|
-
service: this.#destination,
|
|
62
|
-
attributes: { 'messaging.destination.name': this.#destination }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
this.#processing = {
|
|
66
|
-
name: `${this.#label} process`,
|
|
67
|
-
kind: 'consumer',
|
|
68
|
-
service: local.locator.id,
|
|
69
|
-
attributes: { 'messaging.destination.name': this.#destination }
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** @hot */
|
|
74
|
-
public async receive(message: Message): Promise<void> {
|
|
75
|
-
const { payload, telemetry, ...extensions } = message
|
|
76
|
-
|
|
77
|
-
if (this.#conditioned === true && (await this.#bridge?.condition(payload)) === false)
|
|
78
|
-
return
|
|
79
|
-
|
|
80
|
-
const request = await this.#request(payload)
|
|
81
|
-
|
|
82
|
-
add(request, extensions)
|
|
83
|
-
|
|
84
|
-
// set after `add`, so that a message field can not spoof the origin
|
|
85
|
-
if (this.#origin !== undefined) request.source = this.#origin
|
|
86
|
-
|
|
87
|
-
// continue the trace from the producer span
|
|
88
|
-
const remote = telemetry === undefined ? null : decode(telemetry)
|
|
89
|
-
const task = async (): Promise<void> => {
|
|
90
|
-
await this.#process(request)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (remote === null) await task()
|
|
94
|
-
else await run(remote, task)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async #process(request: Request): Promise<void> {
|
|
98
|
-
return console.span(this.#delivery, async () =>
|
|
99
|
-
console.span(this.#processing, async () => {
|
|
100
|
-
try {
|
|
101
|
-
await this.#local.invoke(this.#endpoint, request)
|
|
102
|
-
} catch (error) {
|
|
103
|
-
console.error('Receiver error', {
|
|
104
|
-
component: this.#local.locator.id,
|
|
105
|
-
endpoint: this.#endpoint,
|
|
106
|
-
error
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
throw error
|
|
110
|
-
}
|
|
111
|
-
})
|
|
112
|
-
)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async #request(payload: object): Promise<Request> {
|
|
116
|
-
return this.#adaptive === true
|
|
117
|
-
? await this.#bridge!.request(payload, ...(this.#arguments ?? []))
|
|
118
|
-
: { input: payload }
|
|
119
|
-
}
|
|
120
|
-
}
|
package/source/reflection.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { Connector } from './connector.js'
|
|
2
|
-
|
|
3
|
-
/** What a reflection reads, once, as it connects. */
|
|
4
|
-
export type Source<T = any> = () => Promise<T>
|
|
5
|
-
|
|
6
|
-
export class Reflection<T = any> extends Connector {
|
|
7
|
-
public value: T | undefined
|
|
8
|
-
|
|
9
|
-
readonly #source: Source<T>
|
|
10
|
-
|
|
11
|
-
public constructor(source: Source<T>) {
|
|
12
|
-
super()
|
|
13
|
-
|
|
14
|
-
this.#source = source
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
protected override async open(): Promise<void> {
|
|
18
|
-
this.value = await this.#source()
|
|
19
|
-
}
|
|
20
|
-
}
|
package/source/remote.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert'
|
|
2
|
-
import { Component } from './component.js'
|
|
3
|
-
import type { Invocable } from './component.js'
|
|
4
|
-
|
|
5
|
-
/** A call knows what its endpoint declares, which is what an explanation is read from. */
|
|
6
|
-
export interface Explicable extends Invocable {
|
|
7
|
-
explain(): any
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export class Remote extends Component<Explicable> {
|
|
11
|
-
protected override kind = 'client' as const
|
|
12
|
-
|
|
13
|
-
public explain(endpoint: string): any {
|
|
14
|
-
if (!(endpoint in this.operations))
|
|
15
|
-
// `assert.fail`, not `assert.ok`: the message is built only when it is needed
|
|
16
|
-
assert.fail(`Endpoint '${endpoint}' is not provided by '${this.locator.id}'`)
|
|
17
|
-
|
|
18
|
-
return this.operations[endpoint].explain()
|
|
19
|
-
}
|
|
20
|
-
}
|