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');
29 const epochToDate
= (epoch
) => new Date(Number(epoch
) * 1000);
30 const dateToEpoch
= (date
) => Math
.round(date
.getTime() / 1000);
32 class DatabaseSQLite
extends Database
{
33 constructor(logger
, options
) {
34 super(logger
, options
);
36 const connectionString
= options
.db
.connectionString
|| 'sqlite://:memory:';
37 const csDelim
= '://';
38 const dbFilename
= connectionString
.slice(connectionString
.indexOf(csDelim
) + csDelim
.length
);
40 const queryLogLevel
= options
.db
.queryLogLevel
;
42 const sqliteOptions
= {
43 ...(queryLogLevel
&& {
44 // eslint-disable-next-line security/detect-object-injection
45 verbose: (query
) => this.logger
[queryLogLevel
](_fileScope('SQLite:verbose'), '', { query
}),
48 this.db
= new SQLite(dbFilename
, sqliteOptions
);
49 this.schemaVersionsSupported
= schemaVersionsSupported
;
50 this.changesSinceLastOptimize
= BigInt(0);
51 this.optimizeAfterChanges
= options
.db
.connectionString
.optimizeAfterChanges
;
52 this.db
.pragma('foreign_keys = on'); // Enforce consistency.
53 this.db
.pragma('journal_mode = WAL'); // Be faster, expect local filesystem.
54 this.db
.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs.
57 this._initStatements();
62 * SQLite cannot prepare its statements without a schema, ensure such exists.
65 const _scope
= _fileScope('_initTables');
67 // Migrations rely upon this table, ensure it exists.
68 const metaVersionTable
= '_meta_schema_version';
69 const tableExists
= this.db
.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:name').pluck(true).bind({ type: 'table', name: metaVersionTable
});
70 let metaExists
= tableExists
.get();
71 if (metaExists
=== undefined) {
72 const fPath
= path
.join(__dirname
, 'sql', 'schema', 'init.sql');
74 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
76 metaExists
= tableExists
.get();
77 /* istanbul ignore if */
78 if (metaExists
=== undefined) {
79 throw new DBErrors
.UnexpectedResult(`did not create ${metaVersionTable} table`);
81 this.logger
.info(_scope
, 'created schema version table', { metaVersionTable
});
85 const currentSchema
= this._currentSchema();
86 const migrationsWanted
= svh
.unappliedSchemaVersions(__dirname
, currentSchema
, this.schemaVersionsSupported
);
87 this.logger
.debug(_scope
, 'schema migrations wanted', { migrationsWanted
});
88 migrationsWanted
.forEach((v
) => {
89 const fPath
= path
.join(__dirname
, 'sql', 'schema', v
, 'apply.sql');
91 // eslint-disable-next-line security/detect-non-literal-fs-filename
92 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
93 this.logger
.debug(_scope
, 'applying migration', { version: v
});
94 const results
= this.db
.exec(fSql
);
95 this.logger
.debug(_scope
, 'migration results', { results
});
96 this.logger
.info(_scope
, 'applied migration', { version: v
});
98 this.logger
.error(_scope
, 'migration failed', { error: e
, fPath
, version: v
});
106 const _scope
= _fileScope('_initStatements');
107 const sqlDir
= path
.join(__dirname
, 'sql');
110 // Decorate the statement calls we use with timing and logging.
111 const wrapFetch
= (logName
, statementName
, fn
) => {
112 const _wrapScope
= _fileScope(logName
);
113 return (...args
) => {
114 const startTimestampMs
= performance
.now();
115 const rows
= fn(...args
);
116 DatabaseSQLite
._deOphidiate(rows
);
117 const elapsedTimeMs
= performance
.now() - startTimestampMs
;
118 this.logger
.debug(_wrapScope
, 'complete', { statementName
, elapsedTimeMs
});
122 const wrapRun
= (logName
, statementName
, fn
) => {
123 const _wrapScope
= _fileScope(logName
);
124 return (...args
) => {
125 const startTimestampMs
= performance
.now();
126 const result
= fn(...args
);
127 const elapsedTimeMs
= performance
.now() - startTimestampMs
;
128 this.logger
.debug(_wrapScope
, 'complete', { ...result
, statementName
, elapsedTimeMs
});
129 result
.duration
= elapsedTimeMs
;
135 for (const f
of fs
.readdirSync(sqlDir
)) {
136 const fPath
= path
.join(sqlDir
, f
);
137 const { name: fName
, ext: fExt
} = path
.parse(f
);
138 // eslint-disable-next-line security/detect-non-literal-fs-filename
139 const stat
= fs
.statSync(fPath
);
141 || fExt
.toLowerCase() !== '.sql') {
144 // eslint-disable-next-line security/detect-non-literal-fs-filename
145 const fSql
= fs
.readFileSync(fPath
, { encoding: 'utf8' });
146 const statementName
= Database
._camelfy(fName
.toLowerCase(), '-');
149 statement
= this.db
.prepare(fSql
);
151 /* istanbul ignore next */
152 this.logger
.error(_scope
, 'failed to prepare statement', { error: e
, file: f
});
153 /* istanbul ignore next */
156 // eslint-disable-next-line security/detect-object-injection
157 this.statement
[statementName
] = statement
;
158 const { get: origGet
, all: origAll
, run: origRun
} = statement
;
159 statement
.get = wrapFetch('SQLite:get', statementName
, origGet
.bind(statement
));
160 statement
.all
= wrapFetch('SQLite:all', statementName
, origAll
.bind(statement
));
161 statement
.run
= wrapRun('SQLite:run', statementName
, origRun
.bind(statement
));
163 this.statement
._optimize
= this.db
.prepare('SELECT * FROM pragma_optimize(0x03)');
165 this.logger
.debug(_scope
, 'statements initialized', { statements: Object
.keys(this.statement
).length
});
169 static _deOphidiate(rows
) {
170 const rowsIsArray
= Array
.isArray(rows
);
174 const exemplaryRow
= rows
[0];
175 for (const prop
in exemplaryRow
) {
176 const camel
= Database
._camelfy(prop
);
177 if (!(camel
in exemplaryRow
)) {
178 for (const d
of rows
) {
179 // eslint-disable-next-line security/detect-object-injection
181 // eslint-disable-next-line security/detect-object-injection
186 return rowsIsArray
? rows : rows
[0];
191 return this.db
.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get();
196 const _scope
= _fileScope('healthCheck');
197 this.logger
.debug(_scope
, 'called', {});
199 throw new DBErrors
.UnexpectedResult('database is not open');
201 return { open: this.db
.open
};
205 _engineInfo(result
) {
206 if (result
.changes
) {
207 this.changesSinceLastOptimize
+= BigInt(result
.changes
);
211 changes: Number(result
.changes
),
212 lastInsertRowid: result
.lastInsertRowid
,
223 const _scope
= _fileScope('_optimize');
225 if (this.optimizeAfterChanges
226 && this.changesSinceLastOptimize
>= this.optimizeAfterChanges
) {
227 const optimize
= this.statement
._optimize
.all();
228 this.logger
.debug(_scope
, 'optimize', { optimize
});
229 this.db
.pragma('optimize');
230 this.changesSinceLastOptimize
= BigInt(0);
235 _purgeTables(really
) {
239 'topic_fetch_in_progress',
241 'verification_in_progress',
243 'subscription_delivery_in_progress',
244 ].forEach((table
) => {
245 const result
= this.db
.prepare(`DELETE FROM ${table}`).run();
246 this.logger
.debug(_fileScope('_purgeTables'), 'success', { table
, result
});
257 transaction(dbCtx
, fn
) {
258 dbCtx
= dbCtx
|| this.db
;
259 return dbCtx
.transaction(fn
)();
263 authenticationSuccess(dbCtx
, identifier
) {
264 const _scope
= _fileScope('authenticationSuccess');
265 this.logger
.debug(_scope
, 'called', { identifier
});
269 result
= this.statement
.authenticationSuccess
.run({ identifier
});
270 if (result
.changes
!= 1) {
271 throw new DBErrors
.UnexpectedResult('did not update authentication success');
274 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
280 authenticationGet(dbCtx
, identifier
) {
281 const _scope
= _fileScope('authenticationGet');
282 this.logger
.debug(_scope
, 'called', { identifier
});
285 return this.statement
.authenticationGet
.get({ identifier
});
287 this.logger
.error(_scope
, 'failed', { error: e
, identifier
});
293 authenticationUpsert(dbCtx
, identifier
, credential
) {
294 const _scope
= _fileScope('authenticationUpsert');
295 const scrubbedCredential
= '*'.repeat((credential
|| '').length
);
296 this.logger
.debug(_scope
, 'called', { identifier
, scrubbedCredential
});
300 result
= this.statement
.authenticationUpsert
.run({ identifier
, credential
});
301 if (result
.changes
!= 1) {
302 throw new DBErrors
.UnexpectedResult('did not upsert authentication');
305 this.logger
.error(_scope
, 'failed', { error: e
, identifier
, scrubbedCredential
});
312 * Converts engine subscription fields to native types.
313 * @param {object} data subscription data
314 * @returns {object} data
316 static _subscriptionDataToNative(data
) {
318 ['created', 'verified', 'expires', 'contentDelivered'].forEach((field
) => {
319 // eslint-disable-next-line security/detect-object-injection
320 data
[field
] = epochToDate(data
[field
]);
327 subscriptionsByTopicId(dbCtx
, topicId
) {
328 const _scope
= _fileScope('subscriptionsByTopicId');
329 this.logger
.debug(_scope
, 'called', { topicId
});
332 const subscriptions
= this.statement
.subscriptionsByTopicId
.all({ topicId
});
333 return subscriptions
.map((s
) => DatabaseSQLite
._subscriptionDataToNative(s
));
335 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
341 subscriptionCountByTopicUrl(dbCtx
, topicUrl
) {
342 const _scope
= _fileScope('subscriptionCountByTopicUrl');
343 this.logger
.debug(_scope
, 'called', { topicUrl
});
346 return this.statement
.subscriptionCountByTopicUrl
.get({ topicUrl
});
348 this.logger
.error(_scope
, 'failed', { error: e
, topicUrl
});
354 subscriptionDelete(dbCtx
, callback
, topicId
) {
355 const _scope
= _fileScope('subscriptionDelete');
356 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
359 const result
= this.statement
.subscriptionDelete
.run({ callback
, topicId
});
360 if (result
.changes
!= 1) {
361 throw new DBErrors
.UnexpectedResult('did not delete subscription');
363 return this._engineInfo(result
);
365 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
371 subscriptionDeleteExpired(dbCtx
, topicId
) {
372 const _scope
= _fileScope('subscriptionDeleteExpired');
373 this.logger
.debug(_scope
, 'called', { topicId
});
376 const result
= this.statement
.subscriptionDeleteExpired
.run({ topicId
});
377 this.logger
.debug(_scope
, 'success', { topicId
, deleted: result
.changes
});
378 return this._engineInfo(result
);
380 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
386 subscriptionDeliveryClaim(dbCtx
, wanted
, claimTimeoutSeconds
, claimant
) {
387 const _scope
= _fileScope('subscriptionDeliveryClaim');
388 this.logger
.debug(_scope
, 'called', { wanted
, claimTimeoutSeconds
, claimant
});
392 this.db
.transaction(() => {
393 subscriptionIds
= this.statement
.subscriptionDeliveryNeeded
.all({ wanted
}).map((claim
) => claim
.id
);
394 subscriptionIds
.forEach((subscriptionId
) => {
395 const result
= this.statement
.subscriptionDeliveryClaimById
.run({ subscriptionId
, claimTimeoutSeconds
, claimant
});
396 if (result
.changes
!= 1) {
397 throw new DBErrors
.UnexpectedResult('did not claim subscription delivery');
401 return subscriptionIds
;
403 this.logger
.error(_scope
, 'failed', { error: e
, wanted
, claimTimeoutSeconds
, claimant
, subscriptionIds
});
409 subscriptionDeliveryClaimById(dbCtx
, subscriptionId
, claimTimeoutSeconds
, claimant
) {
410 const _scope
= _fileScope('subscriptionDeliveryClaimById');
411 this.logger
.debug(_scope
, 'called', { subscriptionId
, claimTimeoutSeconds
, claimant
});
414 const result
= this.statement
.subscriptionDeliveryClaimById
.run({ subscriptionId
, claimTimeoutSeconds
, claimant
});
415 if (result
.changes
!= 1) {
416 throw new DBErrors
.UnexpectedResult('did not claim subscription delivery');
418 return this._engineInfo(result
);
420 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionId
, claimTimeoutSeconds
, claimant
});
426 subscriptionDeliveryComplete(dbCtx
, callback
, topicId
, topicContentUpdated
) {
427 const _scope
= _fileScope('subscriptionDeliveryComplete');
428 this.logger
.debug(_scope
, 'called', { callback
, topicId
, topicContentUpdated
});
432 this.db
.transaction(() => {
433 topicContentUpdated
= dateToEpoch(topicContentUpdated
);
434 result
= this.statement
.subscriptionDeliverySuccess
.run({ callback
, topicId
, topicContentUpdated
});
435 if (result
.changes
!= 1) {
436 throw new DBErrors
.UnexpectedResult('did not set subscription delivery success');
438 result
= this.statement
.subscriptionDeliveryDone
.run({ callback
, topicId
});
439 if (result
.changes
!= 1) {
440 throw new DBErrors
.UnexpectedResult('did not complete subscription delivery');
443 return this._engineInfo(result
);
445 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
, topicContentUpdated
});
451 subscriptionDeliveryGone(dbCtx
, callback
, topicId
) {
452 const _scope
= _fileScope('subscriptionDeliveryGone');
453 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
457 this.db
.transaction(() => {
458 result
= this.statement
.subscriptionDelete
.run({ callback
, topicId
});
459 if (result
.changes
!= 1) {
460 throw new DBErrors
.UnexpectedResult('did not delete subscription');
462 // Delete cascades to delivery
463 // result = this.statement.subscriptionDeliveryDone.run({ callback, topicId });
464 // if (result.changes != 1) {
465 // throw new DBErrors.UnexpectedResult('did not complete subscription delivery');
468 return this._engineInfo(result
);
470 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
476 subscriptionDeliveryIncomplete(dbCtx
, callback
, topicId
, retryDelays
= [60]) {
477 const _scope
= _fileScope('subscriptionDeliveryIncomplete');
478 this.logger
.debug(_scope
, 'called', { callback
, topicId
, retryDelays
});
482 this.db
.transaction(() => {
483 const { currentAttempt
} = this.statement
.subscriptionDeliveryAttempts
.get({ callback
, topicId
});
484 const nextAttemptDelaySeconds
= common
.attemptRetrySeconds(currentAttempt
, retryDelays
);
485 result
= this.statement
.subscriptionDeliveryFailure
.run({ nextAttemptDelaySeconds
, callback
, topicId
});
486 if (result
.changes
!= 1) {
487 throw new DBErrors
.UnexpectedResult('did not set delivery failure');
489 result
= this.statement
.subscriptionDeliveryDone
.run({ callback
, topicId
});
490 if (result
.changes
!= 1) {
491 throw new DBErrors
.UnexpectedResult('did not complete subscription delivery');
495 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
501 subscriptionGet(dbCtx
, callback
, topicId
) {
502 const _scope
= _fileScope('subscriptionGet');
503 this.logger
.debug(_scope
, 'called', { callback
, topicId
});
507 subscription
= this.statement
.subscriptionGet
.get({ callback
, topicId
});
508 return DatabaseSQLite
._subscriptionDataToNative(subscription
);
510 this.logger
.error(_scope
, 'failed', { error: e
, callback
, topicId
});
516 subscriptionGetById(dbCtx
, subscriptionId
) {
517 const _scope
= _fileScope('subscriptionGetById');
518 this.logger
.debug(_scope
, 'called', { subscriptionId
});
522 subscription
= this.statement
.subscriptionGetById
.get({ subscriptionId
});
523 return DatabaseSQLite
._subscriptionDataToNative(subscription
);
525 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionId
});
531 subscriptionUpdate(dbCtx
, data
) {
532 const _scope
= _fileScope('subscriptionUpdate');
533 this.logger
.debug(_scope
, 'called', { data
});
535 const subscriptionData
= {
539 this._subscriptionUpdateDataValidate(subscriptionData
);
542 const result
= this.statement
.subscriptionUpdate
.run(subscriptionData
);
543 if (result
.changes
!= 1) {
544 throw new DBErrors
.UnexpectedResult('did not update subscription');
547 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionData
});
553 subscriptionUpsert(dbCtx
, data
) {
554 const _scope
= _fileScope('subscriptionUpsert');
555 this.logger
.debug(_scope
, 'called', { ...data
});
557 const subscriptionData
= {
559 httpRemoteAddr: null,
563 this._subscriptionUpsertDataValidate(subscriptionData
);
567 result
= this.statement
.subscriptionUpsert
.run(subscriptionData
);
568 if (result
.changes
!= 1) {
569 throw new DBErrors
.UnexpectedResult('did not upsert subscription');
571 return this._engineInfo(result
);
573 this.logger
.error(_scope
, 'failed', { error: e
, subscriptionData
});
579 topicDeleted(dbCtx
, topicId
) {
580 const _scope
= _fileScope('topicDeleted');
581 this.logger
.debug(_scope
, 'called', { topicId
});
585 result
= this.statement
.topicDeleted
.run({ topicId
});
586 if (result
.changes
!= 1) {
587 throw new DBErrors
.UnexpectedResult('did not update topic as deleted');
590 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
596 topicFetchClaim(dbCtx
, wanted
, claimTimeoutSeconds
, claimant
) {
597 const _scope
= _fileScope('topicFetchClaim');
598 this.logger
.debug(_scope
, 'called', { wanted
, claimTimeoutSeconds
});
602 this.db
.transaction(() => {
603 topicIds
= this.statement
.topicContentFetchNeeded
.all({ wanted
}).map((claim
) => claim
.id
);
604 topicIds
.forEach((topicId
) => {
605 const result
= this.statement
.topicContentFetchClaimById
.run({ topicId
, claimTimeoutSeconds
, claimant
});
606 if (result
.changes
!= 1) {
607 throw new DBErrors
.UnexpectedResult('did not claim topic fetch');
613 this.logger
.error(_scope
, 'failed to claim topics for fetch', { error: e
, wanted
, claimTimeoutSeconds
, claimant
, topicIds
});
619 topicFetchClaimById(dbCtx
, topicId
, claimTimeoutSeconds
, claimant
) {
620 const _scope
= _fileScope('topicFetchClaimById');
621 this.logger
.debug(_scope
, 'called', { topicId
, claimTimeoutSeconds
, claimant
});
625 result
= this.statement
.topicContentFetchClaimById
.run({ topicId
, claimTimeoutSeconds
, claimant
});
626 if (result
.changes
!= 1) {
627 throw new DBErrors
.UnexpectedResult('did not claim topic fetch');
629 return this._engineInfo(result
);
631 this.logger
.error(_scope
, 'failed to claim topics for fetch', { error: e
, topicId
, claimTimeoutSeconds
, claimant
});
637 topicFetchComplete(dbCtx
, topicId
) {
638 const _scope
= _fileScope('topicFetchComplete');
639 this.logger
.debug(_scope
, 'called', { topicId
});
643 this.db
.transaction(() => {
644 result
= this.statement
.topicAttemptsReset
.run({ topicId
, forever: EPOCH_FOREVER
});
645 if (result
.changes
!= 1) {
646 throw new DBErrors
.UnexpectedResult('did not reset topic attempts');
648 result
= this.statement
.topicContentFetchDone
.run({ topicId
});
649 if (result
.changes
!= 1) {
650 throw new DBErrors
.UnexpectedResult('did not release topic fetch');
653 return this._engineInfo(result
);
655 this.logger
.error(_scope
, 'failed', { error: e
, result
, topicId
});
661 topicFetchIncomplete(dbCtx
, topicId
, retryDelays
= [60]) {
662 const _scope
= _fileScope('topicFetchIncomplete');
663 this.logger
.debug(_scope
, 'called', { topicId
});
667 this.db
.transaction(() => {
668 const { contentFetchAttemptsSinceSuccess: currentAttempt
} = this.statement
.topicAttempts
.get({ topicId
});
669 const nextAttemptDelaySeconds
= common
.attemptRetrySeconds(currentAttempt
, retryDelays
);
670 result
= this.statement
.topicAttemptsIncrement
.run({ topicId
, nextAttemptDelaySeconds
});
671 if (result
.changes
!= 1) {
672 throw new DBErrors
.UnexpectedResult('did not set topic attempts');
674 result
= this.statement
.topicContentFetchDone
.run({ topicId
});
675 if (result
.changes
!= 1) {
676 throw new DBErrors
.UnexpectedResult('did not release topic fetch');
680 return this._engineInfo(result
);
682 this.logger
.error(_scope
, 'failed', { error: e
, result
, topicId
});
688 topicFetchRequested(dbCtx
, topicId
) {
689 const _scope
= _fileScope('topicFetchRequested');
690 this.logger
.debug(_scope
, 'called', { topicId
});
694 result
= this.statement
.topicContentFetchRequested
.run({ topicId
});
695 if (result
.changes
!= 1) {
696 throw new DBErrors
.UnexpectedResult('did not set topic fetch requested');
698 return this._engineInfo(result
);
700 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
707 * Converts engine topic fields to native types.
708 * @param {object} data topic
709 * @returns {object} topic data
711 static _topicDataToNative(data
) {
713 data
.isActive
= !!data
.isActive
;
714 data
.isDeleted
= !!data
.isDeleted
;
715 ['created', 'lastPublish', 'contentFetchNextAttempt', 'contentUpdated'].forEach((field
) => {
716 // eslint-disable-next-line security/detect-object-injection
717 data
[field
] = epochToDate(data
[field
]);
724 // eslint-disable-next-line no-unused-vars
726 const _scope
= _fileScope('topicGetAll');
727 this.logger
.debug(_scope
, 'called');
731 topics
= this.statement
.topicGetInfoAll
.all();
733 this.logger
.error(_scope
, 'failed', { error: e
, topics
});
738 .map(DatabaseSQLite
._topicDataToNative
)
739 .map(this._topicDefaults
.bind(this));
745 topicGetById(dbCtx
, topicId
, applyDefaults
= true) {
746 const _scope
= _fileScope('topicGetById');
747 this.logger
.debug(_scope
, 'called', { topicId
});
751 topic
= this.statement
.topicGetById
.get({ topicId
});
752 DatabaseSQLite
._topicDataToNative(topic
);
754 topic
= this._topicDefaults(topic
);
758 this.logger
.error(_scope
, 'failed', { error: e
, topic
, topicId
});
764 topicGetByUrl(dbCtx
, topicUrl
, applyDefaults
= true) {
765 const _scope
= _fileScope('topicGetByUrl');
766 this.logger
.debug(_scope
, 'called', { topicUrl
});
770 topic
= this.statement
.topicGetByUrl
.get({ topicUrl
});
771 DatabaseSQLite
._topicDataToNative(topic
);
773 topic
= this._topicDefaults(topic
);
777 this.logger
.error(_scope
, 'failed', { error: e
, topic
, topicUrl
});
783 topicGetContentById(dbCtx
, topicId
) {
784 const _scope
= _fileScope('topicGetContentById');
785 this.logger
.debug(_scope
, 'called', { topicId
});
789 topic
= this.statement
.topicGetContentById
.get({ topicId
});
790 DatabaseSQLite
._topicDataToNative(topic
);
791 return this._topicDefaults(topic
);
793 this.logger
.error(_scope
, 'failed', { error: e
, topic
, topicId
});
799 topicPendingDelete(dbCtx
, topicId
) {
800 const _scope
= _fileScope('topicPendingDelete');
801 this.logger
.debug(_scope
, 'called', { topicId
});
804 this.db
.transaction(() => {
805 const topic
= this.statement
.topicGetById
.get({ topicId
});
806 if (!topic
.isDeleted
) {
807 this.logger
.debug(_scope
, 'topic not set deleted, not deleting', { topicId
});
811 const { count: subscriberCount
} = this.statement
.subscriptionCountByTopicUrl
.get({ topicUrl: topic
.url
});
812 if (subscriberCount
) {
813 this.logger
.debug(_scope
, 'topic has subscribers, not deleting', { topicId
, subscriberCount
});
817 const result
= this.statement
.topicDeleteById
.run({ topicId
});
818 if (result
.changes
!== 1) {
819 throw new DBErrors
.UnexpectedResult('did not delete topic');
822 this.logger
.debug(_scope
, 'success', { topicId
});
824 this.logger
.error(_scope
, 'failed', { error: e
, topicId
});
830 topicPublishHistory(dbCtx
, topicId
, days
) {
831 const _scope
= _fileScope('topicPublishHistory');
832 this.logger
.debug(_scope
, 'called', { topicId
, days
});
834 const events
= this.statement
.topicPublishHistory
.all({ topicId
, daysAgo: days
});
835 const history
= Array
.from({ length: days
}, () => 0);
836 // eslint-disable-next-line security/detect-object-injection
837 events
.forEach(({ daysAgo
, contentUpdates
}) => history
[daysAgo
] = Number(contentUpdates
));
843 topicSet(dbCtx
, data
) {
844 const _scope
= _fileScope('topicSet');
845 this.logger
.debug(_scope
, 'called', data
);
847 const topicSetData
= {
848 publisherValidationUrl: null,
849 leaseSecondsPreferred: null,
850 leaseSecondsMin: null,
851 leaseSecondsMax: null,
857 this._topicSetDataValidate(topicSetData
);
858 result
= this.statement
.topicUpsert
.run(topicSetData
);
859 if (result
.changes
!= 1) {
860 throw new DBErrors
.UnexpectedResult('did not set topic data');
862 return this._engineInfo(result
);
864 this.logger
.error(_scope
, 'failed', { error: e
, result
});
870 topicSetContent(dbCtx
, data
) {
871 const _scope
= _fileScope('topicSetContent');
872 const topicSetContentData
= {
875 httpLastModified: null,
879 ...topicSetContentData
,
880 content: common
.logTruncate(topicSetContentData
.content
, 100),
882 this.logger
.debug(_scope
, 'called', logData
);
886 this._topicSetContentDataValidate(topicSetContentData
);
887 result
= this.statement
.topicSetContent
.run(topicSetContentData
);
888 logData
.result
= result
;
889 if (result
.changes
!= 1) {
890 throw new DBErrors
.UnexpectedResult('did not set topic content');
892 result
= this.statement
.topicSetContentHistory
.run({
893 topicId: data
.topicId
,
894 contentHash: data
.contentHash
,
895 contentSize: data
.content
.length
,
897 if (result
.changes
!= 1) {
898 throw new DBErrors
.UnexpectedResult('did not set topic content history');
900 return this._engineInfo(result
);
902 this.logger
.error(_scope
, 'failed', { error: e
, ...logData
});
908 topicUpdate(dbCtx
, data
) {
909 const _scope
= _fileScope('topicUpdate');
910 this.logger
.debug(_scope
, 'called', { data
});
913 leaseSecondsPreferred: null,
914 leaseSecondsMin: null,
915 leaseSecondsMax: null,
916 publisherValidationUrl: null,
920 this._topicUpdateDataValidate(topicData
);
923 const result
= this.statement
.topicUpdate
.run(topicData
);
924 if (result
.changes
!= 1) {
925 throw new DBErrors
.UnexpectedResult('did not update topic');
928 this.logger
.error(_scope
, 'failed', { error: e
, topicData
});
934 verificationClaim(dbCtx
, wanted
, claimTimeoutSeconds
, claimant
) {
935 const _scope
= _fileScope('verificationClaim');
936 this.logger
.debug(_scope
, 'called', { wanted
, claimTimeoutSeconds
});
940 this.db
.transaction(() => {
941 verificationIds
= this.statement
.verificationNeeded
.all({ wanted
}).map((claim
) => claim
.id
);
942 verificationIds
.forEach((verificationId
) => {
943 const result
= this.statement
.verificationClaimById
.run({ verificationId
, claimTimeoutSeconds
, claimant
});
944 if (result
.changes
!= 1) {
945 throw new DBErrors
.UnexpectedResult('did not claim verification');
949 return verificationIds
;
951 this.logger
.error(_scope
, 'failed to claim verifications', { wanted
, claimTimeoutSeconds
});
957 verificationClaimById(dbCtx
, verificationId
, claimTimeoutSeconds
, claimant
) {
958 const _scope
= _fileScope('verificationClaimById');
959 this.logger
.debug(_scope
, 'called', { verificationId
, claimTimeoutSeconds
, claimant
});
963 result
= this.statement
.verificationClaimById
.run({ verificationId
, claimTimeoutSeconds
, claimant
});
964 if (result
.changes
!= 1) {
965 throw new DBErrors
.UnexpectedResult('did not claim verification');
967 return this._engineInfo(result
);
969 this.logger
.error(_scope
, 'failed to claim verification', { error: e
, verificationId
, claimTimeoutSeconds
, claimant
});
975 verificationComplete(dbCtx
, verificationId
, callback
, topicId
) {
976 const _scope
= _fileScope('verificationComplete');
977 this.logger
.debug(_scope
, 'called', { verificationId
});
981 this.db
.transaction(() => {
982 result
= this.statement
.verificationScrub
.run({ verificationId
, callback
, topicId
});
983 if (result
.changes
< 1) {
984 throw new DBErrors
.UnexpectedResult('did not remove verifications');
988 this.logger
.error(_scope
, 'failed', { verificationId
});
991 return this._engineInfo(result
);
996 * Converts engine verification fields to native types.
997 * @param {object} data verification
999 static _verificationDataToNative(data
) {
1001 data
.isPublisherValidated
= !!data
.isPublisherValidated
;
1006 verificationGetById(dbCtx
, verificationId
) {
1007 const _scope
= _fileScope('verificationGetById');
1008 this.logger
.debug(_scope
, 'called', { verificationId
});
1012 verification
= this.statement
.verificationGetById
.get({ verificationId
});
1013 DatabaseSQLite
._verificationDataToNative(verification
);
1014 return verification
;
1016 this.logger
.error(_scope
, 'failed', { error: e
, verificationId
});
1022 verificationIncomplete(dbCtx
, verificationId
, retryDelays
= [60]) {
1023 const _scope
= _fileScope('verificationIncomplete');
1024 this.logger
.debug(_scope
, 'called', { verificationId
});
1028 this.db
.transaction(() => {
1029 const { attempts: currentAttempt
} = this.statement
.verificationAttempts
.get({ verificationId
});
1030 const nextAttemptDelaySeconds
= common
.attemptRetrySeconds(currentAttempt
, retryDelays
);
1031 result
= this.statement
.verificationAttemptsIncrement
.run({ verificationId
, nextAttemptDelaySeconds
});
1032 if (result
.changes
!= 1) {
1033 throw new DBErrors
.UnexpectedResult('did not increment verification attempts');
1035 result
= this.statement
.verificationDone
.run({ verificationId
});
1036 if (result
.changes
!= 1) {
1037 throw new DBErrors
.UnexpectedResult('did not release verification in progress');
1041 return this._engineInfo(result
);
1043 this.logger
.error(_scope
, 'failed', { error: e
, result
, verificationId
});
1050 * Convert native verification fields to engine types.
1051 * @param {object} data verification
1053 static _verificationDataToEngine(data
) {
1055 data
.isPublisherValidated
= data
.isPublisherValidated
? 1 : 0;
1060 verificationInsert(dbCtx
, verification
) {
1061 const _scope
= _fileScope('verificationInsert');
1062 this.logger
.debug(_scope
, 'called', { verification
});
1064 const verificationData
= {
1066 httpRemoteAddr: null,
1072 let result
, verificationId
;
1074 this._verificationDataValidate(verificationData
);
1075 DatabaseSQLite
._verificationDataToEngine(verificationData
);
1076 result
= this.statement
.verificationInsert
.run(verificationData
);
1077 if (result
.changes
!= 1) {
1078 throw new DBErrors
.UnexpectedResult('did not insert verification');
1080 verificationId
= result
.lastInsertRowid
;
1081 this.logger
.debug(_scope
, 'inserted verification', { verificationId
});
1083 return verificationId
;
1085 this.logger
.error(_scope
, 'failed', { error: e
, verificationData
});
1091 verificationRelease(dbCtx
, verificationId
) {
1092 const _scope
= _fileScope('verificationRelease');
1093 this.logger
.debug(_scope
, 'called', { verificationId
});
1097 result
= this.statement
.verificationDone
.run({ verificationId
});
1098 if (result
.changes
!= 1) {
1099 throw new DBErrors
.UnexpectedResult('did not release verification');
1102 this.logger
.error(_scope
, 'failed', { error: e
, verificationId
});
1108 verificationUpdate(dbCtx
, verificationId
, data
) {
1109 const _scope
= _fileScope('verificationUpdate');
1110 this.logger
.debug(_scope
, 'called', { verificationId
, data
});
1112 const verificationData
= {
1120 this._verificationUpdateDataValidate(verificationData
);
1121 DatabaseSQLite
._verificationDataToEngine(verificationData
);
1122 result
= this.statement
.verificationUpdate
.run(verificationData
);
1123 if (result
.changes
!= 1) {
1124 throw new DBErrors
.UnexpectedResult('did not update verification');
1127 this.logger
.error(_scope
, 'failed', { error: e
, verificationData
});
1133 verificationValidated(dbCtx
, verificationId
) {
1134 const _scope
= _fileScope('verificationValidated');
1135 this.logger
.debug(_scope
, 'called', { verificationId
});
1139 result
= this.statement
.verificationValidate
.run({ verificationId
});
1140 if (result
.changes
!= 1) {
1141 throw new DBErrors
.UnexpectedResult('did not set verification validation');
1144 this.logger
.error(_scope
, 'failed', { error: e
, verificationId
});
1152 module
.exports
= DatabaseSQLite
;