@toa.io/core 1.0.0-alpha.292 → 1.0.0-alpha.299

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 (138) hide show
  1. package/package.json +10 -4
  2. package/transpiled/call.js +12 -0
  3. package/transpiled/call.js.map +1 -1
  4. package/transpiled/component.js +31 -4
  5. package/transpiled/component.js.map +1 -1
  6. package/transpiled/context.d.ts +5 -0
  7. package/transpiled/context.js +6 -0
  8. package/transpiled/context.js.map +1 -1
  9. package/transpiled/emission.d.ts +4 -2
  10. package/transpiled/emission.js +4 -2
  11. package/transpiled/emission.js.map +1 -1
  12. package/transpiled/entities/entity.js +34 -6
  13. package/transpiled/entities/entity.js.map +1 -1
  14. package/transpiled/entities/factory.d.ts +5 -0
  15. package/transpiled/entities/factory.js +6 -2
  16. package/transpiled/entities/factory.js.map +1 -1
  17. package/transpiled/event.d.ts +2 -2
  18. package/transpiled/event.js +6 -1
  19. package/transpiled/event.js.map +1 -1
  20. package/transpiled/exceptions.d.ts +19 -0
  21. package/transpiled/exceptions.js +74 -2
  22. package/transpiled/exceptions.js.map +1 -1
  23. package/transpiled/index.d.ts +2 -1
  24. package/transpiled/index.js +2 -1
  25. package/transpiled/index.js.map +1 -1
  26. package/transpiled/{outbox/outbox.d.ts → outbox.d.ts} +6 -7
  27. package/transpiled/outbox.js +424 -0
  28. package/transpiled/outbox.js.map +1 -0
  29. package/transpiled/query/options.js +1 -1
  30. package/transpiled/query/options.js.map +1 -1
  31. package/transpiled/receiver.js +14 -1
  32. package/transpiled/receiver.js.map +1 -1
  33. package/transpiled/remote.js +2 -3
  34. package/transpiled/remote.js.map +1 -1
  35. package/transpiled/state.d.ts +1 -1
  36. package/transpiled/trail.d.ts +49 -0
  37. package/transpiled/trail.js +98 -0
  38. package/transpiled/trail.js.map +1 -0
  39. package/transpiled/types/bindings.d.ts +22 -0
  40. package/transpiled/types/extensions.d.ts +13 -2
  41. package/transpiled/types/message.d.ts +5 -0
  42. package/transpiled/types/outbox.d.ts +29 -3
  43. package/transpiled/types/request.d.ts +5 -0
  44. package/transpiled/types/storages.d.ts +20 -0
  45. package/CHANGELOG.md +0 -43
  46. package/source/assignment.ts +0 -27
  47. package/source/call.ts +0 -63
  48. package/source/cascade.ts +0 -32
  49. package/source/component.ts +0 -83
  50. package/source/composition.ts +0 -27
  51. package/source/connector.ts +0 -217
  52. package/source/context.ts +0 -68
  53. package/source/contract/contract.ts +0 -23
  54. package/source/contract/index.ts +0 -2
  55. package/source/contract/reply.ts +0 -56
  56. package/source/contract/request.ts +0 -140
  57. package/source/contract/schemas.ts +0 -39
  58. package/source/discovery.ts +0 -53
  59. package/source/effect.ts +0 -14
  60. package/source/emission.ts +0 -21
  61. package/source/entities/changeset.ts +0 -37
  62. package/source/entities/entity.ts +0 -137
  63. package/source/entities/factory.ts +0 -52
  64. package/source/entities/index.ts +0 -4
  65. package/source/entities/newid.ts +0 -9
  66. package/source/entities/set.ts +0 -27
  67. package/source/event.ts +0 -57
  68. package/source/exceptions.ts +0 -133
  69. package/source/exposition.ts +0 -33
  70. package/source/guard.ts +0 -16
  71. package/source/index.ts +0 -36
  72. package/source/locator.ts +0 -37
  73. package/source/observation.ts +0 -16
  74. package/source/operation.ts +0 -139
  75. package/source/outbox/index.ts +0 -1
  76. package/source/outbox/outbox.ts +0 -341
  77. package/source/query/criteria.ts +0 -90
  78. package/source/query/options.ts +0 -35
  79. package/source/query.ts +0 -67
  80. package/source/receiver.ts +0 -120
  81. package/source/reflection.ts +0 -20
  82. package/source/remote.ts +0 -20
  83. package/source/state.ts +0 -179
  84. package/source/transition.ts +0 -79
  85. package/source/transmission.ts +0 -38
  86. package/source/types/atomicity.ts +0 -63
  87. package/source/types/bindings.ts +0 -59
  88. package/source/types/bridges.ts +0 -54
  89. package/source/types/extensions.ts +0 -89
  90. package/source/types/index.ts +0 -14
  91. package/source/types/message.ts +0 -5
  92. package/source/types/operations.ts +0 -31
  93. package/source/types/outbox.ts +0 -35
  94. package/source/types/receiver.ts +0 -10
  95. package/source/types/request.ts +0 -54
  96. package/source/types/state.ts +0 -9
  97. package/source/types/storages.ts +0 -134
  98. package/source/unmanaged.ts +0 -8
  99. package/test/call.fixtures.js +0 -25
  100. package/test/call.test.js +0 -79
  101. package/test/component.fixtures.js +0 -18
  102. package/test/component.test.js +0 -57
  103. package/test/connector.fixtures.js +0 -56
  104. package/test/connector.test.js +0 -266
  105. package/test/context.fixtures.js +0 -14
  106. package/test/context.test.js +0 -40
  107. package/test/contract/conditions.test.js +0 -32
  108. package/test/contract/contract.fixtures.js +0 -28
  109. package/test/contract/request.test.js +0 -196
  110. package/test/discovery.test.js +0 -71
  111. package/test/emission.fixtures.js +0 -13
  112. package/test/emission.test.js +0 -60
  113. package/test/entities/entity.fixtures.js +0 -24
  114. package/test/entities/entity.test.js +0 -137
  115. package/test/entities/factory.fixtures.js +0 -31
  116. package/test/entities/factory.test.js +0 -99
  117. package/test/entities/set.fixtures.js +0 -9
  118. package/test/entities/set.test.js +0 -13
  119. package/test/event.fixtures.js +0 -24
  120. package/test/event.test.js +0 -162
  121. package/test/locator.test.js +0 -120
  122. package/test/outbox.test.js +0 -202
  123. package/test/query.fixtures.js +0 -96
  124. package/test/query.test.js +0 -183
  125. package/test/receiver.fixtures.js +0 -20
  126. package/test/receiver.test.js +0 -144
  127. package/test/reflection.test.js +0 -31
  128. package/test/state.fixtures.js +0 -49
  129. package/test/state.test.js +0 -162
  130. package/test/transmission.fixtures.js +0 -13
  131. package/test/transmission.test.js +0 -77
  132. package/transpiled/outbox/index.d.ts +0 -1
  133. package/transpiled/outbox/index.js +0 -2
  134. package/transpiled/outbox/index.js.map +0 -1
  135. package/transpiled/outbox/outbox.js +0 -268
  136. package/transpiled/outbox/outbox.js.map +0 -1
  137. package/tsconfig.json +0 -10
  138. package/tsconfig.tsbuildinfo +0 -1
@@ -1,139 +0,0 @@
1
- import { Readable } from 'node:stream'
2
- import { Connector } from './connector.js'
3
- import { SystemException, RequestContractException } from './exceptions.js'
4
- import { environment } from '@toa.io/generic'
5
- import type { Cascade } from './cascade.js'
6
- import type { State } from './state.js'
7
- import type { Query as Translator } from './query.js'
8
- import type { Contract } from './contract/contract.js'
9
- import type { Entity } from './entities/entity.js'
10
- import type { EntitySet } from './entities/set.js'
11
- import type { Changeset } from './entities/changeset.js'
12
- import type { scope as Scope } from './types/operations.js'
13
- import type { Query, Request } from './types/request.js'
14
-
15
- /** What an operation acquires for the algorithm to run against. */
16
- export type Scoped = Entity | EntitySet | Changeset | Readable | null
17
-
18
- /** What one invocation carries from step to step. */
19
- export interface Store {
20
- request: Request
21
- scope?: Scoped
22
- state?: any
23
- reply?: any
24
- /** set by a transition, which is the only operation that runs its steps again */
25
- retry?: () => Promise<any>
26
- }
27
-
28
- export interface Contracts {
29
- request: Contract
30
- reply: Contract
31
- }
32
-
33
- export interface Definition {
34
- scope: Scope
35
- concurrency?: string
36
- }
37
-
38
- export class Operation extends Connector {
39
- public scope: State
40
-
41
- /**
42
- * Whether what this operation acquires may be modified and committed. Only a
43
- * transition commits, and only a commit needs the pre-image an entity keeps
44
- * to diff the new state against.
45
- *
46
- * @protected
47
- */
48
- protected mutable: boolean = false
49
-
50
- readonly #cascade: Cascade
51
- readonly #contracts: Contracts
52
- readonly #query: Translator
53
- readonly #scope: Scope
54
-
55
- // eslint-disable-next-line max-params
56
- public constructor(
57
- cascade: Cascade,
58
- scope: State,
59
- contracts: Contracts,
60
- query: Translator,
61
- definition: Definition
62
- ) {
63
- super()
64
-
65
- this.scope = scope
66
-
67
- this.#cascade = cascade
68
- this.#contracts = contracts
69
- this.#query = query
70
- this.#scope = definition.scope
71
-
72
- this.depends(cascade)
73
- }
74
-
75
- public async invoke(request: Request): Promise<any> {
76
- try {
77
- if (request.authentic !== true) this.#contracts.request.fit(request)
78
-
79
- // the request carries the query onward in its parsed form: what a storage is given,
80
- // not what the caller sent
81
- if ('query' in request)
82
- request.query = this.#query.parse(request.query as Query) as any
83
-
84
- // validate entity
85
- if ('entity' in request) this.scope.fit(request.entity)
86
-
87
- const store = { request }
88
-
89
- return await this.process(store)
90
- } catch (e) {
91
- const exception = e instanceof Error ? new SystemException(e) : e
92
-
93
- return { exception }
94
- }
95
- }
96
-
97
- protected async process(store: Store): Promise<any> {
98
- await this.acquire(store)
99
- await this.run(store)
100
- await this.commit(store)
101
-
102
- return store.reply
103
- }
104
-
105
- protected async acquire(store: Store): Promise<void> {
106
- if (this.#scope === 'none') return
107
-
108
- const scope = await this.query(store.request.query)
109
- const raw = scope === null || scope instanceof Readable
110
-
111
- store.scope = scope
112
- store.state = raw ? scope : (scope as Entity).get()
113
- }
114
-
115
- protected async run(store: Store): Promise<void> {
116
- const { request, state } = store
117
- const reply = await this.#cascade.run(request.input, state)
118
-
119
- // validate reply only on local environments
120
- if (environment.get('TOA_ENV') === 'local' && !(reply instanceof Readable))
121
- this.#contracts.reply.fit(reply)
122
-
123
- store.reply = reply
124
- }
125
-
126
- protected async commit(_store: Store): Promise<void> {}
127
-
128
- protected async query(query?: Query): Promise<Scoped> {
129
- if (query === undefined)
130
- throw new RequestContractException('Request query is required')
131
-
132
- const acquire = this.scope[this.#scope] as (
133
- query: Query,
134
- mutable?: boolean
135
- ) => Promise<Scoped>
136
-
137
- return acquire.call(this.scope, query, this.mutable)
138
- }
139
- }
@@ -1 +0,0 @@
1
- export { Outbox, LANES } from './outbox.js'
@@ -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
@@ -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
- }
@@ -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