179 lines
5.3 KiB
TypeScript
179 lines
5.3 KiB
TypeScript
import { computed, ref } from "vue";
|
|
import { defineStore } from "pinia";
|
|
import * as api from "@/lib/backend/api";
|
|
|
|
export const WRITE_UNLOCK_ONE_MINUTE_SECS = 60;
|
|
export const WRITE_UNLOCK_FIVE_MINUTES_SECS = 300;
|
|
export type WriteUnlockDurationSecs = typeof WRITE_UNLOCK_ONE_MINUTE_SECS | typeof WRITE_UNLOCK_FIVE_MINUTES_SECS;
|
|
|
|
export interface ReadOnlyUnlockRequest {
|
|
connectionId: string;
|
|
connectionName?: string;
|
|
sql?: string;
|
|
source?: string;
|
|
}
|
|
|
|
interface QueuedUnlockRequest {
|
|
request: ReadOnlyUnlockRequest;
|
|
resolve: (confirmed: boolean) => void;
|
|
}
|
|
|
|
function applyRemaining(windows: Record<string, number>, connectionId: string, remainingMs: number, now = Date.now()): Record<string, number> {
|
|
const next = { ...windows };
|
|
if (remainingMs > 0) next[connectionId] = now + remainingMs;
|
|
else delete next[connectionId];
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Coordinates the read-only write-unlock dialog and per-connection countdown.
|
|
* Expiry is compared against wall-clock `Date.now()` so backgrounded tabs still
|
|
* expire; the backend Instant gate remains authoritative for actual writes.
|
|
*/
|
|
export const useReadOnlyUnlockStore = defineStore("readOnlyUnlock", () => {
|
|
const pending = ref<ReadOnlyUnlockRequest>();
|
|
const windows = ref<Record<string, number>>({});
|
|
const nowMs = ref(Date.now());
|
|
const queue: QueuedUnlockRequest[] = [];
|
|
let resolvePending: ((confirmed: boolean) => void) | undefined;
|
|
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
let unlockInFlight = false;
|
|
|
|
function touchNow() {
|
|
nowMs.value = Date.now();
|
|
}
|
|
|
|
function pruneExpired(now = Date.now()) {
|
|
const next: Record<string, number> = {};
|
|
for (const [connectionId, expiresAt] of Object.entries(windows.value)) {
|
|
if (expiresAt > now) next[connectionId] = expiresAt;
|
|
}
|
|
windows.value = next;
|
|
if (!Object.keys(next).length) stopTicker();
|
|
}
|
|
|
|
function startTicker() {
|
|
if (ticker !== undefined) return;
|
|
ticker = setInterval(() => {
|
|
touchNow();
|
|
pruneExpired();
|
|
}, 1000);
|
|
}
|
|
|
|
function stopTicker() {
|
|
if (ticker === undefined) return;
|
|
clearInterval(ticker);
|
|
ticker = undefined;
|
|
}
|
|
|
|
// Always compare against the wall clock at call time: the ticker-fed `nowMs`
|
|
// goes stale in throttled background tabs and would keep expired windows live.
|
|
function remainingMs(connectionId: string, now = Date.now()): number {
|
|
return Math.max(0, (windows.value[connectionId] ?? 0) - now);
|
|
}
|
|
|
|
function isUnlocked(connectionId: string, now = Date.now()): boolean {
|
|
return remainingMs(connectionId, now) > 0;
|
|
}
|
|
|
|
function rememberWindow(connectionId: string, remaining: number) {
|
|
windows.value = applyRemaining(windows.value, connectionId, remaining);
|
|
touchNow();
|
|
if (Object.keys(windows.value).length) startTicker();
|
|
else stopTicker();
|
|
}
|
|
|
|
async function refreshFromBackend(connectionId: string): Promise<number> {
|
|
const remaining = await api.connectionWriteUnlockState(connectionId);
|
|
rememberWindow(connectionId, remaining);
|
|
return remaining;
|
|
}
|
|
|
|
async function requestUnlock(request: ReadOnlyUnlockRequest): Promise<boolean> {
|
|
if (isUnlocked(request.connectionId)) return true;
|
|
try {
|
|
const remaining = await refreshFromBackend(request.connectionId);
|
|
if (remaining > 0) return true;
|
|
} catch {
|
|
// Backend remains the execution gate; continue to the confirm dialog.
|
|
}
|
|
return new Promise<boolean>((resolve) => {
|
|
if (pending.value) {
|
|
queue.push({ request, resolve });
|
|
return;
|
|
}
|
|
beginRequest(request, resolve);
|
|
});
|
|
}
|
|
|
|
function beginRequest(request: ReadOnlyUnlockRequest, resolve: (confirmed: boolean) => void) {
|
|
pending.value = request;
|
|
resolvePending = resolve;
|
|
}
|
|
|
|
function settle(confirmed: boolean) {
|
|
const resolve = resolvePending;
|
|
resolvePending = undefined;
|
|
pending.value = undefined;
|
|
resolve?.(confirmed);
|
|
|
|
const next = queue.shift();
|
|
if (next) {
|
|
if (confirmed && isUnlocked(next.request.connectionId)) next.resolve(true);
|
|
else beginRequest(next.request, next.resolve);
|
|
}
|
|
}
|
|
|
|
async function confirm(durationSecs: WriteUnlockDurationSecs) {
|
|
const request = pending.value;
|
|
if (!request || unlockInFlight) return;
|
|
unlockInFlight = true;
|
|
try {
|
|
const remaining = await api.unlockConnectionWrites(request.connectionId, durationSecs);
|
|
rememberWindow(request.connectionId, remaining);
|
|
settle(true);
|
|
} catch {
|
|
// Keep the dialog open so the user can retry or cancel.
|
|
} finally {
|
|
unlockInFlight = false;
|
|
}
|
|
}
|
|
|
|
function cancel() {
|
|
settle(false);
|
|
}
|
|
|
|
async function lockNow(connectionId: string) {
|
|
await api.lockConnectionWrites(connectionId);
|
|
rememberWindow(connectionId, 0);
|
|
}
|
|
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("visibilitychange", () => {
|
|
touchNow();
|
|
pruneExpired();
|
|
if (document.visibilityState !== "visible") return;
|
|
for (const connectionId of Object.keys(windows.value)) {
|
|
void refreshFromBackend(connectionId);
|
|
}
|
|
});
|
|
}
|
|
|
|
const unlockedConnectionIds = computed(() => Object.keys(windows.value).filter((id) => isUnlocked(id)));
|
|
|
|
return {
|
|
pending,
|
|
windows,
|
|
nowMs,
|
|
unlockedConnectionIds,
|
|
remainingMs,
|
|
isUnlocked,
|
|
requestUnlock,
|
|
confirm,
|
|
cancel,
|
|
lockNow,
|
|
refreshFromBackend,
|
|
rememberWindow,
|
|
};
|
|
});
|