184e41b220f570f457764b6ae2b8dd1e215dc53d
[squeep-indie-auther] / src / db / postgres / index.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 const pgpInitOptions = {
5 capSQL: true,
6 };
7
8 const path = require('path');
9 const pgp = require('pg-promise')(pgpInitOptions);
10 const { unappliedSchemaVersions } = require('../schema-version-helper');
11 const Database = require('../abstract');
12 const DBErrors = require('../errors');
13 const common = require('../../common');
14 const Enum = require('../../enum');
15
16 const _fileScope = common.fileScope(__filename);
17
18 const PGTypeIdINT8 = 20; // Type Id 20 == INT8 (BIGINT)
19 const PGTypeIdINT8Array = 1016; //Type Id 1016 == INT8[] (BIGINT[])
20 pgp.pg.types.setTypeParser(PGTypeIdINT8, BigInt); // Type Id 20 = INT8 (BIGINT)
21 const parseBigIntArray = pgp.pg.types.getTypeParser(PGTypeIdINT8Array); // Type Id 1016 = INT8[] (BIGINT[])
22 pgp.pg.types.setTypeParser(PGTypeIdINT8Array, (a) => parseBigIntArray(a).map(BigInt));
23
24 const schemaVersionsSupported = {
25 min: {
26 major: 1,
27 minor: 0,
28 patch: 0,
29 },
30 max: {
31 major: 1,
32 minor: 1,
33 patch: 0,
34 },
35 };
36
37 class DatabasePostgres extends Database {
38 constructor(logger, options, _pgp = pgp) {
39 super(logger, options);
40
41 this.db = _pgp(options.db.connectionString);
42 this.schemaVersionsSupported = schemaVersionsSupported;
43
44 // Suppress QF warnings when running tests
45 this.noWarnings = options.db.noWarnings;
46
47 // Log queries
48 const queryLogLevel = options.db.queryLogLevel;
49 if (queryLogLevel) {
50 const queryScope = _fileScope('pgp:query');
51 pgpInitOptions.query = (event) => {
52 this.logger[queryLogLevel](queryScope, '', { ...common.pick(event || {}, ['query', 'params']) });
53 };
54 }
55
56 // Log errors
57 const errorScope = _fileScope('pgp:error');
58 pgpInitOptions.error = (err, event) => {
59 this.logger.error(errorScope, '', { err, event });
60 };
61
62 // Deophidiate column names in-place, log results
63 pgpInitOptions.receive = ({ data, result, ctx: event }) => {
64 const exemplaryRow = data[0];
65 for (const prop in exemplaryRow) {
66 const camel = common.camelfy(prop);
67 if (!(camel in exemplaryRow)) {
68 for (const d of data) {
69 d[camel] = d[prop];
70 delete d[prop];
71 }
72 }
73 }
74 if (queryLogLevel) {
75 // Omitting .rows
76 const resultLog = common.pick(result || {}, ['command', 'rowCount', 'duration']);
77 this.logger[queryLogLevel](_fileScope('pgp:result'), '', { query: event.query, ...resultLog });
78 }
79 };
80
81 // Expose these for test coverage
82 this.pgpInitOptions = pgpInitOptions;
83 this._pgp = _pgp;
84
85 this._initStatements(_pgp);
86 }
87
88
89 _queryFileHelper(_pgp) {
90 return (file) => {
91 const _scope = _fileScope('_queryFile');
92 /* istanbul ignore next */
93 const qfParams = {
94 minify: true,
95 ...(this.noWarnings && { noWarnings: this.noWarnings }),
96 };
97 const qf = new _pgp.QueryFile(file, qfParams);
98 if (qf.error) {
99 this.logger.error(_scope, 'failed to create SQL statement', { error: qf.error, file });
100 throw qf.error;
101 }
102 return qf;
103 };
104 }
105
106
107 async initialize(applyMigrations = true) {
108 const _scope = _fileScope('initialize');
109 this.logger.debug(_scope, 'called', { applyMigrations });
110 if (applyMigrations) {
111 await this._initTables();
112 }
113 await super.initialize();
114 if (this.listener) {
115 await this.listener.start();
116 }
117 }
118
119
120 async _initTables(_pgp) {
121 const _scope = _fileScope('_initTables');
122 this.logger.debug(_scope, 'called', {});
123
124 const _queryFile = this._queryFileHelper(_pgp || this._pgp);
125
126 // Migrations rely upon this table, ensure it exists.
127 const metaVersionTable = '_meta_schema_version';
128
129 const tableExists = async (name) => this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(name)', { name });
130 let metaExists = await tableExists(metaVersionTable);
131 if (!metaExists) {
132 const fPath = path.join(__dirname, 'sql', 'schema', 'init.sql');
133 const initSql = _queryFile(fPath);
134 const results = await this.db.multiResult(initSql);
135 this.logger.debug(_scope, 'executed init sql', { results });
136 metaExists = await tableExists(metaVersionTable);
137 /* istanbul ignore if */
138 if (!metaExists) {
139 throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
140 }
141 this.logger.info(_scope, 'created schema version table', { metaVersionTable });
142 }
143
144 // Apply migrations
145 const currentSchema = await this._currentSchema();
146 const migrationsWanted = unappliedSchemaVersions(__dirname, currentSchema, this.schemaVersionsSupported);
147 this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
148 for (const v of migrationsWanted) {
149 const fPath = path.join(__dirname, 'sql', 'schema', v, 'apply.sql');
150 const migrationSql = _queryFile(fPath);
151 const results = await this.db.multiResult(migrationSql);
152 this.logger.debug(_scope, 'executed migration sql', { version: v, results });
153 this.logger.info(_scope, 'applied migration', { version: v });
154 }
155 }
156
157
158 _initStatements(_pgp) {
159 const _scope = _fileScope('_initStatements');
160 const _queryFile = this._queryFileHelper(_pgp);
161 this.statement = _pgp.utils.enumSql(path.join(__dirname, 'sql'), {}, _queryFile);
162 this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
163 }
164
165
166 async healthCheck() {
167 const _scope = _fileScope('healthCheck');
168 this.logger.debug(_scope, 'called', {});
169 const c = await this.db.connect();
170 c.done();
171 return { serverVersion: c.client.serverVersion };
172 }
173
174
175 async _currentSchema() {
176 return this.db.one('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1');
177 }
178
179
180 async _closeConnection() {
181 const _scope = _fileScope('_closeConnection');
182 try {
183 if (this.listener) {
184 await this.listener.stop();
185 }
186 await this._pgp.end();
187 } catch (e) {
188 this.logger.error(_scope, 'failed', { error: e });
189 throw e;
190 }
191 }
192
193
194 /* istanbul ignore next */
195 async _purgeTables(really = false) {
196 const _scope = _fileScope('_purgeTables');
197 try {
198 if (really) {
199 await this.db.tx(async (t) => {
200 await t.batch([
201 'almanac',
202 'authentication',
203 'profile',
204 'redeemed_ticket',
205 'resource',
206 'token',
207 ].map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table })));
208 });
209 }
210 } catch (e) {
211 this.logger.error(_scope, 'failed', { error: e });
212 throw e;
213 }
214 }
215
216
217 async context(fn) {
218 return this.db.task(async (t) => fn(t));
219 }
220
221
222 // eslint-disable-next-line class-methods-use-this
223 async transaction(dbCtx, fn) {
224 return dbCtx.txIf(async (t) => fn(t));
225 }
226
227
228 static _almanacErrorThrow() {
229 throw new DBErrors.UnexpectedResult('did not update almanac');
230 }
231
232
233 async almanacGetAll(dbCtx) {
234 const _scope = _fileScope('almanacGetAll');
235 this.logger.debug(_scope, 'called');
236
237 try {
238 return await dbCtx.manyOrNone(this.statement.almanacGetAll);
239 } catch (e) {
240 this.logger.error(_scope, 'failed', { error: e });
241 throw e;
242 }
243 }
244
245
246 async almanacUpsert(dbCtx, event, date) {
247 const _scope = _fileScope('almanacUpsert');
248 this.logger.debug(_scope, 'called', { event, date });
249
250 try {
251 const result = await dbCtx.result(this.statement.almanacUpsert, { event, date: date ?? new Date() });
252 if (result.rowCount != 1) {
253 this.constructor._almanacErrorThrow();
254 }
255 } catch (e) {
256 this.logger.error(_scope, 'failed', { error: e, event, date });
257 throw e;
258 }
259 }
260
261
262 async authenticationGet(dbCtx, identifier) {
263 const _scope = _fileScope('authenticationGet');
264 this.logger.debug(_scope, 'called', { identifier });
265
266 try {
267 return await dbCtx.oneOrNone(this.statement.authenticationGet, { identifier });
268 } catch (e) {
269 this.logger.error(_scope, 'failed', { error: e, identifier });
270 throw e;
271 }
272 }
273
274
275 async authenticationSuccess(dbCtx, identifier) {
276 const _scope = _fileScope('authenticationSuccess');
277 this.logger.debug(_scope, 'called', { identifier });
278
279 try {
280 const result = await dbCtx.result(this.statement.authenticationSuccess, { identifier });
281 if (result.rowCount != 1) {
282 throw new DBErrors.UnexpectedResult('did not update authentication success event');
283 }
284 } catch (e) {
285 this.logger.error(_scope, 'failed', { error: e, identifier });
286 throw e;
287 }
288 }
289
290
291 async authenticationUpsert(dbCtx, identifier, credential) {
292 const _scope = _fileScope('authenticationUpsert');
293 const scrubbedCredential = '*'.repeat((credential || '').length);
294 this.logger.debug(_scope, 'called', { identifier, scrubbedCredential });
295
296 try {
297 const result = await dbCtx.result(this.statement.authenticationUpsert, { identifier, credential });
298 if (result.rowCount != 1) {
299 throw new DBErrors.UnexpectedResult('did not upsert authentication');
300 }
301 } catch (e) {
302 this.logger.error(_scope, 'failed', { error: e, identifier, scrubbedCredential });
303 throw e;
304 }
305 }
306
307
308 async profileIdentifierInsert(dbCtx, profile, identifier) {
309 const _scope = _fileScope('profileIdentifierInsert');
310 this.logger.debug(_scope, 'called', { profile, identifier });
311
312 try {
313 const result = await dbCtx.result(this.statement.profileIdentifierInsert, { profile, identifier });
314 if (result.rowCount != 1) {
315 throw new DBErrors.UnexpectedResult('did not insert identifier');
316 }
317 } catch (e) {
318 this.logger.error(_scope, 'failed', { error: e, profile, identifier });
319 throw e;
320 }
321 }
322
323
324 async profileIsValid(dbCtx, profile) {
325 const _scope = _fileScope('profileIsValid');
326 this.logger.debug(_scope, 'called', { profile });
327
328 try {
329 const profileResponse = await dbCtx.oneOrNone(this.statement.profileGet, { profile });
330 return !!profileResponse;
331 } catch (e) {
332 this.logger.error(_scope, 'failed', { error: e, profile });
333 throw e;
334 }
335 }
336
337
338 async profileScopeInsert(dbCtx, profile, scope) {
339 const _scope = _fileScope('profileScopeInsert');
340 this.logger.debug(_scope, 'called', { profile, scope });
341
342 try {
343 const result = await dbCtx.result(this.statement.profileScopeInsert, { profile, scope });
344 // Duplicate inserts get ignored
345 if (result.rowCount != 1 && result.rowCount != 0) {
346 throw new DBErrors.UnexpectedResult('did not insert profile scope');
347 }
348 } catch (e) {
349 this.logger.error(_scope, 'failed', { error: e, profile, scope });
350 throw e;
351 }
352 }
353
354
355 async profileScopesSetAll(dbCtx, profile, scopes) {
356 const _scope = _fileScope('profileScopesSetAll');
357 this.logger.debug(_scope, 'called', { profile, scopes });
358
359 try {
360 await this.transaction(dbCtx, async (txCtx) => {
361 await txCtx.result(this.statement.profileScopesClear, { profile });
362 if (scopes.length) {
363 await txCtx.result(this.statement.profileScopesSetAll, { profile, scopes });
364 }
365 }); // transaction
366 } catch (e) {
367 this.logger.error(_scope, 'failed', { error: e, profile, scopes });
368 throw e;
369 }
370 }
371
372
373 async profilesScopesByIdentifier(dbCtx, identifier) {
374 const _scope = _fileScope('profilesScopesByIdentifier');
375 this.logger.debug(_scope, 'called', { identifier });
376
377 try {
378 const profileScopesRows = await dbCtx.manyOrNone(this.statement.profilesScopesByIdentifier, { identifier });
379 return Database._profilesScopesBuilder(profileScopesRows);
380 } catch (e) {
381 this.logger.error(_scope, 'failed', { error: e, identifier });
382 throw e;
383 }
384 }
385
386
387 async redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, resource, profileData }) {
388 const _scope = _fileScope('redeemCode');
389 this.logger.debug(_scope, 'called', { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, resource, profileData });
390
391 let result, ret = false;
392 try {
393 await this.transaction(dbCtx, async (txCtx) => {
394 result = await txCtx.result(this.statement.redeemCode, { codeId, created, isToken, clientId, profile, identifier, lifespanSeconds, refreshLifespanSeconds, resource, profileData });
395 if (result.rowCount != 1) {
396 this.logger.error(_scope, 'failed', { result });
397 throw new DBErrors.UnexpectedResult('did not redeem code');
398 }
399 // Abort and return false if redemption resulted in revocation.
400 if (result.rows[0].isRevoked) {
401 return;
402 }
403 this.logger.debug(_scope, 'code redeemed', { redeemed: result.rows[0] });
404
405 // Ensure there are entries for all scopes.
406 if (scopes.length !== 0) {
407 await txCtx.result(this.statement.scopesInsert, { scopes });
408 }
409
410 // Record accepted scopes for this token.
411 result = await txCtx.result(this.statement.tokenScopesSet, { codeId, scopes });
412 if (result.rowCount != scopes.length) {
413 this.logger.error(_scope, 'token scope count mismatch', { codeId, scopes, result });
414 throw new DBErrors.UnexpectedResult('did not set all scopes on token');
415 }
416 ret = true;
417 }); // txCtx
418 } catch (e) {
419 this.logger.error(_scope, 'failed', { error: e, codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData });
420 throw e;
421 }
422
423 return ret;
424 }
425
426
427 async refreshCode(dbCtx, codeId, refreshed, removeScopes) {
428 const _scope = _fileScope('refreshCode');
429 this.logger.debug(_scope, 'called', { codeId, refreshed, removeScopes });
430
431 try {
432 return await this.transaction(dbCtx, async (txCtx) => {
433 const refreshedToken = await txCtx.oneOrNone(this.statement.refreshCode, { codeId, refreshed });
434 if (refreshedToken) {
435 if (removeScopes.length) {
436 const removeResult = await txCtx.result(this.statement.tokenScopesRemove, { codeId, removeScopes });
437 if (removeResult.rowCount != removeScopes.length) {
438 this.logger.error(_scope, 'failed to remove token scopes', { actual: removeResult.rowCount, expected: removeScopes.length });
439 throw new DBErrors.UnexpectedResult('did not remove scopes from token');
440 }
441 } else {
442 delete refreshedToken.scopes; // Not updated, remove from response.
443 }
444 } else {
445 this.logger.debug(_scope, 'did not refresh token', {});
446 }
447 return refreshedToken;
448 });
449 } catch (e) {
450 this.logger.error(_scope, 'failed', { error: e, codeId });
451 throw e;
452 }
453 }
454
455
456 async resourceGet(dbCtx, resourceId) {
457 const _scope = _fileScope('resourceGet');
458 this.logger.debug(_scope, 'called', { resourceId });
459
460 try {
461 return await dbCtx.oneOrNone(this.statement.resourceGet, { resourceId });
462 } catch (e) {
463 this.logger.error(_scope, 'failed', { error: e, resourceId });
464 throw e;
465 }
466 }
467
468
469 async resourceUpsert(dbCtx, resourceId, secret, description) {
470 const _scope = _fileScope('resourceUpsert');
471 const logSecret = secret?.length && common.logTruncate('*'.repeat(secret.length), 3) || undefined;
472 this.logger.debug(_scope, 'called', { resourceId, secret: logSecret, description });
473
474 try {
475 const result = await dbCtx.result(this.statement.resourceUpsert, { resourceId, secret, description });
476 if (result.rowCount != 1) {
477 throw new DBErrors.UnexpectedResult('did not upsert resource');
478 }
479 return result.rows[0];
480 } catch (e) {
481 this.logger.error(_scope, 'failed', { error: e, resourceId, secret: logSecret, description });
482 throw e;
483 }
484 }
485
486
487 async scopeCleanup(dbCtx, atLeastMsSinceLast) {
488 const _scope = _fileScope('scopeCleanup');
489 this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
490
491 const almanacEvent = Enum.AlmanacEntry.ScopeCleanup;
492 try {
493 return await this.transaction(dbCtx, async (txCtx) => {
494
495 // Check that enough time has passed since last cleanup
496 const now = new Date();
497 const cleanupNotAfter = new Date(now.getTime() - atLeastMsSinceLast);
498 const { date: lastCleanupDate } = await txCtx.oneOrNone(this.statement.almanacGet, { event: almanacEvent }) || { date: new Date(0) };
499 if (lastCleanupDate >= cleanupNotAfter) {
500 this.logger.debug(_scope, 'skipping token cleanup, too soon', { lastCleanupDate, cleanupNotAfter, atLeastMsSinceLast });
501 return;
502 }
503
504 // Do the cleanup
505 const { rowCount: scopesRemoved } = await txCtx.result(this.statement.scopeCleanup);
506
507 // Update the last cleanup time
508 const result = await txCtx.result(this.statement.almanacUpsert, { event: almanacEvent, date: now });
509 if (result.rowCount != 1) {
510 this.constructor._almanacErrorThrow();
511 }
512
513 this.logger.debug(_scope, 'completed', { scopesRemoved, atLeastMsSinceLast });
514 return scopesRemoved;
515 }); // tx
516
517 } catch (e) {
518 this.logger.error(_scope, 'failed', { error: e, atLeastMsSinceLast });
519 throw e;
520 }
521 }
522
523
524 async scopeDelete(dbCtx, scope) {
525 const _scope = _fileScope('scopeDelete');
526 this.logger.debug(_scope, 'called', { scope });
527
528 try {
529 return await this.transaction(dbCtx, async (txCtx) => {
530 const { inUse } = await txCtx.one(this.statement.scopeInUse, { scope });
531 if (inUse) {
532 this.logger.debug(_scope, 'not deleted, in use', { scope });
533 return false;
534 }
535 const result = await txCtx.result(this.statement.scopeDelete, { scope });
536 if (result.rowCount == 0) {
537 this.logger.debug(_scope, 'no such scope', { scope });
538 } else {
539 this.logger.debug(_scope, 'deleted', { scope });
540 }
541 return true;
542 });
543 } catch (e) {
544 this.logger.error(_scope, 'failed', { error: e, scope });
545 throw e;
546 }
547 }
548
549
550 async scopeUpsert(dbCtx, scope, application, description, manuallyAdded = false) {
551 const _scope = _fileScope('scopeUpsert');
552 this.logger.debug(_scope, 'called', { scope, description });
553
554 try {
555 const result = await dbCtx.result(this.statement.scopeUpsert, { scope, application, description, manuallyAdded });
556 if (result.rowCount != 1) {
557 throw new DBErrors.UnexpectedResult('did not upsert scope');
558 }
559 } catch (e) {
560 this.logger.error(_scope, 'failed', { error: e, scope, application, description });
561 throw e;
562 }
563 }
564
565
566 async tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast) {
567 const _scope = _fileScope('tokenCleanup');
568 this.logger.debug(_scope, 'called', { codeLifespanSeconds, atLeastMsSinceLast });
569
570 const almanacEvent = Enum.AlmanacEntry.TokenCleanup;
571 try {
572 return await this.transaction(dbCtx, async (txCtx) => {
573
574 // Check that enough time has passed since last cleanup
575 const now = new Date();
576 const cleanupNotAfter = new Date(now.getTime() - atLeastMsSinceLast);
577 const { date: lastCleanupDate } = await txCtx.oneOrNone(this.statement.almanacGet, { event: almanacEvent }) || { date: new Date(0) };
578 if (lastCleanupDate >= cleanupNotAfter) {
579 this.logger.debug(_scope, 'skipping token cleanup, too soon', { lastCleanupDate, cleanupNotAfter, codeLifespanSeconds, atLeastMsSinceLast });
580 return;
581 }
582
583 // Do the cleanup
584 const { rowCount: tokensRemoved } = await txCtx.result(this.statement.tokenCleanup, { codeLifespanSeconds });
585
586 // Update the last cleanup time
587 const result = await txCtx.result(this.statement.almanacUpsert, { event: almanacEvent, date: now });
588 if (result.rowCount != 1) {
589 this.constructor._almanacErrorThrow();
590 }
591
592 this.logger.debug(_scope, 'completed', { tokensRemoved, codeLifespanSeconds, atLeastMsSinceLast });
593 return tokensRemoved;
594 }); // tx
595
596 } catch (e) {
597 this.logger.error(_scope, 'failed', { error: e, atLeastMsSinceLast });
598 throw e;
599 }
600 }
601
602
603 async tokenGetByCodeId(dbCtx, codeId) {
604 const _scope = _fileScope('tokenGetByCodeId');
605 this.logger.debug(_scope, 'called', { codeId });
606
607 try {
608 return await dbCtx.oneOrNone(this.statement.tokenGetByCodeId, { codeId });
609 } catch (e) {
610 this.logger.error(_scope, 'failed', { error: e, codeId });
611 throw e;
612 }
613 }
614
615
616 async tokenRevokeByCodeId(dbCtx, codeId) {
617 const _scope = _fileScope('tokenRevokeByCodeId');
618 this.logger.debug(_scope, 'called', { codeId });
619
620 try {
621 const result = await dbCtx.result(this.statement.tokenRevokeByCodeId, { codeId });
622 if (result.rowCount != 1) {
623 throw new DBErrors.UnexpectedResult('did not revoke token');
624 }
625 } catch (e) {
626 this.logger.error(_scope, 'failed', { error: e, codeId });
627 throw e;
628 }
629 }
630
631
632 async tokenRefreshRevokeByCodeId(dbCtx, codeId) {
633 const _scope = _fileScope('tokenRefreshRevokeByCodeId');
634 this.logger.debug(_scope, 'called', { codeId });
635
636 try {
637 const result = await dbCtx.result(this.statement.tokenRefreshRevokeByCodeId, { codeId });
638 if (result.rowCount != 1) {
639 throw new DBErrors.UnexpectedResult('did not revoke token');
640 }
641 } catch (e) {
642 this.logger.error(_scope, 'failed', { error: e, codeId });
643 throw e;
644 }
645 }
646
647
648 async tokensGetByIdentifier(dbCtx, identifier) {
649 const _scope = _fileScope('tokensGetByIdentifier');
650 this.logger.debug(_scope, 'called', { identifier });
651
652 try {
653 return await dbCtx.manyOrNone(this.statement.tokensGetByIdentifier, { identifier });
654 } catch (e) {
655 this.logger.error(_scope, 'failed', { error: e, identifier });
656 throw e;
657 }
658 }
659
660
661 async ticketRedeemed(dbCtx, redeemedData) {
662 const _scope = _fileScope('ticketRedeemed');
663 this.logger.debug(_scope, 'called', { ...redeemedData });
664
665 try {
666 const result = await dbCtx.result(this.statement.ticketRedeemed, redeemedData);
667 if (result.rowCount != 1) {
668 throw new DBErrors.UnexpectedResult('did not store redeemed ticket');
669 }
670 } catch (e) {
671 this.logger.error(_scope, 'failed', { error: e, ...redeemedData });
672 throw e;
673 }
674 }
675
676
677 async ticketTokenPublished(dbCtx, redeemedData) {
678 const _scope = _fileScope('ticketRedeemed');
679 this.logger.debug(_scope, 'called', { ...redeemedData });
680
681 const almanacEvent = Enum.AlmanacEntry.TicketPublished;
682 try {
683 const result = await dbCtx.result(this.statement.ticketTokenPublished, redeemedData);
684 if (result.rowCount != 1) {
685 throw new DBErrors.UnexpectedResult('did not store redeemed ticket');
686 }
687 const almanacResult = await dbCtx.result(this.statement.almanacUpsert, { event: almanacEvent, date: new Date() });
688 if (almanacResult.rowCount != 1) {
689 this.constructor._almanacErrorThrow();
690 }
691 } catch (e) {
692 this.logger.error(_scope, 'failed', { error: e, ...redeemedData });
693 throw e;
694 }
695 }
696
697 async ticketTokenGetUnpublished(dbCtx) {
698 const _scope = _fileScope('ticketTokenGetUnpublished');
699 this.logger.debug(_scope, 'called');
700
701 try {
702 return await dbCtx.manyOrNone(this.statement.ticketTokenGetUnpublished);
703 } catch (e) {
704 this.logger.error(_scope, 'failed', { error: e });
705 throw e;
706 }
707 }
708
709 }
710
711 module.exports = DatabasePostgres;