mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 21:46:51 +00:00
640 lines
22 KiB
JavaScript
640 lines
22 KiB
JavaScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { PuterStore } from '../types';
|
|
|
|
/**
|
|
* @typedef {{
|
|
* userId: number;
|
|
* value: unknown;
|
|
* uid?: string;
|
|
* type?: string;
|
|
* audience?: string;
|
|
* appUid?: string | null;
|
|
* group?: string | null;
|
|
* }} NotificationInsert
|
|
*/
|
|
|
|
/** How long a probe that found no `send_key` column stands. */
|
|
const SEND_KEY_REPROBE_MS = 60_000;
|
|
|
|
/**
|
|
* Fixed width and compared byte for byte on every engine, whatever `group`
|
|
* holds.
|
|
*/
|
|
const groupKey = (group) =>
|
|
group ? createHash('sha256').update(group, 'utf8').digest('hex') : null;
|
|
|
|
const pageLimit = (limit) => {
|
|
const n = Number(limit);
|
|
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 50;
|
|
};
|
|
|
|
/**
|
|
* The WHERE terms that pick one mailbox slice. `appUid: null` is the rows about
|
|
* no app; `undefined` is every app.
|
|
*
|
|
* @param {number} userId
|
|
* @param {{
|
|
* audience: string;
|
|
* appUid?: string | null;
|
|
* excludeTypes: readonly string[];
|
|
* }} scope
|
|
*/
|
|
const sliceWhere = (userId, { audience, appUid, excludeTypes }) => {
|
|
const where = ['`user_id` = ?', '`audience` = ?'];
|
|
const params = [userId, audience];
|
|
if (excludeTypes.length > 0) {
|
|
where.push(
|
|
`\`type\` NOT IN (${excludeTypes.map(() => '?').join(', ')})`,
|
|
);
|
|
params.push(...excludeTypes);
|
|
}
|
|
if (appUid === null) {
|
|
where.push('`app_uid` IS NULL');
|
|
} else if (appUid !== undefined) {
|
|
where.push('`app_uid` = ?');
|
|
params.push(appUid);
|
|
}
|
|
return { where, params };
|
|
};
|
|
|
|
export class NotificationStore extends PuterStore {
|
|
#sendKeyPresent = false;
|
|
#sendKeyProbedAt = Number.NEGATIVE_INFINITY;
|
|
/** @type {Promise<void> | null} */
|
|
#sendKeyProbe = null;
|
|
|
|
async onServerStart() {
|
|
await this.#hasSendKey();
|
|
}
|
|
|
|
// -- Reads --------------------------------------------------------
|
|
|
|
/**
|
|
* Pass `primary` to read back a row just written; a replica may lag.
|
|
*
|
|
* @param {string} uid
|
|
* @param {{ userId?: number; primary?: boolean }} [opts]
|
|
*/
|
|
async getByUid(uid, { userId, primary = false } = {}) {
|
|
const where =
|
|
userId !== undefined
|
|
? 'WHERE `uid` = ? AND `user_id` = ?'
|
|
: 'WHERE `uid` = ?';
|
|
const params = userId !== undefined ? [uid, userId] : [uid];
|
|
const sql = `SELECT * FROM \`notification\` ${where} LIMIT 1`;
|
|
const rows = primary
|
|
? await this.clients.db.pread(sql, params)
|
|
: await this.clients.db.read(sql, params);
|
|
return this.#normalizeRow(rows[0]) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The row a delayed send wrote or last replaced, from the primary. Called
|
|
* by extensions.
|
|
*
|
|
* @param {string} sendKey
|
|
*/
|
|
async getBySendKey(sendKey) {
|
|
const rows = await this.clients.db.pread(
|
|
'SELECT * FROM `notification` WHERE `send_key` = ? LIMIT 1',
|
|
[sendKey],
|
|
);
|
|
return this.#normalizeRow(rows[0]) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The newest row `appUid` wrote for the user under `group`, or null when it
|
|
* was shown or acknowledged: an older row is never picked up in its place.
|
|
* Rows written without a group never match.
|
|
*
|
|
* @param {number} userId
|
|
* @param {string} appUid
|
|
* @param {string} group
|
|
* @param {{ primary?: boolean }} [opts]
|
|
*/
|
|
async getUnseenByGroup(userId, appUid, group, { primary = false } = {}) {
|
|
const sql =
|
|
'SELECT * FROM `notification` WHERE `user_id` = ? AND `app_uid` = ? ' +
|
|
'AND `group_key` = ? ORDER BY `id` DESC LIMIT 1';
|
|
const params = [userId, appUid, groupKey(group)];
|
|
const rows = primary
|
|
? await this.clients.db.pread(sql, params)
|
|
: await this.clients.db.read(sql, params);
|
|
const row = rows[0];
|
|
if (!row || (row.shown ?? row.acknowledged ?? null) !== null) {
|
|
return null;
|
|
}
|
|
return this.#normalizeRow(row);
|
|
}
|
|
|
|
/**
|
|
* A mailbox, newest first. `scope` narrows to one audience/app slice on the
|
|
* way out of the database — `appUid: null` means the rows naming no app,
|
|
* which is not the same question as "any app". `excludeTypes` leaves rows
|
|
* of those types out.
|
|
*
|
|
* @param {number} userId
|
|
* @param {{
|
|
* limit?: number;
|
|
* onlyUnacknowledged?: boolean;
|
|
* filter?: string;
|
|
* scope?: {
|
|
* audiences: readonly string[];
|
|
* appUid: string | null;
|
|
* } | null;
|
|
* excludeTypes?: readonly string[];
|
|
* }} [opts]
|
|
*/
|
|
async listByUserId(
|
|
userId,
|
|
{
|
|
limit = 200,
|
|
onlyUnacknowledged = false,
|
|
filter = undefined,
|
|
scope = null,
|
|
excludeTypes = [],
|
|
} = {},
|
|
) {
|
|
const where = ['`user_id` = ?'];
|
|
const params = [userId];
|
|
|
|
if (excludeTypes.length > 0) {
|
|
where.push(
|
|
`\`type\` NOT IN (${excludeTypes.map(() => '?').join(', ')})`,
|
|
);
|
|
params.push(...excludeTypes);
|
|
}
|
|
|
|
if (onlyUnacknowledged || filter === 'unacknowledged') {
|
|
where.push('`acknowledged` IS NULL');
|
|
} else if (filter === 'unseen') {
|
|
where.push('`shown` IS NULL', '`acknowledged` IS NULL');
|
|
} else if (filter === 'acknowledged') {
|
|
where.push('`acknowledged` IS NOT NULL');
|
|
}
|
|
|
|
if (scope) {
|
|
if (scope.audiences.length === 0) return [];
|
|
const placeholders = scope.audiences.map(() => '?').join(', ');
|
|
where.push(`\`audience\` IN (${placeholders})`);
|
|
params.push(...scope.audiences);
|
|
// `undefined` is "any app" — a session's own generic slice, left
|
|
// unfiltered here because the caller applies the audience
|
|
// predicate over the page afterwards.
|
|
if (scope.appUid === null) {
|
|
where.push('`app_uid` IS NULL');
|
|
} else if (scope.appUid !== undefined) {
|
|
where.push('`app_uid` = ?');
|
|
params.push(scope.appUid);
|
|
}
|
|
}
|
|
|
|
const n = Number(limit);
|
|
const safeLimit = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 200;
|
|
const rows = await this.clients.db.read(
|
|
`SELECT * FROM \`notification\`
|
|
WHERE ${where.join(' AND ')}
|
|
ORDER BY \`created_at\` DESC, \`id\` DESC
|
|
LIMIT ?`,
|
|
[...params, safeLimit],
|
|
);
|
|
return rows.map((r) => this.#normalizeRow(r));
|
|
}
|
|
|
|
/**
|
|
* One page of a mailbox slice, oldest first, for a client catching up on
|
|
* what it missed. Keyset on `id`: `after` is the last id of the previous
|
|
* page, so a page cannot repeat or skip a row the way an offset can when
|
|
* rows arrive between requests.
|
|
*
|
|
* `appUid` is matched, not filtered afterwards — `null` means the rows
|
|
* about no app, which is not the same question as "any app". `undefined` is
|
|
* "any app": a session's own generic slice, which spans every app the
|
|
* audience predicate already grants it rather than one named ref.
|
|
* `excludeTypes` leaves rows of those types out.
|
|
*
|
|
* @param {number} userId
|
|
* @param {{
|
|
* audience: string;
|
|
* appUid?: string | null;
|
|
* after?: number | null;
|
|
* limit?: number;
|
|
* excludeTypes?: readonly string[];
|
|
* }} opts
|
|
*/
|
|
async listScoped(
|
|
userId,
|
|
{ audience, appUid, after = null, limit = 50, excludeTypes = [] },
|
|
) {
|
|
const safeLimit = pageLimit(limit);
|
|
if (safeLimit === 0) return [];
|
|
|
|
const { where, params } = sliceWhere(userId, {
|
|
audience,
|
|
appUid,
|
|
excludeTypes,
|
|
});
|
|
if (after !== null && Number.isFinite(Number(after))) {
|
|
where.push('`id` > ?');
|
|
params.push(Math.floor(Number(after)));
|
|
}
|
|
|
|
const rows = await this.clients.db.read(
|
|
`SELECT * FROM \`notification\`
|
|
WHERE ${where.join(' AND ')}
|
|
ORDER BY \`id\` ASC
|
|
LIMIT ?`,
|
|
[...params, safeLimit],
|
|
);
|
|
return rows.map((r) => this.#normalizeRow(r));
|
|
}
|
|
|
|
/**
|
|
* The rows of a mailbox slice up to id `upTo` that were replaced after
|
|
* `after`, in replacement order: what a client that read up to `upTo` has
|
|
* not seen since. Keyset on (`replaced_at`, `id`), so rows replaced in the
|
|
* same millisecond page without a repeat or a skip. Scoped like
|
|
* `listScoped`.
|
|
*
|
|
* @param {number} userId
|
|
* @param {{
|
|
* audience: string;
|
|
* appUid?: string | null;
|
|
* upTo: number;
|
|
* after: { replacedAt: number; id: number };
|
|
* limit?: number;
|
|
* excludeTypes?: readonly string[];
|
|
* }} opts
|
|
*/
|
|
async listReplacedScoped(
|
|
userId,
|
|
{ audience, appUid, upTo, after, limit = 50, excludeTypes = [] },
|
|
) {
|
|
const safeLimit = pageLimit(limit);
|
|
if (safeLimit === 0) return [];
|
|
|
|
const { where, params } = sliceWhere(userId, {
|
|
audience,
|
|
appUid,
|
|
excludeTypes,
|
|
});
|
|
where.push(
|
|
'`id` <= ?',
|
|
'`replaced_at` >= ?',
|
|
'(`replaced_at` > ? OR `id` > ?)',
|
|
);
|
|
params.push(upTo, after.replacedAt, after.replacedAt, after.id);
|
|
|
|
const rows = await this.clients.db.read(
|
|
`SELECT * FROM \`notification\`
|
|
WHERE ${where.join(' AND ')}
|
|
ORDER BY \`replaced_at\` ASC, \`id\` ASC
|
|
LIMIT ?`,
|
|
[...params, safeLimit],
|
|
);
|
|
return rows.map((r) => this.#normalizeRow(r));
|
|
}
|
|
|
|
// -- Writes -------------------------------------------------------
|
|
|
|
/**
|
|
* Write a notification and return its row, built from what was written:
|
|
* `created_at` is stamped here rather than defaulted, so nothing needs
|
|
* reading back.
|
|
*
|
|
* `uid` lets the caller name the row up front — NotificationService pushes
|
|
* the uid to the socket after this insert lands, and the ack / mark-shown
|
|
* round trip comes back keyed on it.
|
|
*
|
|
* `type`, `audience` and `appUid` are the scope tuple; the registry in the
|
|
* notification service is what decides which combinations are legal, and
|
|
* the empty `type` written by a caller that names none reads as legacy.
|
|
* `group` is kept as a key `getUnseenByGroup` finds the row by.
|
|
*
|
|
* @param {NotificationInsert} args
|
|
*/
|
|
async create({
|
|
userId,
|
|
value,
|
|
uid = uuidv4(),
|
|
type = '',
|
|
audience = 'account',
|
|
appUid = null,
|
|
group = null,
|
|
}) {
|
|
if (!userId) throw new Error('create: userId is required');
|
|
const db = this.clients.db;
|
|
const serialized =
|
|
typeof value === 'string' ? value : JSON.stringify(value ?? {});
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
const key = groupKey(group);
|
|
const { insertId } = await db.write(
|
|
'INSERT INTO `notification` (`uid`, `user_id`, `value`, `type`, ' +
|
|
'`audience`, `app_uid`, `group_key`, `created_at`) VALUES ' +
|
|
`(?, ?, ?, ?, ?, ?, ?, ${db.timestampFromUnixSql()})${db.returningIdClause()}`,
|
|
[uid, userId, serialized, type, audience, appUid, key, createdAt],
|
|
);
|
|
return this.#normalizeRow({
|
|
id: Number(insertId),
|
|
uid,
|
|
user_id: userId,
|
|
value: serialized,
|
|
acknowledged: null,
|
|
shown: null,
|
|
created_at: db.timestampValue(createdAt),
|
|
app_uid: appUid,
|
|
audience,
|
|
type,
|
|
group_key: key,
|
|
replaced_at: null,
|
|
send_key: null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `create` for a caller that needs only the uid.
|
|
*
|
|
* @param {NotificationInsert} args
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async insert(args) {
|
|
return (await this.create(args)).uid;
|
|
}
|
|
|
|
/**
|
|
* The statements that write a delayed send's notification, for the caller's
|
|
* transaction. Each runs only while `guard` holds. With `replaceUid` the
|
|
* open row is replaced in place and takes `sendKey`; the insert, whose uid
|
|
* is `sendKey`, then runs only if that row was shown or dismissed in
|
|
* between. Called by extensions.
|
|
*
|
|
* @param {{
|
|
* sendKey: string;
|
|
* userId: number;
|
|
* value: unknown;
|
|
* type: string;
|
|
* audience: string;
|
|
* appUid: string | null;
|
|
* group?: string | null;
|
|
* replaceUid?: string | null;
|
|
* at: number;
|
|
* }} send
|
|
* `at` is epoch ms, the row's time either way.
|
|
* @param {{ table: string; where: string; values: unknown[] }} guard
|
|
* @returns {{ statement: string; values: unknown[] }[]}
|
|
*/
|
|
sendEntries(send, guard) {
|
|
const db = this.clients.db;
|
|
const serialized = JSON.stringify(send.value ?? {});
|
|
const seconds = Math.floor(send.at / 1000);
|
|
const fence = `SELECT 1 FROM \`${guard.table}\` WHERE ${guard.where}`;
|
|
const entries = [];
|
|
if (send.replaceUid) {
|
|
entries.push({
|
|
statement:
|
|
'UPDATE `notification` SET `value` = ?, `shown` = NULL, ' +
|
|
`\`created_at\` = ${db.timestampFromUnixSql()}, ` +
|
|
'`replaced_at` = ?, `send_key` = ? WHERE `uid` = ? ' +
|
|
'AND `user_id` = ? AND `shown` IS NULL ' +
|
|
`AND \`acknowledged\` IS NULL AND EXISTS (${fence})`,
|
|
values: [
|
|
serialized,
|
|
seconds,
|
|
send.at,
|
|
send.sendKey,
|
|
send.replaceUid,
|
|
send.userId,
|
|
...guard.values,
|
|
],
|
|
});
|
|
}
|
|
// Postgres can't type a bare parameter in a SELECT list.
|
|
const param = (type) =>
|
|
db.case({ postgres: `CAST(? AS ${type})`, otherwise: '?' });
|
|
entries.push({
|
|
statement:
|
|
'INSERT INTO `notification` (`uid`, `user_id`, `value`, `type`, ' +
|
|
'`audience`, `app_uid`, `group_key`, `created_at`, `send_key`) ' +
|
|
`SELECT ${param('TEXT')}, ${param('INTEGER')}, ${param('JSONB')}, ` +
|
|
`${param('TEXT')}, ${param('TEXT')}, ${param('TEXT')}, ` +
|
|
`${param('TEXT')}, ${db.timestampFromUnixSql()}, ${param('TEXT')} ` +
|
|
`FROM \`${guard.table}\` WHERE ${guard.where} AND NOT EXISTS ` +
|
|
'(SELECT 1 FROM `notification` WHERE `send_key` = ?)',
|
|
values: [
|
|
send.sendKey,
|
|
send.userId,
|
|
serialized,
|
|
send.type,
|
|
send.audience,
|
|
send.appUid,
|
|
groupKey(send.group),
|
|
seconds,
|
|
send.sendKey,
|
|
...guard.values,
|
|
send.sendKey,
|
|
],
|
|
});
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Rewrite a notification the recipient hasn't dismissed. `shown` is cleared
|
|
* with it, so changed wording goes out again on their next connect —
|
|
* `#sendUnreads` only carries what was never shown.
|
|
*
|
|
* `onlyUnseen` also skips a row already shown. `replacedAt`, in epoch ms,
|
|
* records the rewrite as a replacement and dates the row to it.
|
|
*
|
|
* `send_key` is cleared with it whenever the column exists, on every node:
|
|
* the content is no longer what a delayed send wrote, so its recovery must
|
|
* not deliver it under that send's key.
|
|
*
|
|
* False when no such row exists, so callers can fall back to a fresh
|
|
* notification instead of dropping what they were reporting.
|
|
*
|
|
* @param {string} uid @param {number} userId @param {unknown} value
|
|
* @param {{ onlyUnseen?: boolean; replacedAt?: number }} [opts]
|
|
*/
|
|
async updateValue(
|
|
uid,
|
|
userId,
|
|
value,
|
|
{ onlyUnseen = false, replacedAt = undefined } = {},
|
|
) {
|
|
const db = this.clients.db;
|
|
const serialized =
|
|
typeof value === 'string' ? value : JSON.stringify(value ?? {});
|
|
const set = ['`value` = ?', '`shown` = NULL'];
|
|
if (await this.#hasSendKey()) set.push('`send_key` = NULL');
|
|
const params = [serialized];
|
|
if (replacedAt !== undefined) {
|
|
set.push(
|
|
`\`created_at\` = ${db.timestampFromUnixSql()}`,
|
|
'`replaced_at` = ?',
|
|
);
|
|
params.push(Math.floor(replacedAt / 1000), replacedAt);
|
|
}
|
|
const where = ['`uid` = ?', '`user_id` = ?', '`acknowledged` IS NULL'];
|
|
if (onlyUnseen) where.push('`shown` IS NULL');
|
|
const result = await db.write(
|
|
`UPDATE \`notification\` SET ${set.join(', ')} WHERE ${where.join(' AND ')}`,
|
|
[...params, uid, userId],
|
|
);
|
|
return result.anyRowsAffected;
|
|
}
|
|
|
|
async markAcknowledged(uid, userId) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const result = await this.clients.db.write(
|
|
'UPDATE `notification` SET `acknowledged` = ? WHERE `uid` = ? AND `user_id` = ? AND `acknowledged` IS NULL',
|
|
[now, uid, userId],
|
|
);
|
|
return result.anyRowsAffected;
|
|
}
|
|
|
|
async markShown(uid, userId) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const result = await this.clients.db.write(
|
|
'UPDATE `notification` SET `shown` = ? WHERE `uid` = ? AND `user_id` = ? AND `shown` IS NULL',
|
|
[now, uid, userId],
|
|
);
|
|
return result.anyRowsAffected;
|
|
}
|
|
|
|
/**
|
|
* Mark a batch shown in one statement. The reconnect replay hands over
|
|
* everything a client missed at once, and a round trip per row turns one
|
|
* connect into up to two hundred of them.
|
|
*
|
|
* @param {string[]} uids @param {number} userId
|
|
*/
|
|
async markShownByUids(uids, userId) {
|
|
const unique = [...new Set(uids.filter((uid) => !!uid))];
|
|
if (unique.length === 0) return 0;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const placeholders = unique.map(() => '?').join(', ');
|
|
const result = await this.clients.db.write(
|
|
'UPDATE `notification` SET `shown` = ? ' +
|
|
`WHERE \`user_id\` = ? AND \`shown\` IS NULL AND \`uid\` IN (${placeholders})`,
|
|
[now, userId, ...unique],
|
|
);
|
|
return result.affectedRows;
|
|
}
|
|
|
|
async deleteByUid(uid, userId) {
|
|
const result = await this.clients.db.write(
|
|
'DELETE FROM `notification` WHERE `uid` = ? AND `user_id` = ?',
|
|
[uid, userId],
|
|
);
|
|
return result.anyRowsAffected;
|
|
}
|
|
|
|
/**
|
|
* Delete up to `limit` rows created more than `days` ago, and report how
|
|
* many went — a full batch means there is more behind it.
|
|
*
|
|
* Only mysql takes a LIMIT on DELETE — postgres has none and sqlite's needs
|
|
* an optional build flag — so the other two bound the batch through an id
|
|
* list. Each engine computes its own cutoff, so no clock crosses the wire.
|
|
*
|
|
* @param {number} days @param {number} limit
|
|
*/
|
|
async deleteCreatedBefore(days, limit) {
|
|
const retentionDays = Math.floor(Number(days));
|
|
const batch = Math.floor(Number(limit));
|
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0;
|
|
if (!Number.isFinite(batch) || batch <= 0) return 0;
|
|
|
|
const cutoff = this.clients.db.case({
|
|
sqlite: `datetime('now', '-${retentionDays} days')`,
|
|
postgres: `(NOW() - INTERVAL '${retentionDays} days')`,
|
|
otherwise: `(NOW() - INTERVAL ${retentionDays} DAY)`,
|
|
});
|
|
const statement = this.clients.db.case({
|
|
mysql:
|
|
'DELETE FROM `notification` ' +
|
|
`WHERE \`created_at\` < ${cutoff} ORDER BY \`id\` LIMIT ?`,
|
|
otherwise:
|
|
'DELETE FROM `notification` WHERE `id` IN (' +
|
|
'SELECT `id` FROM `notification` ' +
|
|
`WHERE \`created_at\` < ${cutoff} ORDER BY \`id\` LIMIT ?)`,
|
|
});
|
|
|
|
const result = await this.clients.db.write(statement, [batch]);
|
|
return result.affectedRows;
|
|
}
|
|
|
|
// -- Internals ----------------------------------------------------
|
|
|
|
/**
|
|
* Whether `notification.send_key` exists, asked of the primary. Present is
|
|
* kept for the process; absent is asked again at most once a minute, so a
|
|
* node started before the column was added clears it soon after.
|
|
*
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async #hasSendKey() {
|
|
if (this.#sendKeyPresent) return true;
|
|
if (Date.now() - this.#sendKeyProbedAt < SEND_KEY_REPROBE_MS)
|
|
return false;
|
|
if (this.#sendKeyProbe) {
|
|
await this.#sendKeyProbe;
|
|
return this.#sendKeyPresent;
|
|
}
|
|
const probe = this.#probeSendKey();
|
|
this.#sendKeyProbe = probe;
|
|
try {
|
|
await probe;
|
|
} finally {
|
|
if (this.#sendKeyProbe === probe) this.#sendKeyProbe = null;
|
|
}
|
|
return this.#sendKeyPresent;
|
|
}
|
|
|
|
/** Never rejects: an error reads as no column. */
|
|
async #probeSendKey() {
|
|
try {
|
|
await this.clients.db.pread(
|
|
'SELECT `send_key` FROM `notification` WHERE 1 = 0',
|
|
[],
|
|
);
|
|
this.#sendKeyPresent = true;
|
|
} catch {
|
|
this.#sendKeyPresent = false;
|
|
}
|
|
this.#sendKeyProbedAt = Date.now();
|
|
}
|
|
|
|
#normalizeRow(row) {
|
|
if (!row) return null;
|
|
if (typeof row.value === 'string') {
|
|
try {
|
|
row.value = JSON.parse(row.value);
|
|
} catch {
|
|
/* keep string */
|
|
}
|
|
}
|
|
return row;
|
|
}
|
|
}
|