mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
release-2.9.2 (#1566)
* fix: stop full database rewrites on every plugin db call * fix: restore release video link in 2.9.2 notes * fix: use TERMIX_PAT in workflows
This commit is contained in:
1 parent
40df00bd95
commit
2b309e9582
19 files changed
+342
-87
No files matched your search
@@ -35,7 +35,7 @@ jobs:
|
|||||||
- name: Resolve newest dev branch and compute beta version
|
- name: Resolve newest dev branch and compute beta version
|
||||||
id: dev
|
id: dev
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
|
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
|
||||||
if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
|
if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
- name: Resolve previous beta commit
|
- name: Resolve previous beta commit
|
||||||
id: prev
|
id: prev
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
SHA="${{ needs.prep.outputs.sha }}"
|
SHA="${{ needs.prep.outputs.sha }}"
|
||||||
PREV_SHA=$(gh release view beta --repo ${{ github.repository }} --json targetCommitish -q .targetCommitish 2>/dev/null || true)
|
PREV_SHA=$(gh release view beta --repo ${{ github.repository }} --json targetCommitish -q .targetCommitish 2>/dev/null || true)
|
||||||
@@ -165,7 +165,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create or update rolling beta release
|
- name: Create or update rolling beta release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
TAG="beta"
|
TAG="beta"
|
||||||
TITLE="Beta (rolling) - ${{ needs.prep.outputs.beta_version }}"
|
TITLE="Beta (rolling) - ${{ needs.prep.outputs.beta_version }}"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ steps.branch.outputs.name }}
|
ref: ${{ steps.branch.outputs.name }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.TERMIX_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Resolve newest dev branch
|
- name: Resolve newest dev branch
|
||||||
id: dev
|
id: dev
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
|
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
|
||||||
# The helper exits non-zero when no dev-X.Y.Z branch exists; treat that
|
# The helper exits non-zero when no dev-X.Y.Z branch exists; treat that
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Retarget and merge Dependabot PRs
|
- name: Retarget and merge Dependabot PRs
|
||||||
if: ${{ steps.dev.outputs.branch != '' }}
|
if: ${{ steps.dev.outputs.branch != '' }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
DEV_BRANCH: ${{ steps.dev.outputs.branch }}
|
DEV_BRANCH: ${{ steps.dev.outputs.branch }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: lukegus
|
username: lukegus
|
||||||
password: ${{ secrets.GHCR_TOKEN }}
|
password: ${{ secrets.TERMIX_PAT }}
|
||||||
|
|
||||||
- name: Login to Docker Hub (prod and beta only)
|
- name: Login to Docker Hub (prod and beta only)
|
||||||
if: ${{ (inputs.build_type == 'Production' || inputs.build_type == 'Beta') && !inputs.dry_run }}
|
if: ${{ (inputs.build_type == 'Production' || inputs.build_type == 'Beta') && !inputs.dry_run }}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.dev_branch }}
|
ref: ${{ needs.prep.outputs.dev_branch }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.TERMIX_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
@@ -153,7 +153,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.dev_branch }}
|
ref: ${{ needs.prep.outputs.dev_branch }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.TERMIX_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
- name: Clean up stale Crowdin git integration branch
|
- name: Clean up stale Crowdin git integration branch
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
|
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
|
||||||
if [ -n "$PR_NUMBER" ]; then
|
if [ -n "$PR_NUMBER" ]; then
|
||||||
@@ -254,7 +254,7 @@ jobs:
|
|||||||
- name: Open and squash-merge PR to main
|
- name: Open and squash-merge PR to main
|
||||||
id: merge
|
id: merge
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
|
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
|
||||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
TITLE="release-${{ needs.prep.outputs.version }}"
|
||||||
@@ -325,7 +325,7 @@ jobs:
|
|||||||
- name: Clear existing release artifacts (overwrite mode)
|
- name: Clear existing release artifacts (overwrite mode)
|
||||||
if: ${{ inputs.mode == 'Overwrite release' }}
|
if: ${{ inputs.mode == 'Overwrite release' }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ needs.prep.outputs.release_tag }}"
|
TAG="${{ needs.prep.outputs.release_tag }}"
|
||||||
if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
||||||
@@ -348,7 +348,7 @@ jobs:
|
|||||||
- name: Create or update GitHub release
|
- name: Create or update GitHub release
|
||||||
if: ${{ inputs.mode != 'Overwrite release' }}
|
if: ${{ inputs.mode != 'Overwrite release' }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ needs.prep.outputs.release_tag }}"
|
TAG="${{ needs.prep.outputs.release_tag }}"
|
||||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
TITLE="release-${{ needs.prep.outputs.version }}"
|
||||||
@@ -391,7 +391,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.TERMIX_PAT }}
|
||||||
|
|
||||||
- name: Bump and commit Homebrew cask
|
- name: Bump and commit Homebrew cask
|
||||||
env:
|
env:
|
||||||
@@ -452,7 +452,7 @@ jobs:
|
|||||||
repository: Termix-SSH/Docs
|
repository: Termix-SSH/Docs
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.TERMIX_PAT }}
|
||||||
path: docs-repo
|
path: docs-repo
|
||||||
|
|
||||||
- name: Create docs release branch
|
- name: Create docs release branch
|
||||||
@@ -499,7 +499,7 @@ jobs:
|
|||||||
if: ${{ inputs.mode != 'Dry run' }}
|
if: ${{ inputs.mode != 'Dry run' }}
|
||||||
working-directory: docs-repo
|
working-directory: docs-repo
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
BRANCH="dev-${{ needs.prep.outputs.version }}"
|
BRANCH="dev-${{ needs.prep.outputs.version }}"
|
||||||
if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||||
@@ -567,14 +567,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Delete dev branch in Termix
|
- name: Delete dev branch in Termix
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/${{ github.repository }}.git \
|
git push https://x-access-token:${{ secrets.TERMIX_PAT }}@github.com/${{ github.repository }}.git \
|
||||||
--delete "${{ needs.prep.outputs.dev_branch }}" || true
|
--delete "${{ needs.prep.outputs.dev_branch }}" || true
|
||||||
|
|
||||||
- name: Delete dev branch in Docs
|
- name: Delete dev branch in Docs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
|
||||||
run: |
|
run: |
|
||||||
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/Termix-SSH/Docs.git \
|
git push https://x-access-token:${{ secrets.TERMIX_PAT }}@github.com/Termix-SSH/Docs.git \
|
||||||
--delete "dev-${{ needs.prep.outputs.version }}" || true
|
--delete "dev-${{ needs.prep.outputs.version }}" || true
|
||||||
+6
-38
@@ -1,6 +1,6 @@
|
|||||||
<!-- SUMMARY -->
|
<!-- SUMMARY -->
|
||||||
|
|
||||||
A patch for 2.9.0 that fixes OIDC sign-in after upgrading, Docker and Host Metrics logins, memory and CPU use, file downloads, tmux sessions, user data export and more. Also adds predefined host tags.
|
An urgent patch for 2.9.1 that fixes the server writing huge amounts of data to disk, which could add up to 100 GB every few hours. Update as soon as you can.
|
||||||
|
|
||||||
<!-- /SUMMARY -->
|
<!-- /SUMMARY -->
|
||||||
|
|
||||||
@@ -12,47 +12,15 @@ https://youtu.be/lngaePO96tM
|
|||||||
|
|
||||||
<!-- UPDATE_LOG -->
|
<!-- UPDATE_LOG -->
|
||||||
|
|
||||||
- Added predefined host tags that admins can set, with suggestions in the host editor
|
- Background database saves now happen at most once every 30 seconds, so disk writes no longer grow with the number of hosts
|
||||||
- Kept compact host row actions on the same line as the host name
|
- Plugin database and storage reads are now logged to the audit log once per start instead of on every read
|
||||||
- Added a reload button when a sign-in verification method fails to load
|
- On first start, 2.9.2 clears the plugin read entries that 2.9.1 piled up in the audit log
|
||||||
- Admins can now merge a duplicate SSO account made by 2.9.0 back into the original account
|
|
||||||
- Hosts behind the same jump hosts now share one connection for status checks
|
|
||||||
- Simplified host status to just online or offline, removing the "last login failed" state
|
|
||||||
- Host status now updates every 15 seconds without a page refresh, and status checks run every 30 seconds by default
|
|
||||||
- Completed the Simplified Chinese translation
|
|
||||||
- Documented cookie and API key sign-in in the API docs
|
|
||||||
|
|
||||||
<!-- /UPDATE_LOG -->
|
<!-- /UPDATE_LOG -->
|
||||||
|
|
||||||
<!-- BUG_FIXES -->
|
<!-- BUG_FIXES -->
|
||||||
|
|
||||||
- OIDC accounts from 2.8 not being able to sign in, or getting a new empty account, after upgrading
|
- The server rewriting its whole database to disk many times a second, causing very high disk writes (up to 100 GB every few hours) on 2.9.1
|
||||||
- Downloading a file in the file manager turning the whole screen black
|
- The audit log filling up with plugin entries and pushing out real ones
|
||||||
- tmux sessions not resuming and new sessions being created instead
|
|
||||||
- Connections through two or more jump hosts failing
|
|
||||||
- Docker and Host Metrics failing to sign in while the terminal worked
|
|
||||||
- Fleets, Proxmox and automations not getting the host sudo password
|
|
||||||
- Server running out of memory on start and when importing hosts
|
|
||||||
- High CPU use after the app was first opened
|
|
||||||
- User data export freezing the server
|
|
||||||
- 1Password and other secret references being rejected as SSH keys
|
|
||||||
- Slow file deletes in the file manager
|
|
||||||
- Mac App Store app failing to start its local server
|
|
||||||
- Snippets failing to sync on older databases
|
|
||||||
- GitHub sign in failing
|
|
||||||
- RDP showing scrollbars and jittering near the edges
|
|
||||||
- Status checks triggering Fail2Ban bans while a session was open
|
|
||||||
- Too many database writes from session activity
|
|
||||||
- Host editor tabs being hidden when they did not fit
|
|
||||||
- SSH host keys being accepted without a check when the host was missing from the database
|
|
||||||
- Host status only updating after a full page refresh
|
|
||||||
- Every host showing offline right after signing in
|
|
||||||
- Host status flipping between online and offline on refresh or after one dropped packet
|
|
||||||
- Hosts showing "last login failed" when they connected fine
|
|
||||||
- Hosts behind a jump host staying offline after the jump connection dropped
|
|
||||||
- Host status being wiped in the desktop app when the host list failed to load
|
|
||||||
- Tunnels, remote desktop logins and other host settings missing from shared hosts in the desktop app
|
|
||||||
- SSO and LDAP provider dialogs overflowing the screen and using mismatched toggles
|
|
||||||
- Plugin audit log entries failing to save when no user was signed in
|
|
||||||
|
|
||||||
<!-- /BUG_FIXES -->
|
<!-- /BUG_FIXES -->
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.9.1",
|
"version": "2.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.9.1",
|
"version": "2.9.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"packages/*",
|
"packages/*",
|
||||||
"plugins/*"
|
"plugins/*"
|
||||||
],
|
],
|
||||||
"version": "2.9.1",
|
"version": "2.9.2",
|
||||||
"description": "Self-hosted SSH and remote desktop management.",
|
"description": "Self-hosted SSH and remote desktop management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -76,4 +76,8 @@ export async function runCoreBootMigrations(): Promise<void> {
|
|||||||
const { runSshOptionsMigration } =
|
const { runSshOptionsMigration } =
|
||||||
await import("./utils/crypto-migration/ssh-options-migration.js");
|
await import("./utils/crypto-migration/ssh-options-migration.js");
|
||||||
await runSshOptionsMigration();
|
await runSshOptionsMigration();
|
||||||
|
|
||||||
|
const { runAuditNoiseCleanup } =
|
||||||
|
await import("./upgrade/audit-noise-cleanup.js");
|
||||||
|
await runAuditNoiseCleanup();
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import { and, asc, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
lt,
|
||||||
|
lte,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { auditLogs } from "../db/schema.js";
|
import { auditLogs } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||||
@@ -26,6 +37,17 @@ export const AUDIT_RETENTION_DAYS_ENV = "AUDIT_LOG_RETENTION_DAYS";
|
|||||||
export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES";
|
export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES";
|
||||||
|
|
||||||
const DEFAULT_MAX_ENTRIES = 10000;
|
const DEFAULT_MAX_ENTRIES = 10000;
|
||||||
|
|
||||||
|
// 2.9.1 logged these on every background plugin query and pushed real
|
||||||
|
// entries out of the capped log. They are now audited once per activation.
|
||||||
|
export const ROUTINE_PLUGIN_AUDIT_ACTIONS = [
|
||||||
|
"plugin_db_client",
|
||||||
|
"plugin_db_refs",
|
||||||
|
"plugin_kv_get",
|
||||||
|
"plugin_kv_list",
|
||||||
|
"plugin_files_data_dir",
|
||||||
|
"plugin_settings_read_core",
|
||||||
|
];
|
||||||
const PRUNE_TARGET_RATIO = 0.9;
|
const PRUNE_TARGET_RATIO = 0.9;
|
||||||
|
|
||||||
function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null {
|
function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null {
|
||||||
@@ -166,6 +188,24 @@ export class AuditLogRepository {
|
|||||||
return rowsAffected(result);
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteRoutinePluginNoise(): Promise<number> {
|
||||||
|
AuditLogRepository.cachedCount = null;
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(auditLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(auditLogs.userId),
|
||||||
|
inArray(auditLogs.action, ROUTINE_PLUGIN_AUDIT_ACTIONS),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rowsAffected(result) > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return rowsAffected(result);
|
||||||
|
}
|
||||||
|
|
||||||
private buildWhere(filters: AuditLogFilters) {
|
private buildWhere(filters: AuditLogFilters) {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function createCurrentRepositoryWriteHook(
|
|||||||
*
|
*
|
||||||
* Marks the in-memory database dirty and lets DatabaseSaveTrigger's existing
|
* Marks the in-memory database dirty and lets DatabaseSaveTrigger's existing
|
||||||
* debounce coalesce the actual serialize+encrypt, instead of forcing one on
|
* debounce coalesce the actual serialize+encrypt, instead of forcing one on
|
||||||
* every single sample. A lost 2-second window of telemetry on crash is
|
* every single sample. Losing up to 30 seconds of these on a crash is
|
||||||
* acceptable; blocking the event loop that serves SSH traffic on every metric
|
* acceptable; blocking the event loop that serves SSH traffic on every metric
|
||||||
* sample is not.
|
* sample is not.
|
||||||
*/
|
*/
|
||||||
@@ -128,7 +128,8 @@ export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
|||||||
export function createCurrentAuditLogRepository(): AuditLogRepository {
|
export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||||
return new AuditLogRepository(
|
return new AuditLogRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
createCurrentRepositoryWriteHook("audit_log_repository_write"),
|
// Rows land on every audited call; the audited action saves on its own.
|
||||||
|
createCurrentRepositoryLazyWriteHook("audit_log_repository_write"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ interface AuditOptions {
|
|||||||
action: string;
|
action: string;
|
||||||
/** Short description of the call shape. Never a value. */
|
/** Short description of the call shape. Never a value. */
|
||||||
details?: () => string;
|
details?: () => string;
|
||||||
|
/**
|
||||||
|
* Audit only the first success. For handles plugins fetch before every
|
||||||
|
* query: an audit row per call rewrote the whole database each time.
|
||||||
|
*/
|
||||||
|
once?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +129,7 @@ function guarded<Args extends unknown[], Result>(
|
|||||||
fn: (...args: Args) => Promise<Result>,
|
fn: (...args: Args) => Promise<Result>,
|
||||||
options: AuditOptions,
|
options: AuditOptions,
|
||||||
): (...args: Args) => Promise<Result> {
|
): (...args: Args) => Promise<Result> {
|
||||||
|
let audited = false;
|
||||||
return async (...args: Args): Promise<Result> => {
|
return async (...args: Args): Promise<Result> => {
|
||||||
// A refusal is audited inside assertCapability, under this action.
|
// A refusal is audited inside assertCapability, under this action.
|
||||||
await assertCapability(
|
await assertCapability(
|
||||||
@@ -135,7 +141,10 @@ function guarded<Args extends unknown[], Result>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fn(...args);
|
const result = await fn(...args);
|
||||||
await writeAudit(manifest, options, { success: true });
|
if (!options.once || !audited) {
|
||||||
|
audited = true;
|
||||||
|
await writeAudit(manifest, options, { success: true });
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await writeAudit(manifest, options, {
|
await writeAudit(manifest, options, {
|
||||||
@@ -242,7 +251,7 @@ export function createPluginContext(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ action: "kv_get" },
|
{ action: "kv_get", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const kvSet = guarded(
|
const kvSet = guarded(
|
||||||
@@ -296,7 +305,7 @@ export function createPluginContext(
|
|||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
return createCurrentPluginStorageRepository().listKeys(pluginId);
|
return createCurrentPluginStorageRepository().listKeys(pluginId);
|
||||||
},
|
},
|
||||||
{ action: "kv_list" },
|
{ action: "kv_list", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const filesDataDir = guarded(
|
const filesDataDir = guarded(
|
||||||
@@ -309,7 +318,7 @@ export function createPluginContext(
|
|||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
return dir;
|
return dir;
|
||||||
},
|
},
|
||||||
{ action: "files_data_dir" },
|
{ action: "files_data_dir", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const dbDefine = guarded(
|
const dbDefine = guarded(
|
||||||
@@ -329,7 +338,7 @@ export function createPluginContext(
|
|||||||
const { getDb } = await import("../database/db/index.js");
|
const { getDb } = await import("../database/db/index.js");
|
||||||
return getDb();
|
return getDb();
|
||||||
},
|
},
|
||||||
{ action: "db_client" },
|
{ action: "db_client", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// The one settings call that leaves the plugin's own namespace, so the one
|
// The one settings call that leaves the plugin's own namespace, so the one
|
||||||
@@ -341,6 +350,7 @@ export function createPluginContext(
|
|||||||
{
|
{
|
||||||
action: "settings_read_core",
|
action: "settings_read_core",
|
||||||
details: () => "read a core server setting",
|
details: () => "read a core server setting",
|
||||||
|
once: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -359,7 +369,7 @@ export function createPluginContext(
|
|||||||
userRoles: schema.userRoles,
|
userRoles: schema.userRoles,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ action: "db_refs" },
|
{ action: "db_refs", once: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const desktopOpenIsolatedWindow = guarded(
|
const desktopOpenIsolatedWindow = guarded(
|
||||||
@@ -488,8 +498,8 @@ export function createPluginContext(
|
|||||||
define: (definition) => dbDefine(definition) as never,
|
define: (definition) => dbDefine(definition) as never,
|
||||||
client: () => dbClient() as never,
|
client: () => dbClient() as never,
|
||||||
refs: () => dbRefs() as never,
|
refs: () => dbRefs() as never,
|
||||||
// Gated but not audited: it follows every write, and the write itself
|
// Gated but not audited: it follows every write, and client() was
|
||||||
// went through a client() call that already left an audit line.
|
// already audited when the plugin first asked for it.
|
||||||
persist: async (options) => {
|
persist: async (options) => {
|
||||||
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
||||||
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
||||||
|
|||||||
@@ -142,6 +142,42 @@ describe("AuditLogRepository", () => {
|
|||||||
).toEqual(["user-2"]);
|
).toEqual(["user-2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears only background routine plugin entries", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
const entry = (userId: string | null, action: string) =>
|
||||||
|
repo.create({
|
||||||
|
userId,
|
||||||
|
username: "plugin:host-metrics",
|
||||||
|
action,
|
||||||
|
resourceType: "plugin",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await entry(null, "plugin_db_client");
|
||||||
|
await entry(null, "plugin_kv_get");
|
||||||
|
await entry(null, "plugin_kv_set");
|
||||||
|
await entry("user-1", "plugin_db_client");
|
||||||
|
await entry(null, "plugin_as_user");
|
||||||
|
writeCount = 0;
|
||||||
|
|
||||||
|
expect(await repo.deleteRoutinePluginNoise()).toBe(2);
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
const left = (
|
||||||
|
await repo.listPage({ filters: {}, limit: 10, offset: 0 })
|
||||||
|
).logs.map((log) => `${log.userId}:${log.action}`);
|
||||||
|
expect(left.sort()).toEqual([
|
||||||
|
"null:plugin_as_user",
|
||||||
|
"null:plugin_kv_set",
|
||||||
|
"user-1:plugin_db_client",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await repo.deleteRoutinePluginNoise()).toBe(0);
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps entries when their user is deleted, detaching instead of removing", async () => {
|
it("keeps entries when their user is deleted, detaching instead of removing", async () => {
|
||||||
const repo = await createRepository();
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.mock("../../../database/db/index.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
createCurrentAuditLogRepository,
|
||||||
createCurrentSessionRepository,
|
createCurrentSessionRepository,
|
||||||
createCurrentRepositoryContext,
|
createCurrentRepositoryContext,
|
||||||
createCurrentRepositoryWriteHook,
|
createCurrentRepositoryWriteHook,
|
||||||
@@ -119,3 +120,42 @@ it("wires SQLite activity to the periodic flush and token changes to immediate p
|
|||||||
else process.env[DATABASE_DIALECT_ENV] = saved;
|
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit rows land on every audited call; a forced save per row rewrote the
|
||||||
|
// whole SQLite file each time.
|
||||||
|
it("saves SQLite audit rows lazily instead of forcing a save per row", async () => {
|
||||||
|
const saved = process.env[DATABASE_DIALECT_ENV];
|
||||||
|
process.env[DATABASE_DIALECT_ENV] = "sqlite";
|
||||||
|
const { getDb } = await import("../../../database/db/index.js");
|
||||||
|
const { DatabaseSaveTrigger } =
|
||||||
|
await import("../../../utils/database-save-trigger.js");
|
||||||
|
const lazy = vi.spyOn(DatabaseSaveTrigger, "triggerSave").mockResolvedValue();
|
||||||
|
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
|
||||||
|
const query = {
|
||||||
|
insert: () => query,
|
||||||
|
values: async () => ({ changes: 1 }),
|
||||||
|
select: () => query,
|
||||||
|
from: async () => [{ count: 1 }],
|
||||||
|
};
|
||||||
|
vi.mocked(getDb).mockReturnValue(
|
||||||
|
query as unknown as ReturnType<typeof getDb>,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const repo = createCurrentAuditLogRepository();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
await repo.create({
|
||||||
|
userId: null,
|
||||||
|
username: "plugin:host-metrics",
|
||||||
|
action: "plugin_db_client",
|
||||||
|
resourceType: "plugin",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(force).not.toHaveBeenCalled();
|
||||||
|
expect(lazy).toHaveBeenCalledTimes(100);
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
|
||||||
|
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -212,6 +212,48 @@ describe("ctx.db capability", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every audit row rewrote the whole SQLite file, and pollers fetch the
|
||||||
|
// client before every query (2.9.1 wrote 100 GB in hours).
|
||||||
|
describe("ctx audit volume", () => {
|
||||||
|
it("audits the client handle once however often it is fetched", async () => {
|
||||||
|
grants.set("demo", ["db:own"]);
|
||||||
|
const { ctx } = contextFor("demo", ["db:own"]);
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) await ctx.db.client();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
auditEntries.filter((row) => row.action === "plugin_db_client"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audits kv reads once but every kv write", async () => {
|
||||||
|
grants.set("demo", ["kv:own"]);
|
||||||
|
const { ctx } = contextFor("demo", ["kv:own"]);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) await ctx.kv.get("k");
|
||||||
|
for (let i = 0; i < 3; i++) await ctx.kv.set("k", i);
|
||||||
|
|
||||||
|
const actions = auditEntries.map((row) => row.action);
|
||||||
|
expect(actions.filter((a) => a === "plugin_kv_get")).toHaveLength(1);
|
||||||
|
expect(actions.filter((a) => a === "plugin_kv_set")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still audits a refusal after the first success", async () => {
|
||||||
|
grants.set("demo", ["db:own"]);
|
||||||
|
const { ctx } = contextFor("demo", ["db:own"]);
|
||||||
|
await ctx.db.client();
|
||||||
|
|
||||||
|
grants.set("demo", []);
|
||||||
|
invalidatePluginPermissionCache("demo");
|
||||||
|
await expect(ctx.db.client()).rejects.toThrow();
|
||||||
|
|
||||||
|
const entries = auditEntries.filter(
|
||||||
|
(row) => row.action === "plugin_db_client",
|
||||||
|
);
|
||||||
|
expect(entries.map((row) => row.success)).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ctx.kv limits", () => {
|
describe("ctx.kv limits", () => {
|
||||||
it("refuses a key over the length cap", async () => {
|
it("refuses a key over the length cap", async () => {
|
||||||
grants.set("demo", ["kv:own"]);
|
grants.set("demo", ["kv:own"]);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const deleteRoutinePluginNoise = vi.fn<() => Promise<number>>();
|
||||||
|
const forceSave = vi.fn<(reason: string) => Promise<void>>();
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentAuditLogRepository: () => ({ deleteRoutinePluginNoise }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||||
|
DatabaseSaveTrigger: { forceSave },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { runAuditNoiseCleanup } =
|
||||||
|
await import("../../upgrade/audit-noise-cleanup.js");
|
||||||
|
|
||||||
|
describe("runAuditNoiseCleanup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteRoutinePluginNoise.mockReset();
|
||||||
|
forceSave.mockReset().mockResolvedValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves once after removing entries", async () => {
|
||||||
|
deleteRoutinePluginNoise.mockResolvedValue(9000);
|
||||||
|
|
||||||
|
await expect(runAuditNoiseCleanup()).resolves.toBe(9000);
|
||||||
|
expect(forceSave).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save when there was nothing to remove", async () => {
|
||||||
|
deleteRoutinePluginNoise.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await expect(runAuditNoiseCleanup()).resolves.toBe(0);
|
||||||
|
expect(forceSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never fails the boot", async () => {
|
||||||
|
deleteRoutinePluginNoise.mockRejectedValue(new Error("locked"));
|
||||||
|
|
||||||
|
await expect(runAuditNoiseCleanup()).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,7 +71,21 @@ describe("DatabaseSaveTrigger", () => {
|
|||||||
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves within the max wait even while writes keep coming", async () => {
|
it("saves at most once per 30 seconds while writes keep coming", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
// One sample every 3 seconds used to rewrite the database after each one.
|
||||||
|
for (let tick = 0; tick < 40; tick++) {
|
||||||
|
void DatabaseSaveTrigger.triggerSave("sample");
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
}
|
||||||
|
// 120 seconds: the first save after 2s, then one per 30s window.
|
||||||
|
expect(save.mock.calls.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(save.mock.calls.length).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still saves a steady stream of writes", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const save = vi.fn().mockResolvedValue(undefined);
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
DatabaseSaveTrigger.initialize(save);
|
DatabaseSaveTrigger.initialize(save);
|
||||||
@@ -79,7 +93,38 @@ describe("DatabaseSaveTrigger", () => {
|
|||||||
void DatabaseSaveTrigger.triggerSave("sample");
|
void DatabaseSaveTrigger.triggerSave("sample");
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
}
|
}
|
||||||
|
expect(save.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(save.mock.calls.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a lone write after a quiet period within the debounce", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
void DatabaseSaveTrigger.triggerSave("first");
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
expect(save).toHaveBeenCalledTimes(1);
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
void DatabaseSaveTrigger.triggerSave("later");
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a force save be throttled", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
DatabaseSaveTrigger.initialize(save);
|
||||||
|
void DatabaseSaveTrigger.triggerSave("sample");
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
// Inside the 30s window, but a critical write saves right away.
|
||||||
|
vi.useRealTimers();
|
||||||
|
await DatabaseSaveTrigger.forceSave("user_create");
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("queues a force save behind an in-flight save", async () => {
|
it("queues a force save behind an in-flight save", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Clears the audit rows 2.9.1 wrote on every background plugin query.
|
||||||
|
*
|
||||||
|
* They filled the capped audit log and pushed real entries out. Idempotent,
|
||||||
|
* so it runs on every boot, before plugins activate and audit again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { databaseLogger } from "../utils/logger.js";
|
||||||
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
|
import { createCurrentAuditLogRepository } from "../database/repositories/factory.js";
|
||||||
|
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||||
|
|
||||||
|
export async function runAuditNoiseCleanup(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const removed =
|
||||||
|
await createCurrentAuditLogRepository().deleteRoutinePluginNoise();
|
||||||
|
if (removed > 0) {
|
||||||
|
await DatabaseSaveTrigger.forceSave("audit_noise_cleanup");
|
||||||
|
databaseLogger.info("Removed routine plugin audit entries", {
|
||||||
|
operation: "audit_noise_cleanup",
|
||||||
|
removed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to remove routine plugin audit entries", {
|
||||||
|
operation: "audit_noise_cleanup_failed",
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { getErrorMessage } from "./error-message.js";
|
|||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
const DEBOUNCE_MS = 2000;
|
const DEBOUNCE_MS = 2000;
|
||||||
const MAX_DEBOUNCE_WAIT_MS = 30_000;
|
// Every save rewrites the whole database file, so background writes (metrics
|
||||||
|
// samples, audit rows) may only cause one save per this window.
|
||||||
|
const MIN_LAZY_SAVE_GAP_MS = 30_000;
|
||||||
|
|
||||||
export class DatabaseSaveTrigger {
|
export class DatabaseSaveTrigger {
|
||||||
private static saveFunction: (() => Promise<void>) | null = null;
|
private static saveFunction: (() => Promise<void>) | null = null;
|
||||||
@@ -11,7 +13,7 @@ export class DatabaseSaveTrigger {
|
|||||||
private static pendingSave = false;
|
private static pendingSave = false;
|
||||||
private static activeSave: Promise<void> | null = null;
|
private static activeSave: Promise<void> | null = null;
|
||||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||||
private static firstTriggerAt: number | null = null;
|
private static lastSaveAt = 0;
|
||||||
private static _dirty = false;
|
private static _dirty = false;
|
||||||
private static batch = new AsyncLocalStorage<{
|
private static batch = new AsyncLocalStorage<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -49,22 +51,16 @@ export class DatabaseSaveTrigger {
|
|||||||
|
|
||||||
this._dirty = true;
|
this._dirty = true;
|
||||||
|
|
||||||
if (this.saveTimeout) {
|
// A save already pending picks this write up too.
|
||||||
clearTimeout(this.saveTimeout);
|
if (this.saveTimeout) return;
|
||||||
}
|
|
||||||
|
|
||||||
// Each call pushes the save back, so steady writes (samples every few
|
|
||||||
// seconds) would keep it from ever running. Cap how long it can wait.
|
|
||||||
const now = Date.now();
|
|
||||||
this.firstTriggerAt ??= now;
|
|
||||||
const delay = Math.max(
|
const delay = Math.max(
|
||||||
0,
|
DEBOUNCE_MS,
|
||||||
Math.min(DEBOUNCE_MS, this.firstTriggerAt + MAX_DEBOUNCE_WAIT_MS - now),
|
this.lastSaveAt + MIN_LAZY_SAVE_GAP_MS - Date.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.saveTimeout = setTimeout(async () => {
|
this.saveTimeout = setTimeout(async () => {
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
this.firstTriggerAt = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runSave();
|
await this.runSave();
|
||||||
@@ -123,7 +119,6 @@ export class DatabaseSaveTrigger {
|
|||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
}
|
}
|
||||||
this.firstTriggerAt = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runSave();
|
await this.runSave();
|
||||||
@@ -156,6 +151,7 @@ export class DatabaseSaveTrigger {
|
|||||||
.finally(() => new Promise<void>((resolve) => setImmediate(resolve)));
|
.finally(() => new Promise<void>((resolve) => setImmediate(resolve)));
|
||||||
this.activeSave = save;
|
this.activeSave = save;
|
||||||
this.pendingSave = true;
|
this.pendingSave = true;
|
||||||
|
this.lastSaveAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await save;
|
await save;
|
||||||
@@ -184,7 +180,7 @@ export class DatabaseSaveTrigger {
|
|||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
}
|
}
|
||||||
this.firstTriggerAt = null;
|
this.lastSaveAt = 0;
|
||||||
|
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
this.activeSave = null;
|
this.activeSave = null;
|
||||||
|
|||||||
Reference in new issue
Block a user