@dbos-inc/pgnotifier-receiver 3.0.35-preview

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.
@@ -0,0 +1,700 @@
1
+ import { DBOS, DBOSLifecycleCallback, ExternalRegistration } from '@dbos-inc/dbos-sdk';
2
+
3
+ import { ClientBase, Notification } from 'pg';
4
+
5
+ export type DBNotification = Notification;
6
+ export type DBNotificationCallback = (n: DBNotification) => void;
7
+ export interface DBNotificationListener {
8
+ close(): Promise<void>;
9
+ }
10
+
11
+ ////
12
+ // Configuration
13
+ ////
14
+
15
+ export enum TriggerOperation {
16
+ RecordInserted = 'insert',
17
+ RecordDeleted = 'delete',
18
+ RecordUpdated = 'update',
19
+ RecordUpserted = 'upsert', // Workflow recovery cannot tell you about delete, only update/insert and can't distinguish them
20
+ }
21
+
22
+ export class DBTriggerConfig {
23
+ // Database table to trigger
24
+ tableName: string = '';
25
+ // Database table schema (optional)
26
+ schemaName?: string = undefined;
27
+
28
+ // These identify the record, for elevation to function parameters
29
+ recordIDColumns?: string[] = undefined;
30
+
31
+ // Should DB trigger / notification be used?
32
+ useDBNotifications?: boolean = false;
33
+
34
+ // Should DB trigger be auto-installed?
35
+ installDBTrigger?: boolean = false;
36
+
37
+ // This identify the record sequence number, for checkpointing the sys db
38
+ sequenceNumColumn?: string = undefined;
39
+ // In case sequence numbers aren't perfectly in order, how far off could they be?
40
+ sequenceNumJitter?: number = undefined;
41
+
42
+ // This identifies the record timestamp, for checkpointing the sysdb
43
+ timestampColumn?: string = undefined;
44
+ // In case sequence numbers aren't perfectly in order, how far off could they be?
45
+ timestampSkewMS?: number = undefined;
46
+
47
+ // Use a workflow queue if set
48
+ queueName?: string = undefined;
49
+
50
+ // If not using triggers, frequency of polling, ms
51
+ dbPollingInterval?: number = 5000;
52
+ }
53
+
54
+ export interface DBConfig {
55
+ connect: () => Promise<ClientBase>;
56
+ disconnect: (c: ClientBase) => Promise<void>;
57
+ query: <R>(sql: string, params?: unknown[]) => Promise<R[]>;
58
+ }
59
+
60
+ export async function dbListen(
61
+ cfg: DBConfig,
62
+ channels: string[],
63
+ callback: DBNotificationCallback,
64
+ ): Promise<DBNotificationListener> {
65
+ const notificationsClient = await cfg.connect();
66
+ for (const nname of channels) {
67
+ await notificationsClient.query(`LISTEN ${nname};`);
68
+ }
69
+
70
+ notificationsClient.on('notification', callback);
71
+
72
+ return {
73
+ close: async () => {
74
+ for (const nname of channels) {
75
+ try {
76
+ await notificationsClient.query(`UNLISTEN ${nname};`);
77
+ } catch (e) {
78
+ DBOS.logger.warn(e);
79
+ }
80
+ await cfg.disconnect(notificationsClient);
81
+ }
82
+ },
83
+ };
84
+ }
85
+
86
+ interface DBTriggerRegistration {
87
+ triggerConfig?: DBTriggerConfig;
88
+ triggerIsWorkflow?: boolean;
89
+ }
90
+
91
+ ///
92
+ // SQL Gen
93
+ ///
94
+
95
+ function quoteIdentifier(identifier: string): string {
96
+ // Escape double quotes within the identifier by doubling them
97
+ return `"${identifier.replace(/"/g, '""')}"`;
98
+ }
99
+
100
+ function quoteConstant(cval: string): string {
101
+ // Escape double quotes within the identifier by doubling them
102
+ return `${cval.replace(/'/g, "''")}`;
103
+ }
104
+
105
+ function createTriggerSQL(
106
+ triggerFuncName: string, // Name of function for trigger to call
107
+ triggerName: string, // Trigger name
108
+ tableName: string, // As known to DB
109
+ tableNameString: string, // Passed as a value to notifier
110
+ notifierName: string, // Notifier name
111
+ ) {
112
+ return `
113
+ CREATE OR REPLACE FUNCTION ${triggerFuncName}() RETURNS trigger AS $$
114
+ DECLARE
115
+ payload json;
116
+ BEGIN
117
+ IF TG_OP = 'INSERT' THEN
118
+ payload = json_build_object(
119
+ 'tname', '${quoteConstant(tableNameString)}',
120
+ 'operation', 'insert',
121
+ 'record', row_to_json(NEW)
122
+ );
123
+ ELSIF TG_OP = 'UPDATE' THEN
124
+ payload = json_build_object(
125
+ 'tname', '${quoteConstant(tableNameString)}',
126
+ 'operation', 'update',
127
+ 'record', row_to_json(NEW)
128
+ );
129
+ ELSIF TG_OP = 'DELETE' THEN
130
+ payload = json_build_object(
131
+ 'tname', '${quoteConstant(tableNameString)}',
132
+ 'operation', 'delete',
133
+ 'record', row_to_json(OLD)
134
+ );
135
+ END IF;
136
+
137
+ PERFORM pg_notify('${notifierName}', payload::text);
138
+ RETURN NEW;
139
+ END;
140
+ $$ LANGUAGE plpgsql;
141
+
142
+ CREATE OR REPLACE TRIGGER ${triggerName}
143
+ AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
144
+ FOR EACH ROW EXECUTE FUNCTION ${triggerFuncName}();
145
+ `;
146
+ }
147
+
148
+ function createCatchupSql(
149
+ tc: DBTriggerConfig,
150
+ tableName: string,
151
+ tableNameString: string,
152
+ startSeqNum?: bigint | null,
153
+ startTimeStamp?: number | null,
154
+ ) {
155
+ // Query string
156
+ let sncpred = '';
157
+ let oby = '';
158
+ const params = [];
159
+ if (tc.sequenceNumColumn && startSeqNum) {
160
+ params.push(startSeqNum);
161
+ sncpred = ` ${quoteIdentifier(tc.sequenceNumColumn)} > $${params.length} AND `;
162
+ oby = `ORDER BY ${quoteIdentifier(tc.sequenceNumColumn)}`;
163
+ }
164
+ let tscpred = '';
165
+ if (tc.timestampColumn && startTimeStamp) {
166
+ params.push(new Date(startTimeStamp));
167
+ tscpred = ` ${quoteIdentifier(tc.timestampColumn)} > $${params.length} AND `;
168
+ oby = `ORDER BY ${quoteIdentifier(tc.timestampColumn)}`;
169
+ }
170
+
171
+ const query = `
172
+ SELECT json_build_object(
173
+ 'tname', '${quoteConstant(tableNameString)}',
174
+ 'operation', 'upsert',
175
+ 'record', row_to_json(t)
176
+ )::text as payload
177
+ FROM (
178
+ SELECT *
179
+ FROM ${tableName}
180
+ WHERE ${sncpred} ${tscpred} 1=1
181
+ ${oby}
182
+ ) t
183
+ `;
184
+
185
+ return { query, params };
186
+ }
187
+
188
+ ///////////////////////////
189
+ // DB Trigger Management
190
+ ///////////////////////////
191
+ interface TriggerPayload {
192
+ operation: TriggerOperation;
193
+ tname: string;
194
+ record: { [key: string]: unknown };
195
+ }
196
+
197
+ export type TriggerFunction<Key extends unknown[]> = (op: TriggerOperation, key: Key, rec: unknown) => Promise<void>;
198
+ export type TriggerFunctionWF<Key extends unknown[]> = (op: TriggerOperation, key: Key, rec: unknown) => Promise<void>;
199
+
200
+ class TriggerPayloadQueue {
201
+ notifyPayloads: TriggerPayload[] = [];
202
+ catchupPayloads: TriggerPayload[] = [];
203
+ catchupFinished: boolean = false;
204
+ shutdown: boolean = false;
205
+ waiting: ((value: TriggerPayload | null) => void)[] = [];
206
+
207
+ enqueueCatchup(tp: TriggerPayload) {
208
+ const resolve = this.waiting.shift();
209
+ if (resolve) {
210
+ resolve(tp);
211
+ } else {
212
+ this.catchupPayloads.push(tp);
213
+ }
214
+ }
215
+
216
+ enqueueNotify(tp: TriggerPayload) {
217
+ if (!this.catchupFinished) {
218
+ this.notifyPayloads.push(tp);
219
+ return;
220
+ }
221
+
222
+ const resolve = this.waiting.shift();
223
+ if (resolve) {
224
+ resolve(tp);
225
+ } else {
226
+ this.notifyPayloads.push(tp);
227
+ }
228
+ }
229
+
230
+ async dequeue(): Promise<TriggerPayload | null> {
231
+ if (this.shutdown) return null;
232
+ if (this.catchupPayloads.length > 0) {
233
+ return this.catchupPayloads.shift()!;
234
+ } else if (this.catchupFinished && this.notifyPayloads.length > 0) {
235
+ return this.notifyPayloads.shift()!;
236
+ } else {
237
+ return new Promise<TriggerPayload | null>((resolve) => {
238
+ this.waiting.push(resolve);
239
+ });
240
+ }
241
+ }
242
+
243
+ finishCatchup() {
244
+ this.catchupFinished = true;
245
+ while (true) {
246
+ if (!this.waiting[0] || !this.notifyPayloads[0]) break;
247
+ this.waiting.shift()!(this.notifyPayloads.shift()!);
248
+ }
249
+ }
250
+
251
+ stop() {
252
+ this.shutdown = true;
253
+ while (true) {
254
+ const resolve = this.waiting.shift();
255
+ if (!resolve) break;
256
+ resolve(null);
257
+ }
258
+ }
259
+
260
+ restart() {
261
+ this.shutdown = false;
262
+ }
263
+ }
264
+
265
+ export class DBTrigger implements DBOSLifecycleCallback {
266
+ listeners: DBNotificationListener[] = [];
267
+ tableToReg: Map<string, ExternalRegistration[]> = new Map();
268
+ shutdown: boolean = false;
269
+ payloadQ: TriggerPayloadQueue = new TriggerPayloadQueue();
270
+ dispatchLoops: Promise<void>[] = [];
271
+ pollers: DBTPollingLoop[] = [];
272
+ pollLoops: Promise<void>[] = [];
273
+
274
+ constructor(readonly db: DBConfig) {
275
+ DBOS.registerLifecycleCallback(this);
276
+ }
277
+
278
+ async createPoll(tc: DBTriggerConfig, fullname: string, tname: string, tstr: string) {
279
+ // Initiate catchup work
280
+ let recseqnum: bigint | null = null;
281
+ let rectmstmp: number | null = null;
282
+ if (tc.sequenceNumColumn || tc.timestampColumn) {
283
+ const lasts = await DBOS.getEventDispatchState('trigger', fullname, 'last');
284
+ recseqnum = lasts?.updateSeq ? BigInt(lasts.updateSeq) : null;
285
+ rectmstmp = lasts?.updateTime ?? null;
286
+ if (recseqnum && tc.sequenceNumJitter) {
287
+ recseqnum -= BigInt(tc.sequenceNumJitter);
288
+ }
289
+ if (rectmstmp && tc.timestampSkewMS) {
290
+ rectmstmp -= tc.timestampSkewMS;
291
+ }
292
+ }
293
+
294
+ // Catchup query
295
+ return createCatchupSql(tc, tname, tstr, recseqnum, rectmstmp);
296
+ }
297
+
298
+ async initialize() {
299
+ this.shutdown = false;
300
+ this.payloadQ.restart();
301
+ this.tableToReg.clear();
302
+
303
+ const hasTrigger: Set<string> = new Set();
304
+ let hasAnyTrigger: boolean = false;
305
+ let hasAnyPoller: boolean = false;
306
+ const nname = 'dbos_table_update';
307
+
308
+ const catchups: { query: string; params: unknown[] }[] = [];
309
+
310
+ const regops = DBOS.getAssociatedInfo(this);
311
+ for (const registeredOperation of regops) {
312
+ const mo = registeredOperation.methodConfig as DBTriggerRegistration;
313
+
314
+ if (mo.triggerConfig) {
315
+ const mr = registeredOperation.methodReg;
316
+ const cname = mr.className;
317
+ const mname = mr.name;
318
+ const tname = mo.triggerConfig.schemaName
319
+ ? `${quoteIdentifier(mo.triggerConfig.schemaName)}.${quoteIdentifier(mo.triggerConfig.tableName)}`
320
+ : quoteIdentifier(mo.triggerConfig.tableName);
321
+
322
+ const tfname = `tf_${cname}_${mname}`;
323
+ const tstr = mo.triggerConfig.schemaName
324
+ ? `${mo.triggerConfig.schemaName}.${mo.triggerConfig.tableName}`
325
+ : mo.triggerConfig.tableName;
326
+ const trigname = `dbt_${cname}_${mname}`;
327
+ const fullname = `${cname}.${mname}`;
328
+
329
+ if (!this.tableToReg.has(tstr)) {
330
+ this.tableToReg.set(tstr, []);
331
+ }
332
+ this.tableToReg.get(tstr)!.push(registeredOperation);
333
+
334
+ let registeredThis = false;
335
+ if (mo.triggerConfig.useDBNotifications || mo.triggerConfig.installDBTrigger) {
336
+ if (!hasTrigger.has(tname)) {
337
+ const trigSQL = createTriggerSQL(tfname, trigname, tname, tstr, nname);
338
+ if (mo.triggerConfig.installDBTrigger) {
339
+ await this.db.query(trigSQL);
340
+ } else {
341
+ DBOS.logger.info(` DBOS DB Trigger: For DB notifications, install the following SQL: \n${trigSQL}`);
342
+ }
343
+ hasTrigger.add(tname);
344
+ hasAnyTrigger = true;
345
+ }
346
+ registeredThis = true;
347
+ }
348
+
349
+ if (mo.triggerIsWorkflow) {
350
+ // Initiate catchup work
351
+ const tc = mo.triggerConfig;
352
+ const catchup = await this.createPoll(tc, fullname, tname, tstr);
353
+
354
+ // Catchup query
355
+ catchups.push(catchup);
356
+
357
+ // Launch poller if needed
358
+ if (!(mo.triggerConfig.useDBNotifications || mo.triggerConfig.installDBTrigger) && tc.dbPollingInterval) {
359
+ const poller = new DBTPollingLoop(this, tc, registeredOperation, tname, tstr);
360
+ this.pollers.push(poller);
361
+ this.pollLoops.push(poller.startLoop());
362
+ hasAnyPoller = true;
363
+ registeredThis = true;
364
+ }
365
+ }
366
+
367
+ if (!registeredThis) {
368
+ DBOS.logger.warn(
369
+ `The DB trigger configuration for ${fullname} does not specify to use DB notifications, nor does it provide a polling interval, and will therefore never run.`,
370
+ );
371
+ }
372
+ }
373
+ }
374
+
375
+ if (hasAnyTrigger || hasAnyPoller) {
376
+ if (hasAnyTrigger) {
377
+ const handler = (msg: DBNotification) => {
378
+ if (msg.channel !== nname) return;
379
+ const payload = JSON.parse(msg.payload!) as TriggerPayload;
380
+ this.payloadQ.enqueueNotify(payload);
381
+ };
382
+
383
+ this.listeners.push(await dbListen(this.db, [nname], handler));
384
+ DBOS.logger.info(`DB Triggers now listening for '${nname}'`);
385
+
386
+ for (const q of catchups) {
387
+ const catchupFunc = async () => {
388
+ try {
389
+ const rows = await this.db.query<{ payload: string }>(q.query, q.params);
390
+ for (const r of rows) {
391
+ const payload = JSON.parse(r.payload) as TriggerPayload;
392
+ this.payloadQ.enqueueCatchup(payload);
393
+ }
394
+ } catch (e) {
395
+ DBOS.logger.error(e);
396
+ }
397
+ };
398
+
399
+ await catchupFunc();
400
+ }
401
+
402
+ this.payloadQ.finishCatchup();
403
+ }
404
+
405
+ const payloadFunc = async (payload: TriggerPayload) => {
406
+ for (const regOp of this.tableToReg.get(payload.tname) ?? []) {
407
+ const mr = regOp.methodReg;
408
+ const mo = regOp.methodConfig as DBTriggerRegistration;
409
+ if (!mo.triggerConfig) continue;
410
+ const key: unknown[] = [];
411
+ const keystr: string[] = [];
412
+ for (const kn of mo.triggerConfig?.recordIDColumns ?? []) {
413
+ const cv = Object.hasOwn(payload.record, kn) ? payload.record[kn] : undefined;
414
+ key.push(cv);
415
+ keystr.push(`${cv?.toString()}`);
416
+ }
417
+ try {
418
+ const cname = mr.className;
419
+ const mname = mr.name;
420
+ const fullname = `${cname}.${mname}`;
421
+ if (mo.triggerIsWorkflow) {
422
+ // Record the time of the wf kicked off (if given)
423
+ const tc = mo.triggerConfig;
424
+ let recseqnum: bigint | null = null;
425
+ let rectmstmp: number | null = null;
426
+ if (tc.sequenceNumColumn) {
427
+ if (!Object.hasOwn(payload.record, tc.sequenceNumColumn)) {
428
+ DBOS.logger.warn(
429
+ `DB Trigger on '${fullname}' specifies sequence number column '${tc.sequenceNumColumn}, but is not in database record.'`,
430
+ );
431
+ continue;
432
+ }
433
+ const sn = payload.record[tc.sequenceNumColumn];
434
+ if (typeof sn === 'number') {
435
+ recseqnum = BigInt(sn);
436
+ } else if (typeof sn === 'string') {
437
+ recseqnum = BigInt(sn);
438
+ } else if (typeof sn === 'bigint') {
439
+ recseqnum = sn;
440
+ } else {
441
+ DBOS.logger.warn(
442
+ `DB Trigger on '${fullname}' specifies sequence number column '${tc.sequenceNumColumn}, but received "${JSON.stringify(sn)}" instead of number'`,
443
+ );
444
+ continue;
445
+ }
446
+ keystr.push(`${recseqnum.toString()}`);
447
+ }
448
+ if (tc.timestampColumn) {
449
+ if (!Object.hasOwn(payload.record, tc.timestampColumn)) {
450
+ DBOS.logger.warn(
451
+ `DB Trigger on '${fullname}' specifies timestamp column '${tc.timestampColumn}, but is not in database record.'`,
452
+ );
453
+ continue;
454
+ }
455
+ const ts = payload.record[tc.timestampColumn];
456
+ if (ts instanceof Date) {
457
+ rectmstmp = ts.getTime();
458
+ } else if (typeof ts === 'number') {
459
+ rectmstmp = ts;
460
+ } else if (typeof ts === 'string') {
461
+ rectmstmp = new Date(ts).getTime();
462
+ } else {
463
+ DBOS.logger.warn(
464
+ `DB Trigger on '${fullname}' specifies timestamp column '${tc.timestampColumn}, but received "${JSON.stringify(ts)}" instead of date/number'`,
465
+ );
466
+ continue;
467
+ }
468
+ keystr.push(`${rectmstmp}`);
469
+ }
470
+
471
+ const wfParams = {
472
+ workflowUUID: `dbt_${cname}_${mname}_${keystr.join('|')}`,
473
+ configuredInstance: null,
474
+ queueName: tc.queueName,
475
+ };
476
+ if (payload.operation === TriggerOperation.RecordDeleted) {
477
+ DBOS.logger.warn(
478
+ `DB Trigger ${fullname} on '${payload.tname}' witnessed a record deletion. Record deletion workflow triggers are not supported.`,
479
+ );
480
+ continue;
481
+ }
482
+ if (payload.operation === TriggerOperation.RecordUpdated && recseqnum === null && rectmstmp === null) {
483
+ DBOS.logger.warn(
484
+ `DB Trigger ${fullname} on '${payload.tname}' witnessed a record update, but no sequence number / timestamp is defined. Record update workflow triggers will not work in this case.`,
485
+ );
486
+ continue;
487
+ }
488
+ if (rectmstmp !== null) wfParams.workflowUUID += `_${rectmstmp}`;
489
+ if (recseqnum !== null) wfParams.workflowUUID += `_${recseqnum}`;
490
+ payload.operation = TriggerOperation.RecordUpserted;
491
+ DBOS.logger.debug(`Executing ${fullname} on ID ${wfParams.workflowUUID} queue ${wfParams.queueName}`);
492
+ await DBOS.startWorkflow(regOp.methodReg.registeredFunction as TriggerFunctionWF<unknown[]>, wfParams)(
493
+ payload.operation,
494
+ key,
495
+ payload.record,
496
+ );
497
+
498
+ await DBOS.upsertEventDispatchState({
499
+ service: 'trigger',
500
+ workflowFnName: fullname,
501
+ key: 'last',
502
+ value: '',
503
+ updateSeq: recseqnum ? recseqnum : undefined,
504
+ updateTime: rectmstmp ? rectmstmp : undefined,
505
+ });
506
+ } else {
507
+ // Use original func, this may not be wrapped
508
+ (await regOp.methodReg.invoke(undefined, [payload.operation, key, payload.record])) as TriggerFunction<
509
+ unknown[]
510
+ >;
511
+ }
512
+ } catch (e) {
513
+ DBOS.logger.warn(`Caught an exception in trigger handling for "${mr.className}.${mr.name}"`);
514
+ DBOS.logger.warn(e);
515
+ }
516
+ }
517
+ };
518
+
519
+ const processingFunc = async () => {
520
+ while (true) {
521
+ const p = await this.payloadQ.dequeue();
522
+ if (p === null) break;
523
+ await payloadFunc(p);
524
+ }
525
+ };
526
+
527
+ this.dispatchLoops.push(processingFunc());
528
+ }
529
+ }
530
+
531
+ async destroy() {
532
+ this.shutdown = true;
533
+ this.payloadQ.stop();
534
+ for (const l of this.listeners) {
535
+ try {
536
+ await l.close();
537
+ } catch (e) {
538
+ DBOS.logger.warn(e);
539
+ }
540
+ }
541
+ this.listeners = [];
542
+ for (const p of this.dispatchLoops) {
543
+ try {
544
+ await p;
545
+ } catch (e) {
546
+ // Error in destroy, NBD
547
+ }
548
+ }
549
+ this.dispatchLoops = [];
550
+ for (const p of this.pollers) {
551
+ p.setStopLoopFlag();
552
+ }
553
+ this.pollers = [];
554
+ for (const p of this.pollLoops) {
555
+ try {
556
+ await p;
557
+ } catch (e) {
558
+ // Error in destroy, NBD
559
+ }
560
+ }
561
+ this.pollLoops = [];
562
+ this.tableToReg = new Map();
563
+ }
564
+
565
+ logRegisteredEndpoints() {
566
+ DBOS.logger.info('Database trigger endpoints registered:');
567
+ const eps = DBOS.getAssociatedInfo(this);
568
+
569
+ for (const e of eps) {
570
+ const { methodConfig, methodReg } = e;
571
+ const mo = methodConfig as DBTriggerRegistration;
572
+ if (mo.triggerConfig) {
573
+ const cname = methodReg.className;
574
+ const mname = methodReg.name;
575
+ const tname = mo.triggerConfig.schemaName
576
+ ? `${mo.triggerConfig.schemaName}.${mo.triggerConfig.tableName}`
577
+ : mo.triggerConfig.tableName;
578
+ DBOS.logger.info(` ${tname} -> ${cname}.${mname}`);
579
+ }
580
+ }
581
+ }
582
+
583
+ trigger(triggerConfig: DBTriggerConfig) {
584
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
585
+ const dbt = this;
586
+ function trigdec<This, Return, Key extends unknown[]>(
587
+ target: object,
588
+ propertyKey: string,
589
+ descriptor: TypedPropertyDescriptor<
590
+ (this: This, operation: TriggerOperation, key: Key, record: unknown) => Promise<Return>
591
+ >,
592
+ ) {
593
+ const { regInfo } = DBOS.associateFunctionWithInfo(dbt, descriptor.value!, {
594
+ ctorOrProto: target,
595
+ name: propertyKey,
596
+ });
597
+
598
+ const triggerRegistration = regInfo as DBTriggerRegistration;
599
+
600
+ triggerRegistration.triggerConfig = triggerConfig;
601
+ triggerRegistration.triggerIsWorkflow = false;
602
+
603
+ return descriptor;
604
+ }
605
+ return trigdec;
606
+ }
607
+
608
+ triggerWorkflow(wfTriggerConfig: DBTriggerConfig) {
609
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
610
+ const dbt = this;
611
+ function trigdec<This, Return, Key extends unknown[]>(
612
+ target: object,
613
+ propertyKey: string,
614
+ descriptor: TypedPropertyDescriptor<
615
+ (this: This, operation: TriggerOperation, key: Key, record: unknown) => Promise<Return>
616
+ >,
617
+ ) {
618
+ const { regInfo } = DBOS.associateFunctionWithInfo(dbt, descriptor.value!, {
619
+ ctorOrProto: target,
620
+ name: propertyKey,
621
+ });
622
+
623
+ const triggerRegistration = regInfo as DBTriggerRegistration;
624
+ triggerRegistration.triggerConfig = wfTriggerConfig;
625
+ triggerRegistration.triggerIsWorkflow = true;
626
+
627
+ return descriptor;
628
+ }
629
+ return trigdec;
630
+ }
631
+ }
632
+
633
+ class DBTPollingLoop {
634
+ private isRunning: boolean = false;
635
+ private interruptResolve?: () => void;
636
+ private trigMethodName: string;
637
+
638
+ constructor(
639
+ readonly trigER: DBTrigger,
640
+ readonly trigReg: DBTriggerConfig,
641
+ readonly reg: ExternalRegistration,
642
+ readonly tname: string,
643
+ readonly tstr: string,
644
+ ) {
645
+ this.trigMethodName = `${reg.methodReg.className}.${reg.methodReg.name}`;
646
+ }
647
+
648
+ async startLoop(): Promise<void> {
649
+ // See if the exec time is available in durable storage...
650
+ let execTime = new Date().getTime();
651
+
652
+ this.isRunning = true;
653
+ while (this.isRunning) {
654
+ const nextExecTime = execTime + (this.trigReg.dbPollingInterval ?? 5000);
655
+ const sleepTime = nextExecTime - new Date().getTime();
656
+ execTime = nextExecTime;
657
+
658
+ if (sleepTime > 0) {
659
+ // Wait for either the timeout or an interruption
660
+ let timer: NodeJS.Timeout;
661
+ const timeoutPromise = new Promise<void>((resolve) => {
662
+ timer = setTimeout(() => {
663
+ resolve();
664
+ }, sleepTime);
665
+ });
666
+ await Promise.race([timeoutPromise, new Promise<void>((_, reject) => (this.interruptResolve = reject))]).catch(
667
+ () => {
668
+ DBOS.logger.debug('Trigger polling loop interrupted!');
669
+ },
670
+ ); // Interrupt sleep throws
671
+ clearTimeout(timer!);
672
+ }
673
+
674
+ if (!this.isRunning) {
675
+ break;
676
+ }
677
+
678
+ // To catch-up poll
679
+ const catchup = await this.trigER.createPoll(this.trigReg, this.trigMethodName, this.tname, this.tstr);
680
+ try {
681
+ const rows = await this.trigER.db.query<{ payload: string }>(catchup.query, catchup.params);
682
+ for (const r of rows) {
683
+ const payload = JSON.parse(r.payload) as TriggerPayload;
684
+ // Post workflows back to dispatch loop; queue processor will do the updates
685
+ this.trigER.payloadQ.enqueueCatchup(payload);
686
+ }
687
+ } catch (e) {
688
+ DBOS.logger.error(e);
689
+ }
690
+ }
691
+ }
692
+
693
+ setStopLoopFlag() {
694
+ if (!this.isRunning) return;
695
+ this.isRunning = false;
696
+ if (this.interruptResolve) {
697
+ this.interruptResolve(); // Trigger the interruption
698
+ }
699
+ }
700
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { DBTriggerConfig, TriggerOperation, DBTrigger } from './dbtrigger';