3 const common
= require('../../common');
4 const Database
= require('../base');
5 const DBErrors
= require('../errors');
6 const svh
= require('../schema-version-helper');
7 const SQLite
= require('better-sqlite3');
8 const fs
= require('fs');
9 const path
= require('path');
10 const { performance
} = require('perf_hooks');
12 const _fileScope
= common
.fileScope(__filename
);
14 const schemaVersionsSupported
= {
27 // max of signed int64 (2^63 - 1), should be enough
28 const EPOCH_FOREVER
= BigInt('9223372036854775807');
30 class DatabaseSQLite
extends Database
{
31 constructor(logger
, options
) {
32 super(logger
, options
);
34 const connectionString
= options
.db
.connectionString
|| 'sqlite://:memory:';
35 const csDelim
= '://';
36 const dbFilename
= connectionString
.slice(connectionString
.indexOf(csDelim
) + csDelim
.length
);
38 const queryLogLevel
= options
.db
.queryLogLevel
;
40 const sqliteOptions
= {
41 ...(queryLogLevel
&& {
42 // eslint-disable-next-line security/detect-object-injection
43 verbose: (query
) => this.logger
[queryLogLevel
](_fileScope('SQLite:verbose'), '', { query
}),
46 this.db
= new SQLite(dbFilename
, sqliteOptions
);
47 this.schemaVersionsSupported
= schemaVersionsSupported
;
48 this.changesSinceLastOptimize
= BigInt(0);
49 this.optimizeAfterChanges
= options
.db
.connectionString
.optimizeAfterChanges
;
50 this.db
.pragma('foreign_keys = on'); // Enforce consistency.
51 this.db
.pragma('journal_mode = WAL'); // Be faster, expect local filesystem.
52 this.db
.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs.
55 this._initStatements();
60 * SQLite cannot prepare its statements without a schema, ensure such exists.
63 const _scope
= _fileScope('_initTables');
65 // Migrations rely upon this table, ensure it exists.
66 const metaVersionTable
= '_meta_schema_version';
67 const tableExists
= this.db
.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:name').pluck(true).bind({ type: 'table', name: metaVersionTable
});
68 let metaExists
= tableExists
.get();
69 if (metaExists
=== undefined) {
70 const fPath
= path
.join(__dirname
, 'sql', 'schema', 'init.sql');
71 // eslint-disable-next-line security/detect-non-literal-fs-filename
72 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
74 metaExists
= tableExists
.get();
75 /* istanbul ignore if */
76 if (metaExists
=== undefined) {
77 throw new DBErrors
.UnexpectedResult(`did not create ${metaVersionTable} table`);
79 this.logger
.info(_scope
, 'created schema version table', { metaVersionTable
});
83 const currentSchema
= this._currentSchema();
84 const migrationsWanted
= svh
.unappliedSchemaVersions(__dirname
, currentSchema
, this.schemaVersionsSupported
);
85 this.logger
.debug(_scope
, 'schema migrations wanted', { migrationsWanted
});
86 migrationsWanted
.forEach((v
) => {
87 const fPath
= path
.join(__dirname
, 'sql', 'schema', v
, 'apply.sql');
88 // eslint-disable-next-line security/detect-non-literal-fs-filename
89 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
90 this.logger
.info(_scope
, 'applying migration', { version: v
});
97 const _scope
= _fileScope('_initStatements');
98 const sqlDir
= path
.join(__dirname
, 'sql');
101 // Decorate the statement calls we use with timing and logging.
102 const wrapFetch
= (logName
, statementName
, fn
) => {
103 const _wrapScope
= _fileScope(logName
);
104 return (...args
) => {
105 const startTimestampMs
= performance
.now();
106 const rows
= fn(...args
);
107 DatabaseSQLite
._deOphidiate(rows
);
108 const elapsedTimeMs
= performance
.now() - startTimestampMs
;
109 this.logger
.debug(_wrapScope
, 'complete', { statementName
, elapsedTimeMs
});
113 const wrapRun
= (logName
, statementName
, fn
) => {
114 const _wrapScope
= _fileScope(logName
);
115 return (...args
) => {
116 const startTimestampMs
= performance
.now();
117 const result
= fn(...args
);
118 const elapsedTimeMs
= performance
.now() - startTimestampMs
;
119 this.logger
.debug(_wrapScope
, 'complete', { ...result
, statementName
, elapsedTimeMs
});
120 result
.duration
= elapsedTimeMs
;
125 // eslint-disable-next-line security/detect-non-literal-fs-filename
126 for (const f
of fs
.readdirSync(sqlDir
)) {
127 const fPath
= path
.join(sqlDir
, f
);
128 const { name: fName
, ext: fExt
} = path
.parse(f
);
129 // eslint-disable-next-line security/detect-non-literal-fs-filename
130 const stat
= fs
.statSync(fPath
);
132 || fExt
.toLowerCase() !== '.sql') {
135 // eslint-disable-next-line security/detect-non-literal-fs-filename
136 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
137 const statementName
= Database
._camelfy(fName
.toLowerCase(), '-');
140 statement
= this.db
.prepare(fSql
);
142 /* istanbul ignore next */
143 this.logger
.error(_scope
, 'failed to prepare statement', { error: e
, file: f
});
144 /* istanbul ignore next */
147 // eslint-disable-next-line security/detect-object-injection
148 this.statement
[statementName
] = statement
;
149 const { get: origGet
, all: origAll
, run: origRun
} = statement
;
150 statement
.get = wrapFetch('SQLite:get', statementName
, origGet
.bind(statement
));
151 statement
.all
= wrapFetch('SQLite:all', statementName
, origAll
.bind(statement
));
152 statement
.run
= wrapRun('SQLite:run', statementName
, origRun
.bind(statement
));
154 this.statement
._optimize
= this.db
.prepare('SELECT * FROM pragma_optimize(0x03)');
156 this.logger
.debug(_scope
, 'statements initialized', { statements: Object
.keys(this.statement
).length
});
160 static _deOphidiate(rows
) {
161 const rowsIsArray
= Array
.isArray(rows
);
165 const exemplaryRow
= rows
[0];
166 for (const prop
in exemplaryRow
) {
167 const camel
= Database
._camelfy(prop
);
168 if (!(camel
in exemplaryRow
)) {
169 for (const d
of rows
) {
170 // eslint-disable-next-line security/detect-object-injection
172 // eslint-disable-next-line security/detect-object-injection
177 return rowsIsArray
? rows : rows
[0];
182 return this.db
.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get();
187 const _scope
= _fileScope('healthCheck');
188 this.logger
.debug(_scope
, 'called', {});
190 throw new DBErrors
.UnexpectedResult('database is not open');
192 return { open: this.db
.open
};
196 _engineInfo(result
) {
197 if (result
.changes
) {
198 this.changesSinceLastOptimize
+= BigInt(result
.changes
);
202 changes: Number(result
.changes
),
203 lastInsertRowid: result
.lastInsertRowid
,
214 const _scope
= _fileScope('_optimize');
216 if (this.optimizeAfterChanges
217 && this.changesSinceLastOptimize
>= this.optimizeAfterChanges
) {
218 const optimize
= this.statement
._optimize
.all();
219 this.logger
.debug(_scope
, 'optimize', { optimize
});
220 this.db
.pragma('optimize');
221 this.changesSinceLastOptimize
= BigInt(0);
226 _purgeTables(really
) {
230 'topic_fetch_in_progress',
232 'verification_in_progress',
234 'subscription_delivery_in_progress',
236 const result
= this.db
.prepare(`DELETE FROM ${table}`).run();
237 this.logger
.debug(_fileScope('_purgeTables'), 'success', { table
, result
});
248 transaction(dbCtx
, fn
) {
249 dbCtx
= dbCtx
|| this.db
;
250 return dbCtx
.transaction(fn
)();
254 authenticationSuccess(dbCtx
, identifier
) {
255 const _scope
= _fileScope('authenticationSuccess');
256 this.logger
.debug(_scope
, 'called', { identifier
});
260 result
= this.statement
.authenticationSuccess
.run({ identifier
});
261 if (result
.changes
!= 1) {
262 throw new DBErrors
.UnexpectedResult('did not update authentication success');
265 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
271 authenticationGet(dbCtx
, identifier
) {
272 const _scope
= _fileScope('authenticationGet');
273 this.logger
.debug(_scope
, 'called', { identifier
});
276 return this.statement
.authenticationGet
.get({ identifier
});
278 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
284 authenticationUpsert(dbCtx
, identifier
, credential
) {
285 const _scope
= _fileScope('authenticationUpsert');
286 const scrubbedCredential
= '*'.repeat((credential
|| '').length
);
287 this.logger
.debug(_scope
, 'called', { identifier
, scrubbedCredential
});
291 result
= this.statement
.authenticationUpsert
.run({ identifier
, credential
});
292 if (result
.changes
!= 1) {
293 throw new DBErrors
.UnexpectedResult('did not upsert authentication');
296 this.logger
.error(_scope
, 'failed', { error: e
, identifier
, scrubbedCredential
})
303 * Converts engine subscription fields to native types.
304 * @param {Object} data
306 static _subscriptionDataToNative(data
) {
307 const epochToDate
= (epoch
) => new Date(Number(epoch
) * 1000);
309 ['created', 'verified', 'expires', 'contentDelivered'].forEach((field
) => {
310 // eslint-disable-next-line security/detect-object-injection
311 data
[field
] = epochToDate(data
[field
]);
318 subscriptionsByTopicId(dbCtx
, topicId
) {
319 const _scope
= _fileScope('subscriptionsByTopicId');
320 this.logger
.debug(_scope
, 'called', { topicId
});
323 const subscriptions
= this.statement
.subscriptionsByTopicId
.all({ topicId
});
324 return subscriptions
.map((s
) => DatabaseSQLite
._subscriptionDataToNative(s
));
326 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
332 subscriptionCountByTopicUrl(dbCtx
, topicUrl
) {
333 const _scope
= _fileScope('subscriptionCountByTopicUrl');
334 this.logger
.debug(_scope
, 'called', { topicUrl
});
337 return this.statement
.subscriptionCountByTopicUrl
.get({ topicUrl
});
339 this.logger
.error(_scope
, 'failed', { error: e
, topicUrl
});
345 subscriptionDelete(dbCtx
, callback
, topicId
) {
346 const _scope
= _fileScope('subscriptionDelete');
347 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
350 const result
= this.statement
.subscriptionDelete
.run({ callback
, topicId
});
351 if (result
.changes
!= 1) {
352 throw new DBErrors
.UnexpectedResult('did not delete subscription');
354 return this._engineInfo(result
);
356 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
362 subscriptionDeleteExpired(dbCtx
, topicId
) {
363 const _scope
= _fileScope('subscriptionDeleteExpired');
364 this.logger
.debug(_scope
, 'called', { topicId
});
367 const result
= this.statement
.subscriptionDeleteExpired
.run({ topicId
});
368 this.logger
.debug(_scope
, 'success', { topicId
, deleted: result
.changes
});
369 return this._engineInfo(result
);
371 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
377 subscriptionDeliveryClaim(dbCtx
, wanted
, claimTimeoutSeconds
, claimant
) {
378 const _scope
= _fileScope('subscriptionDeliveryClaim');
379 this.logger
.debug(_scope
, 'called', { wanted
, claimTimeoutSeconds
, claimant
});
383 this.db
.transaction(() => {
384 subscriptionIds
= this.statement
.subscriptionDeliveryNeeded
.all({ wanted
}).map((claim
) => claim
.id
);
385 subscriptionIds
.forEach((subscriptionId
) => {
386 const result
= this.statement
.subscriptionDeliveryClaimById
.run({ subscriptionId
, claimTimeoutSeconds
, claimant
});
387 if (result
.changes
!= 1) {
388 throw new DBErrors
.UnexpectedResult('did not claim subscription delivery');
392 return subscriptionIds
;
394 this.logger
.error(_scope
, 'failed', { error: e
, wanted
, claimTimeoutSeconds
, claimant
, subscriptionIds
});
400 subscriptionDeliveryClaimById(dbCtx
, subscriptionId
, claimTimeoutSeconds
, claimant
) {
401 const _scope
= _fileScope('subscriptionDeliveryClaimById');
402 this.logger
.debug(_scope
, 'called', { subscriptionId
, claimTimeoutSeconds
, claimant
});
405 const result
= this.statement
.subscriptionDeliveryClaimById
.run({ subscriptionId
, claimTimeoutSeconds
, claimant
});
406 if (result
.changes
!= 1) {
407 throw new DBErrors
.UnexpectedResult('did not claim subscription delivery');
409 return this._engineInfo(result
);
411 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionId
, claimTimeoutSeconds
, claimant
});
417 subscriptionDeliveryComplete(dbCtx
, callback
, topicId
) {
418 const _scope
= _fileScope('subscriptionDeliveryComplete');
419 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
423 this.db
.transaction(() => {
424 result
= this.statement
.subscriptionDeliverySuccess
.run({ callback
, topicId
});
425 if (result
.changes
!= 1) {
426 throw new DBErrors
.UnexpectedResult('did not set subscription delivery success');
428 result
= this.statement
.subscriptionDeliveryDone
.run({ callback
, topicId
});
429 if (result
.changes
!= 1) {
430 throw new DBErrors
.UnexpectedResult('did not complete subscription delivery');
433 return this._engineInfo(result
);
435 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
441 subscriptionDeliveryGone(dbCtx
, callback
, topicId
) {
442 const _scope
= _fileScope('subscriptionDeliveryGone');
443 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
447 this.db
.transaction(() => {
448 result
= this.statement
.subscriptionDelete
.run({ callback
, topicId
});
449 if (result
.changes
!= 1) {
450 throw new DBErrors
.UnexpectedResult('did not delete subscription');
452 // Delete cascades to delivery
453 // result = this.statement.subscriptionDeliveryDone.run({ callback, topicId });
454 // if (result.changes != 1) {
455 // throw new DBErrors.UnexpectedResult('did not complete subscription delivery');
458 return this._engineInfo(result
);
460 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
466 subscriptionDeliveryIncomplete(dbCtx
, callback
, topicId
, retryDelays
= [60]) {
467 const _scope
= _fileScope('subscriptionDeliveryIncomplete');
468 this.logger
.debug(_scope
, 'called', { callback
, topicId
, retryDelays
});
472 this.db
.transaction(() => {
473 const { currentAttempt
} = this.statement
.subscriptionDeliveryAttempts
.get({ callback
, topicId
});
474 const nextAttemptDelaySeconds
= common
.attemptRetrySeconds(currentAttempt
, retryDelays
);
475 result
= this.statement
.subscriptionDeliveryFailure
.run({ nextAttemptDelaySeconds
, callback
, topicId
});
476 if (result
.changes
!= 1) {
477 throw new DBErrors
.UnexpectedResult('did not set delivery failure');
479 result
= this.statement
.subscriptionDeliveryDone
.run({ callback
, topicId
});
480 if (result
.changes
!= 1) {
481 throw new DBErrors
.UnexpectedResult('did not complete subscription delivery');
485 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
491 subscriptionGet(dbCtx
, callback
, topicId
) {
492 const _scope
= _fileScope('subscriptionGet');
493 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
497 subscription
= this.statement
.subscriptionGet
.get({ callback
, topicId
});
498 return DatabaseSQLite
._subscriptionDataToNative(subscription
);
500 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
506 subscriptionGetById(dbCtx
, subscriptionId
) {
507 const _scope
= _fileScope('subscriptionGetById');
508 this.logger
.debug(_scope
, 'called', { subscriptionId
});
512 subscription
= this.statement
.subscriptionGetById
.get({ subscriptionId
});
513 return DatabaseSQLite
._subscriptionDataToNative(subscription
);
515 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionId
});
521 subscriptionUpdate(dbCtx
, data
) {
522 const _scope
= _fileScope('subscriptionUpdate');
523 this.logger
.debug(_scope
, 'called', { data
});
525 const subscriptionData
= {
529 this._subscriptionUpdateDataValidate(subscriptionData
);
532 const result
= this.statement
.subscriptionUpdate
.run(subscriptionData
);
533 if (result
.changes
!= 1) {
534 throw new DBErrors
.UnexpectedResult('did not update subscription');
537 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionData
});
543 subscriptionUpsert(dbCtx
, data
) {
544 const _scope
= _fileScope('subscriptionUpsert');
545 this.logger
.debug(_scope
, 'called', { ...data
});
547 const subscriptionData
= {
549 httpRemoteAddr: null,
553 this._subscriptionUpsertDataValidate(subscriptionData
);
557 result
= this.statement
.subscriptionUpsert
.run(subscriptionData
);
558 if (result
.changes
!= 1) {
559 throw new DBErrors
.UnexpectedResult('did not upsert subscription');
561 return this._engineInfo(result
);
563 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionData
});
569 topicDeleted(dbCtx
, topicId
) {
570 const _scope
= _fileScope('topicDeleted');
571 this.logger
.debug(_scope
, 'called', { topicId
});
575 result
= this.statement
.topicDeleted
.run({ topicId
});
576 if (result
.changes
!= 1) {
577 throw new DBErrors
.UnexpectedResult('did not update topic as deleted');
580 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
586 topicFetchClaim(dbCtx
, wanted
, claimTimeoutSeconds
, claimant
) {
587 const _scope
= _fileScope('topicFetchClaim');
588 this.logger
.debug(_scope
, 'called', { wanted
, claimTimeoutSeconds
});
592 this.db
.transaction(() => {
593 topicIds
= this.statement
.topicContentFetchNeeded
.all({ wanted
}).map((claim
) => claim
.id
);
594 topicIds
.forEach((topicId
) => {
595 const result
= this.statement
.topicContentFetchClaimById
.run({ topicId
, claimTimeoutSeconds
, claimant
});
596 if (result
.changes
!= 1) {
597 throw new DBErrors
.UnexpectedResult('did not claim topic fetch');
603 this.logger
.error(_scope
, 'failed to claim topics for fetch', { error: e
, wanted
, claimTimeoutSeconds
, claimant
, topicIds
});
609 topicFetchClaimById(dbCtx
, topicId
, claimTimeoutSeconds
, claimant
) {
610 const _scope
= _fileScope('topicFetchClaimById');
611 this.logger
.debug(_scope
, 'called', { topicId
, claimTimeoutSeconds
, claimant
});
615 result
= this.statement
.topicContentFetchClaimById
.run({ topicId
, claimTimeoutSeconds
, claimant
});
616 if (result
.changes
!= 1) {
617 throw new DBErrors
.UnexpectedResult('did not claim topic fetch');
619 return this._engineInfo(result
);
621 this.logger
.error(_scope
, 'failed to claim topics for fetch', { error: e
, topicId
, claimTimeoutSeconds
, claimant
});
627 topicFetchComplete(dbCtx
, topicId
) {
628 const _scope
= _fileScope('topicFetchComplete');
629 this.logger
.debug(_scope
, 'called', { topicId
});
633 this.db
.transaction(() => {
634 result
= this.statement
.topicAttemptsReset
.run({ topicId
, forever: EPOCH_FOREVER
});
635 if (result
.changes
!= 1) {
636 throw new DBErrors
.UnexpectedResult('did not reset topic attempts');
638 result
= this.statement
.topicContentFetchDone
.run({ topicId
});
639 if (result
.changes
!= 1) {
640 throw new DBErrors
.UnexpectedResult('did not release topic fetch');
643 return this._engineInfo(result
);
645 this.logger
.error(_scope
, 'failed', { error: e
, result
, topicId
});
651 topicFetchIncomplete(dbCtx
, topicId
, retryDelays
= [60]) {
652 const _scope
= _fileScope('topicFetchIncomplete');
653 this.logger
.debug(_scope
, 'called', { topicId
});
657 this.db
.transaction(() => {
658 const { contentFetchAttemptsSinceSuccess: currentAttempt
} = this.statement
.topicAttempts
.get({ topicId
});
659 const nextAttemptDelaySeconds
= common
.attemptRetrySeconds(currentAttempt
, retryDelays
);
660 result
= this.statement
.topicAttemptsIncrement
.run({ topicId
, nextAttemptDelaySeconds
});
661 if (result
.changes
!= 1) {
662 throw new DBErrors
.UnexpectedResult('did not set topic attempts');
664 result
= this.statement
.topicContentFetchDone
.run({ topicId
});
665 if (result
.changes
!= 1) {
666 throw new DBErrors
.UnexpectedResult('did not release topic fetch');
670 return this._engineInfo(result
);
672 this.logger
.error(_scope
, 'failed', { error: e
, result
, topicId
});
678 topicFetchRequested(dbCtx
, topicId
) {
679 const _scope
= _fileScope('topicFetchRequested');
680 this.logger
.debug(_scope
, 'called', { topicId
});
684 result
= this.statement
.topicContentFetchRequested
.run({ topicId
});
685 if (result
.changes
!= 1) {
686 throw new DBErrors
.UnexpectedResult('did not set topic fetch requested');
688 return this._engineInfo(result
);
690 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
697 * Converts engine topic fields to native types.
698 * @param {Object} data
700 static _topicDataToNative(data
) {
701 const epochToDate
= (epoch
) => new Date(Number(epoch
) * 1000);
703 data
.isActive
= !!data
.isActive
;
704 data
.isDeleted
= !!data
.isDeleted
;
705 ['created', 'lastPublish', 'contentFetchNextAttempt', 'contentUpdated'].forEach((field
) => {
706 // eslint-disable-next-line security/detect-object-injection
707 data
[field
] = epochToDate(data
[field
]);
714 // eslint-disable-next-line no-unused-vars
716 const _scope
= _fileScope('topicGetAll');
717 this.logger
.debug(_scope
, 'called');
721 topics
= this.statement
.topicGetInfoAll
.all();
723 this.logger
.error(_scope
, 'failed', { error: e
, topics
});
728 .map(DatabaseSQLite
._topicDataToNative
)
729 .map(this._topicDefaults
.bind(this));
735 topicGetById(dbCtx
, topicId
, applyDefaults
= true) {
736 const _scope
= _fileScope('topicGetById');
737 this.logger
.debug(_scope
, 'called', { topicId
});
741 topic
= this.statement
.topicGetById
.get({ topicId
});
742 DatabaseSQLite
._topicDataToNative(topic
);
744 topic
= this._topicDefaults(topic
);
748 this.logger
.error(_scope
, 'failed', { error: e
, topic
, topicId
});
754 topicGetByUrl(dbCtx
, topicUrl
) {
755 const _scope
= _fileScope('topicGetByUrl');
756 this.logger
.debug(_scope
, 'called', { topicUrl
});
760 topic
= this.statement
.topicGetByUrl
.get({ topicUrl
});
761 DatabaseSQLite
._topicDataToNative(topic
);
762 return this._topicDefaults(topic
);
764 this.logger
.error(_scope
, 'failed', { error: e
, topic
, topicUrl
});
770 topicGetContentById(dbCtx
, topicId
) {
771 const _scope
= _fileScope('topicGetContentById');
772 this.logger
.debug(_scope
, 'called', { topicId
});
776 topic
= this.statement
.topicGetContentById
.get({ topicId
});
777 DatabaseSQLite
._topicDataToNative(topic
);
778 return this._topicDefaults(topic
);
780 this.logger
.error(_scope
, 'failed', { error: e
, topic
, topicId
});
786 topicPendingDelete(dbCtx
, topicId
) {
787 const _scope
= _fileScope('topicPendingDelete');
788 this.logger
.debug(_scope
, 'called', { topicId
});
791 this.db
.transaction(() => {
792 const topic
= this.statement
.topicGetById
.get({ topicId
});
793 if (!topic
.isDeleted
) {
794 this.logger
.debug(_scope
, 'topic not set deleted, not deleting', { topicId
});
798 const { count: subscriberCount
} = this.statement
.subscriptionCountByTopicUrl
.get({ topicUrl: topic
.url
});
799 if (subscriberCount
) {
800 this.logger
.debug(_scope
, 'topic has subscribers, not deleting', { topicId
, subscriberCount
});
804 const result
= this.statement
.topicDeleteById
.run({ topicId
});
805 if (result
.changes
!== 1) {
806 throw new DBErrors
.UnexpectedResult('did not delete topic');
809 this.logger
.debug(_scope
, 'success', { topicId
});
811 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
817 topicSet(dbCtx
, data
) {
818 const _scope
= _fileScope('topicSet');
819 this.logger
.debug(_scope
, 'called', data
);
821 const topicSetData
= {
822 publisherValidationUrl: null,
823 leaseSecondsPreferred: null,
824 leaseSecondsMin: null,
825 leaseSecondsMax: null,
831 this._topicSetDataValidate(topicSetData
);
832 result
= this.statement
.topicUpsert
.run(topicSetData
);
833 if (result
.changes
!= 1) {
834 throw new DBErrors
.UnexpectedResult('did not set topic data');
836 return this._engineInfo(result
);
838 this.logger
.error(_scope
, 'failed', { error: e
, result
});
844 topicSetContent(dbCtx
, data
) {
845 const _scope
= _fileScope('topicSetContent');
846 const topicSetContentData
= {
851 ...topicSetContentData
,
852 content: common
.logTruncate(topicSetContentData
.content
, 100),
854 this.logger
.debug(_scope
, 'called', logData
);
858 this._topicSetContentDataValidate(topicSetContentData
);
859 result
= this.statement
.topicSetContent
.run(topicSetContentData
);
860 logData
.result
= result
;
861 if (result
.changes
!= 1) {
862 throw new DBErrors
.UnexpectedResult('did not set topic content');
864 return this._engineInfo(result
);
866 this.logger
.error(_scope
, 'failed', { error: e
, ...logData
});
872 topicUpdate(dbCtx
, data
) {
873 const _scope
= _fileScope('topicUpdate');
874 this.logger
.debug(_scope
, 'called', { data
});
877 leaseSecondsPreferred: null,
878 leaseSecondsMin: null,
879 leaseSecondsMax: null,
880 publisherValidationUrl: null,
884 this._topicUpdateDataValidate(topicData
);
887 const result
= this.statement
.topicUpdate
.run(topicData
);
888 if (result
.changes
!= 1) {
889 throw new DBErrors
.UnexpectedResult('did not update topic');
892 this.logger
.error(_scope
, 'failed', { error: e
, topicData
});
898 verificationClaim(dbCtx
, wanted
, claimTimeoutSeconds
, claimant
) {
899 const _scope
= _fileScope('verificationClaim');
900 this.logger
.debug(_scope
, 'called', { wanted
, claimTimeoutSeconds
});
904 this.db
.transaction(() => {
905 verificationIds
= this.statement
.verificationNeeded
.all({ wanted
}).map((claim
) => claim
.id
);
906 verificationIds
.forEach((verificationId
) => {
907 const result
= this.statement
.verificationClaimById
.run({ verificationId
, claimTimeoutSeconds
, claimant
});
908 if (result
.changes
!= 1) {
909 throw new DBErrors
.UnexpectedResult('did not claim verification');
913 return verificationIds
;
915 this.logger
.error(_scope
, 'failed to claim verifications', { wanted
, claimTimeoutSeconds
});
921 verificationClaimById(dbCtx
, verificationId
, claimTimeoutSeconds
, claimant
) {
922 const _scope
= _fileScope('verificationClaimById');
923 this.logger
.debug(_scope
, 'called', { verificationId
, claimTimeoutSeconds
, claimant
});
927 result
= this.statement
.verificationClaimById
.run({ verificationId
, claimTimeoutSeconds
, claimant
});
928 if (result
.changes
!= 1) {
929 throw new DBErrors
.UnexpectedResult('did not claim verification');
931 return this._engineInfo(result
);
933 this.logger
.error(_scope
, 'failed to claim verification', { error: e
, verificationId
, claimTimeoutSeconds
, claimant
});
939 verificationComplete(dbCtx
, verificationId
, callback
, topicId
) {
940 const _scope
= _fileScope('verificationComplete');
941 this.logger
.debug(_scope
, 'called', { verificationId
});
945 this.db
.transaction(() => {
946 result
= this.statement
.verificationScrub
.run({ verificationId
, callback
, topicId
});
947 if (result
.changes
< 1) {
948 throw new DBErrors
.UnexpectedResult('did not remove verifications');
952 this.logger
.error(_scope
, 'failed', { verificationId
});
955 return this._engineInfo(result
);
960 * Converts engine verification fields to native types.
961 * @param {Object} data
963 static _verificationDataToNative(data
) {
965 data
.isPublisherValidated
= !!data
.isPublisherValidated
;
970 verificationGetById(dbCtx
, verificationId
) {
971 const _scope
= _fileScope('verificationGetById');
972 this.logger
.debug(_scope
, 'called', { verificationId
});
976 verification
= this.statement
.verificationGetById
.get({ verificationId
});
977 DatabaseSQLite
._verificationDataToNative(verification
);
980 this.logger
.error(_scope
, 'failed', { error: e
, verificationId
});
986 verificationIncomplete(dbCtx
, verificationId
, retryDelays
= [60]) {
987 const _scope
= _fileScope('verificationIncomplete');
988 this.logger
.debug(_scope
, 'called', { verificationId
});
992 this.db
.transaction(() => {
993 const { attempts: currentAttempt
} = this.statement
.verificationAttempts
.get({ verificationId
});
994 const nextAttemptDelaySeconds
= common
.attemptRetrySeconds(currentAttempt
, retryDelays
);
995 result
= this.statement
.verificationAttemptsIncrement
.run({ verificationId
, nextAttemptDelaySeconds
});
996 if (result
.changes
!= 1) {
997 throw new DBErrors
.UnexpectedResult('did not increment verification attempts');
999 result
= this.statement
.verificationDone
.run({ verificationId
});
1000 if (result
.changes
!= 1) {
1001 throw new DBErrors
.UnexpectedResult('did not release verification in progress');
1005 return this._engineInfo(result
);
1007 this.logger
.error(_scope
, 'failed', { error: e
, result
, verificationId
});
1014 * Convert native verification fields to engine types.
1016 static _verificationDataToEngine(data
) {
1018 data
.isPublisherValidated
= data
.isPublisherValidated
? 1 : 0;
1023 verificationInsert(dbCtx
, verification
) {
1024 const _scope
= _fileScope('verificationInsert');
1025 this.logger
.debug(_scope
, 'called', { verification
});
1027 const verificationData
= {
1029 httpRemoteAddr: null,
1035 let result
, verificationId
;
1037 this._verificationDataValidate(verificationData
);
1038 DatabaseSQLite
._verificationDataToEngine(verificationData
);
1039 result
= this.statement
.verificationInsert
.run(verificationData
);
1040 if (result
.changes
!= 1) {
1041 throw new DBErrors
.UnexpectedResult('did not insert verification');
1043 verificationId
= result
.lastInsertRowid
;
1044 this.logger
.debug(_scope
, 'inserted verification', { verificationId
});
1046 return verificationId
;
1048 this.logger
.error(_scope
, 'failed', { error: e
, verificationData
});
1054 verificationRelease(dbCtx
, verificationId
) {
1055 const _scope
= _fileScope('verificationRelease');
1056 this.logger
.debug(_scope
, 'called', { verificationId
});
1060 result
= this.statement
.verificationDone
.run({ verificationId
});
1061 if (result
.changes
!= 1) {
1062 throw new DBErrors
.UnexpectedResult('did not release verification');
1065 this.logger
.error(_scope
, 'failed', { error: e
, verificationId
});
1071 verificationUpdate(dbCtx
, verificationId
, data
) {
1072 const _scope
= _fileScope('verificationUpdate');
1073 this.logger
.debug(_scope
, 'called', { verificationId
, data
});
1075 const verificationData
= {
1083 this._verificationUpdateDataValidate(verificationData
);
1084 DatabaseSQLite
._verificationDataToEngine(verificationData
);
1085 result
= this.statement
.verificationUpdate
.run(verificationData
);
1086 if (result
.changes
!= 1) {
1087 throw new DBErrors
.UnexpectedResult('did not update verification');
1090 this.logger
.error(_scope
, 'failed', { error: e
, verificationData
});
1096 verificationValidated(dbCtx
, verificationId
) {
1097 const _scope
= _fileScope('verificationValidated');
1098 this.logger
.debug(_scope
, 'called', { verificationId
});
1102 result
= this.statement
.verificationValidate
.run({ verificationId
});
1103 if (result
.changes
!= 1) {
1104 throw new DBErrors
.UnexpectedResult('did not set verification validation');
1107 this.logger
.error(_scope
, 'failed', { error: e
, verificationId
});
1115 module
.exports
= DatabaseSQLite
;