terrier-engine 4.13.6 → 4.14.0

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.
@@ -2,7 +2,8 @@ import {ModalPart} from "../../terrier/modals"
2
2
  import {PartTag} from "tuff-core/parts"
3
3
  import {DdDive, DdDiveRun, UnpersistedDdDiveRun} from "../gen/models"
4
4
  import Db from "../dd-db"
5
- import Api, {ErrorEvent} from "../../terrier/api"
5
+ import Api from "../../terrier/api"
6
+ import {ErrorEvent} from "../../terrier/api-subscriber"
6
7
  import {Query} from "../queries/queries"
7
8
  import {DivTag, HtmlParentTag} from "tuff-core/html"
8
9
  import {IconName} from "../../terrier/theme"
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "files": [
5
5
  "*"
6
6
  ],
7
- "version": "4.13.6",
7
+ "version": "4.14.0",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/Terrier-Tech/terrier-engine"
@@ -0,0 +1,404 @@
1
+ import Api, {ApiResponse, noArgListener, Streamer} from "./api"
2
+ import {LogEntry} from "./logging"
3
+ import dayjs from "dayjs"
4
+ import duration from "dayjs/plugin/duration"
5
+
6
+ dayjs.extend(duration)
7
+
8
+ /** Configuration for ApiSubscribers */
9
+ export type SubscriptionOptions = {
10
+ /** If true, retry the subscription after it is cancelled */
11
+ keepAlive?: boolean
12
+ }
13
+
14
+ /** Parameters to send with the subscription request */
15
+ export type SubscriptionParams = Record<string, string>
16
+
17
+ export type SubscriptionEventHandlers<TResult> = {
18
+ onResult: resultListener<TResult>[]
19
+ onError: errorListener[]
20
+ onLog: logListener[]
21
+ }
22
+
23
+ export type SubscriptionLifecycleHandlers = {
24
+ onSubscribe: noArgListener[]
25
+ onUnsubscribe: noArgListener[]
26
+ onClose: closeListener[]
27
+ }
28
+
29
+ type resultListener<TResult> = (result: ResultEvent<TResult>) => boolean
30
+ type errorListener = (result: ErrorEvent) => boolean
31
+ type logListener = (log: LogEvent) => void
32
+ type closeListener = (reason?: string) => void
33
+
34
+
35
+ type BaseSubscriptionEvent = {
36
+ _type: '_result' | '_error' | '_log' | '_close' | undefined
37
+ }
38
+
39
+ export type SubscriptionEvent<TResult> = BaseSubscriptionEvent & (ResultEvent<TResult> | ErrorEvent | LogEvent | CloseEvent)
40
+
41
+ /** Type of result events from a subscription */
42
+ export type ResultEvent<TResult> = BaseSubscriptionEvent & { _type: '_result' | undefined } & TResult
43
+
44
+ /** Type of error events from a subscription */
45
+ export type ErrorEvent = BaseSubscriptionEvent & { _type: '_error' } & {
46
+ prefix?: string
47
+ message: string
48
+ backtrace: string[]
49
+ }
50
+
51
+ /** Type of log events from a subscription */
52
+ export type LogEvent = BaseSubscriptionEvent & { _type: '_log' } & LogEntry
53
+
54
+ export type CloseEvent = BaseSubscriptionEvent & { _type: '_close' }
55
+
56
+ /**
57
+ * An abstraction over the process of receiving continuous updates from the server.
58
+ * This allows client code to specify a dependency on continuous updates of a particular type without needing
59
+ * to care about how those updates are fulfilled.
60
+ *
61
+ * Behind the scenes, this could be handled by pulling from the client (PollingSubscriber),
62
+ * pushing from the server (CableSubscriber), or a hybrid of the two (StreamingSubscriber).
63
+ *
64
+ * Subclasses are responsible for implementing the process of initiating and finalizing the subscription and
65
+ * passing events to the appropriate handlers.
66
+ *
67
+ * Each subclass is responsible for providing a constructor with whatever dependencies are required, but all subclasses
68
+ * must implement `params` and `options` properties and handle them appropriately.
69
+ */
70
+ export abstract class ApiSubscriber<TResult, TParams extends SubscriptionParams> {
71
+
72
+ public abstract params: TParams
73
+ public abstract options?: SubscriptionOptions
74
+
75
+ protected isSubscribed: boolean = false
76
+
77
+ protected eventHandlers: SubscriptionEventHandlers<TResult> = { onResult: [], onError: [], onLog: [] }
78
+ protected lifecycleHandlers: SubscriptionLifecycleHandlers = { onSubscribe: [], onUnsubscribe: [], onClose: [] }
79
+ protected otherHandlers: Record<string, ((event: unknown) => boolean | void)[]> = {}
80
+
81
+ // Handler registration
82
+
83
+ /** Register a handler for a custom event type */
84
+ public on<EventType>(eventType: string, handler: (event: EventType) => boolean | void): this {
85
+ if (eventType in ['_result', '_error', '_log', '_close']) {
86
+ throw new Error(`${eventType} is a reserved handler type. Please use a different type or use the appropriate handler registration function.`)
87
+ }
88
+
89
+ this.otherHandlers[eventType] ??= []
90
+ this.otherHandlers[eventType].push(handler as (event: unknown) => boolean | void)
91
+ return this
92
+ }
93
+
94
+ /** Register a handler for the default result type of this subscriber */
95
+ public onResult(handler: (result: ResultEvent<TResult>) => boolean): this {
96
+ this.eventHandlers.onResult.push(handler)
97
+ return this
98
+ }
99
+
100
+ /** Register a handler for error events */
101
+ public onError(handler: (error: ErrorEvent) => boolean): this {
102
+ this.eventHandlers.onError.push(handler)
103
+ return this
104
+ }
105
+
106
+ /** Register a handler for log events */
107
+ public onLog(handler: (logEntry: LogEvent) => void): this {
108
+ this.eventHandlers.onLog.push(handler)
109
+ return this
110
+ }
111
+
112
+ /** Register a handler to be notified when this subscriber is subscribed */
113
+ public onSubscribe(handler: () => void): this {
114
+ return this.onLifecycle('onSubscribe', handler)
115
+ }
116
+
117
+ /** Register a handler to be notified when this subscriber is manually unsubscribed */
118
+ public onUnsubscribe(handler: () => void): this {
119
+ return this.onLifecycle('onUnsubscribe', handler)
120
+ }
121
+
122
+ /** Register a handler to be notified when the publisher source of this subscriber closes the subscription */
123
+ public onClose(handler: () => void): this {
124
+ return this.onLifecycle('onClose', handler)
125
+ }
126
+
127
+ /** utility to register a generic lifecycle handler */
128
+ protected onLifecycle(key: keyof SubscriptionLifecycleHandlers, handler: noArgListener): this
129
+ protected onLifecycle(key: 'onClose', handler: (reason?: string) => void): this
130
+ protected onLifecycle(key: keyof SubscriptionLifecycleHandlers, handler: noArgListener | ((reason?: string) => void)): this {
131
+ this.lifecycleHandlers[key].push(handler)
132
+ return this
133
+ }
134
+
135
+ // Start or stop subscription
136
+
137
+ /**
138
+ * Starts the subscription. Calling this after the subscription has already been started has no effect
139
+ */
140
+ public subscribe(): this {
141
+ if (this.isSubscribed) return this
142
+ this.subscribeImpl()
143
+ this.isSubscribed = true
144
+ this.notifyLifecycle('onSubscribe')
145
+ return this
146
+ }
147
+
148
+ /** Stops the subscription and closes the connection. */
149
+ public unsubscribe(): void {
150
+ if (!this.isSubscribed) {
151
+ throw new Error("Can't unsubscribe from a un-started subscription!")
152
+ }
153
+ this.unsubscribeImpl()
154
+ this.isSubscribed = false
155
+ this.notifyLifecycle('onUnsubscribe')
156
+ }
157
+
158
+ // Modify subscription params
159
+
160
+ /** Update the params of this subscriber. Depending on the implementation, this may restart the subscription. */
161
+ public abstract updateParams(newParams: TParams): void
162
+
163
+ // Internal
164
+
165
+ protected close() {
166
+ this.isSubscribed = false
167
+ }
168
+
169
+ /** Call the handlers for the given lifecycle event */
170
+ protected notifyLifecycle(key: keyof SubscriptionLifecycleHandlers): void
171
+ protected notifyLifecycle(key: 'onClose', reason: string | undefined): void
172
+ protected notifyLifecycle(key: keyof SubscriptionLifecycleHandlers, reason: string | undefined = undefined): void {
173
+ if (key == 'onClose') {
174
+ this.lifecycleHandlers[key].forEach(handler => handler(reason))
175
+ } else {
176
+ this.lifecycleHandlers[key].forEach(handler => handler())
177
+ }
178
+ }
179
+
180
+ /** Subclasses implement this method to start the subscription */
181
+ protected abstract subscribeImpl(): void
182
+
183
+ /** Subclasses imlpement this method to end the subscription */
184
+ protected abstract unsubscribeImpl(): void
185
+ }
186
+
187
+ ////////////////////////////////////////////////////////////////////////////////
188
+ // Polling Subscriber
189
+ ////////////////////////////////////////////////////////////////////////////////
190
+
191
+ /**
192
+ * An implementation of ApiSubscriber that uses polling to periodically make a request to the server
193
+ */
194
+ export class PollingSubscriber<TResult, TParams extends SubscriptionParams> extends ApiSubscriber<TResult, TParams> {
195
+
196
+ // used to ensure we only schedule one interval at a time and allows for cancellation.
197
+ private timeoutHandle: NodeJS.Timeout | null = null
198
+
199
+ /**
200
+ * @param url the url to make a request to.
201
+ * @param params params to use to make the request.
202
+ * @param interval the interval on which to poll. Either a number of milliseconds or a dayjs Duration.
203
+ * @param options extra subscription options
204
+ */
205
+ constructor(
206
+ public url: string,
207
+ public params: TParams,
208
+ public interval: number | duration.Duration,
209
+ public options: SubscriptionOptions | undefined = undefined
210
+ ) {
211
+ super()
212
+ }
213
+
214
+ subscribeImpl() {
215
+ if (this.timeoutHandle) return
216
+ this.makeRequest()
217
+ }
218
+
219
+ unsubscribeImpl() {
220
+ this.clearTimeout()
221
+ }
222
+
223
+ updateParams(newParams: TParams) {
224
+ this.params = newParams
225
+ this.makeRequest()
226
+ }
227
+
228
+ private makeRequest() {
229
+ this.clearTimeout()
230
+ this.request().then(response => {
231
+ let shouldContinuePolling = true
232
+ if ('events' in response) {
233
+ for (const event of response.events) {
234
+ shouldContinuePolling &&= this.handleEvent(event)
235
+ }
236
+ } else {
237
+ if (response.status == 'error') response._type = '_error'
238
+ shouldContinuePolling = this.handleEvent(response)
239
+ }
240
+
241
+ if (shouldContinuePolling) {
242
+ this.startTimeout()
243
+ } else {
244
+ this.unsubscribe()
245
+ }
246
+ })
247
+ }
248
+
249
+ protected request(): Promise<((ApiResponse & SubscriptionEvent<TResult>) | { events: SubscriptionEvent<TResult>[] })> {
250
+ return Api.get<((ApiResponse & SubscriptionEvent<TResult>) | { events: SubscriptionEvent<TResult>[] })>(this.url, this.params)
251
+ }
252
+
253
+ protected handleEvent(event: SubscriptionEvent<TResult>): boolean {
254
+ let shouldContinuePolling = true
255
+
256
+ switch (event._type) {
257
+ case '_result':
258
+ case undefined:
259
+ for (const handler of this.eventHandlers.onResult) {
260
+ const r = handler(event)
261
+ shouldContinuePolling &&= r
262
+ }
263
+ break
264
+ case '_error':
265
+ for (const handler of this.eventHandlers.onError) {
266
+ const r = handler(event)
267
+ shouldContinuePolling &&= r
268
+ }
269
+ break
270
+ case '_log':
271
+ this.eventHandlers.onLog.forEach(handler => handler(event))
272
+ break
273
+ case '_close':
274
+ this.notifyLifecycle('onClose')
275
+ this.close()
276
+ shouldContinuePolling = this.options?.keepAlive ?? false
277
+ break
278
+ default:
279
+ const otherEvent = event as any
280
+ for (const handler of this.otherHandlers[otherEvent._type]) {
281
+ const r = handler(otherEvent) ?? true
282
+ shouldContinuePolling &&= r
283
+ }
284
+ }
285
+
286
+ return shouldContinuePolling
287
+ }
288
+
289
+ protected clearTimeout() {
290
+ if (this.timeoutHandle) {
291
+ clearTimeout(this.timeoutHandle)
292
+ this.timeoutHandle = null
293
+ }
294
+ }
295
+
296
+ protected startTimeout() {
297
+ if (this.timeoutHandle) return
298
+ const intervalMs = (dayjs.isDuration(this.interval)) ? this.interval.asMilliseconds() : this.interval
299
+ this.timeoutHandle = setTimeout(this.makeRequest.bind(this), intervalMs)
300
+ }
301
+ }
302
+
303
+ ////////////////////////////////////////////////////////////////////////////////
304
+ // Action Cable Subscriber
305
+ ////////////////////////////////////////////////////////////////////////////////
306
+
307
+ /**
308
+ * An ApiSubscriber that uses ActionCable to get pushed results from the server.
309
+ * Not implemented yet
310
+ */
311
+ export class CableSubscriber<TResult, TParams extends SubscriptionParams> extends ApiSubscriber<TResult, TParams> {
312
+
313
+ constructor(
314
+ public channelName: string,
315
+ public params: TParams,
316
+ public options: SubscriptionOptions | undefined = undefined
317
+ ) {
318
+ throw new Error("Method not implemented.")
319
+ super()
320
+ }
321
+
322
+ public updateParams(_newParams: TParams): void {
323
+ throw new Error("Method not implemented.")
324
+ }
325
+ protected subscribeImpl(): void {
326
+ throw new Error("Method not implemented.")
327
+ }
328
+ protected unsubscribeImpl(): void {
329
+ throw new Error("Method not implemented.")
330
+ }
331
+ }
332
+
333
+ ////////////////////////////////////////////////////////////////////////////////
334
+ // Event Stream Subscriber
335
+ ////////////////////////////////////////////////////////////////////////////////
336
+
337
+ export class StreamingSubscriber<TResult, TParams extends SubscriptionParams> extends ApiSubscriber<TResult, TParams> {
338
+
339
+ private streamer?: Streamer
340
+
341
+ constructor(
342
+ public url: string,
343
+ public params: TParams,
344
+ public options: SubscriptionOptions | undefined = undefined,
345
+ ) {
346
+ super()
347
+ }
348
+
349
+ public on<EventType>(eventType: string, handler: (event: EventType) => boolean | void): this {
350
+ this.streamer?.on(eventType, handler)
351
+ return super.on(eventType, handler)
352
+ }
353
+
354
+ public onResult(handler: (result: ResultEvent<TResult>) => boolean): this {
355
+ this.streamer?.on<ResultEvent<TResult>>('_result', handler)
356
+ return super.onResult(handler)
357
+ }
358
+
359
+ public onError(handler: (error: ErrorEvent) => boolean): this {
360
+ this.streamer?.on<ErrorEvent>('_error', handler)
361
+ return super.onError(handler)
362
+ }
363
+
364
+ public onLog(handler: (logEntry: LogEvent) => void): this {
365
+ this.streamer?.on<LogEvent>('_log', handler)
366
+ return super.onLog(handler)
367
+ }
368
+
369
+ protected subscribeImpl(): void {
370
+ const paramsString = new URLSearchParams(this.params).toString()
371
+ const streamer = new Streamer(`${this.url}?${paramsString}`, this.options ?? {})
372
+
373
+ this.eventHandlers.onResult.forEach(handler => streamer.on<ResultEvent<TResult>>('_result', this.wrapHandler(handler)))
374
+ this.eventHandlers.onError.forEach(handler => streamer.on<ErrorEvent>('_error', this.wrapHandler(handler)))
375
+ this.eventHandlers.onLog.forEach(handler => streamer.on<LogEvent>('_log', handler))
376
+ this.lifecycleHandlers.onClose.forEach(handler => streamer.onClose(handler))
377
+ streamer.onClose(() => {
378
+ this.close()
379
+ })
380
+ Object.entries(this.otherHandlers)
381
+ .forEach(([key, handlers]) =>
382
+ handlers.forEach(handler => streamer.on(key, this.wrapHandler(handler)))
383
+ )
384
+
385
+ this.streamer = streamer
386
+ }
387
+ protected unsubscribeImpl(): void {
388
+ this.streamer?.sse.close()
389
+ this.streamer = undefined
390
+ }
391
+
392
+ public updateParams(newParams: TParams): void {
393
+ this.unsubscribeImpl()
394
+ this.params = newParams
395
+ this.subscribeImpl()
396
+ }
397
+
398
+ private wrapHandler<T>(handler: (param: T) => boolean | void): (param: T) => void {
399
+ return (param: T) => {
400
+ const shouldContinue = handler(param) ?? true
401
+ if (!shouldContinue) this.unsubscribe()
402
+ }
403
+ }
404
+ }
package/terrier/api.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import {Logger} from "tuff-core/logging"
2
2
  import {QueryParams} from "tuff-core/urls"
3
3
  import {LogEntry} from "./logging"
4
+ import {ErrorEvent} from "./api-subscriber"
4
5
 
5
6
  const log = new Logger('Api')
6
- log.level = 'debug'
7
-
8
7
 
9
8
  ////////////////////////////////////////////////////////////////////////////////
10
9
  // Basic Requests
@@ -79,21 +78,32 @@ async function apiRequest<ResponseType>(url: string, config: RequestInit): Promi
79
78
  * @param params a set of parameters that will be added to the URL as a query string
80
79
  */
81
80
  async function safeGet<ResponseType>(url: string, params: QueryParams | Record<string, string | undefined>): Promise<ResponseType> {
81
+ const response = await get<ApiResponse & ResponseType>(url, params)
82
+ if (response.status == 'error') {
83
+ throw new ApiException(response.message)
84
+ }
85
+ return response
86
+ }
87
+
88
+ /**
89
+ * Performs a GET request for the given datatype.
90
+ * Unlike `safeGet`, this will return the result regardless of the response status.
91
+ * `ResponseType` does not need to include the `status` or `message` fields, this is handled automatically.
92
+ * @param url the base URL for the request
93
+ * @param params a set of parameters that will be added to the URL as a query string
94
+ */
95
+ async function get<ResponseType>(url: string, params: QueryParams | Record<string, string | undefined>): Promise<ResponseType> {
82
96
  if (!params.raw) {
83
97
  params = new QueryParams(params as Record<string, string>)
84
98
  }
85
99
  const fullUrl = (params as QueryParams).serialize(url)
86
- log.debug(`Safe getting ${fullUrl}`)
87
- const response = await apiRequest<ResponseType>(fullUrl, {
100
+ log.debug(`Getting ${fullUrl}`)
101
+ return await apiRequest<ResponseType>(fullUrl, {
88
102
  method: 'GET',
89
103
  headers: {
90
104
  'Accept': 'application/json'
91
105
  }
92
106
  })
93
- if (response.status == 'error') {
94
- throw new ApiException(response.message)
95
- }
96
- return response
97
107
  }
98
108
 
99
109
  /**
@@ -142,14 +152,6 @@ async function post<ResponseType>(url: string, body: Record<string, unknown> | F
142
152
  // Event Streams
143
153
  ////////////////////////////////////////////////////////////////////////////////
144
154
 
145
- /**
146
- * Type of error events from a streaming response.
147
- */
148
- export type ErrorEvent = {
149
- prefix?: string
150
- message: string
151
- backtrace: string[]
152
- }
153
155
 
154
156
  /**
155
157
  * Configure a `Streamer`.
@@ -158,7 +160,7 @@ export type StreamOptions = {
158
160
  keepAlive?: boolean
159
161
  }
160
162
 
161
- type noArgListener = () => any
163
+ export type noArgListener = () => any
162
164
 
163
165
  type StreamLifecycle = 'close'
164
166
 
@@ -172,13 +174,13 @@ export class Streamer {
172
174
  close: []
173
175
  }
174
176
 
175
- constructor(readonly url: string, readonly options: StreamOptions) {
177
+ constructor(readonly url: string, readonly options: StreamOptions | undefined = undefined) {
176
178
  this.sse = new EventSource(url)
177
179
 
178
180
  // this is a special event sent by the ResponseStreamer on the server
179
181
  // to tell us that the request is done
180
182
  this.sse.addEventListener('_close', evt => {
181
- if (!this.options.keepAlive) {
183
+ if (!this.options?.keepAlive) {
182
184
  log.debug(`Closing Streamer at ${url}`, evt)
183
185
  this.sse.close()
184
186
  for (const listener of this.lifecycleListeners['close']) {
@@ -220,11 +222,10 @@ export class Streamer {
220
222
 
221
223
  onClose(listener: noArgListener) {
222
224
  this.lifecycleListeners['close'].push(listener)
225
+ return this
223
226
  }
224
227
  }
225
228
 
226
-
227
-
228
229
  /**
229
230
  * Creates a streaming response for the given endpoint.
230
231
  * @param url
@@ -242,6 +243,7 @@ function stream(url: string, options: StreamOptions={}): Streamer {
242
243
 
243
244
  const Api = {
244
245
  safeGet,
246
+ get,
245
247
  safePost,
246
248
  post,
247
249
  stream,