@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.
- package/README.md +200 -0
- package/dist/src/dbtrigger.d.ts +89 -0
- package/dist/src/dbtrigger.d.ts.map +1 -0
- package/dist/src/dbtrigger.js +593 -0
- package/dist/src/dbtrigger.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +33 -0
- package/src/dbtrigger.ts +700 -0
- package/src/index.ts +1 -0
- package/tests/dbtriggers.test.ts +188 -0
- package/tests/dbtriggers_poll.test.ts +285 -0
- package/tests/dbtriggers_seq.test.ts +246 -0
- package/tsconfig.json +9 -0
package/src/dbtrigger.ts
ADDED
|
@@ -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';
|