Files
MikrotikManager/backend/src/services/server-poll-storage-model.ts
T

142 lines
8.9 KiB
TypeScript

/** Pure poll-journal accounting, not MAC/terminal authority. Full reservations
* include unresolved work; compact history continues paying bytes and rows.
* Bytes are logical charges, never a claim about PostgreSQL relation size. */
const ATTEMPT_BYTES = 8 * 1024 * 1024
const ADMISSION_BYTES = 32768
const ATTEMPT_ROWS = 4019 // anchor + 2008 intents + 2008 acks + receipt + quarantine
const DAY_MS = 86400000
const DATE_MAX = 8640000000000000
export interface PollStoragePolicy {
attemptBytes: number
admissionBytes: number
attemptRows: number
globalAttempts: number
ownerAttempts: number
globalBytes: number
ownerBytes: number
globalRows: number
ownerRows: number
retentionDays: number
safetyWindowMs: number
}
export const SERVER_POLL_STORAGE_POLICY: Readonly<PollStoragePolicy> = Object.freeze({
attemptBytes: ATTEMPT_BYTES, admissionBytes: ADMISSION_BYTES, attemptRows: ATTEMPT_ROWS,
globalAttempts: 128, ownerAttempts: 8,
globalBytes: 128 * (ATTEMPT_BYTES + ADMISSION_BYTES), ownerBytes: 8 * (ATTEMPT_BYTES + ADMISSION_BYTES),
globalRows: 128 * ATTEMPT_ROWS, ownerRows: 8 * ATTEMPT_ROWS,
retentionDays: 14, safetyWindowMs: 60000,
})
export class PollStorageRefused extends Error {
readonly automaticReplay=false
constructor() { super("Poll storage state unavailable"); this.name = "PollStorageRefused" }
}
function refuse(): never { throw new PollStorageRefused() }
const integer = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value,-0)
const timestamp = (value: unknown): value is number => integer(value) && value <= DATE_MAX
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
const days = (value: unknown): value is number => integer(value) && value >= 1 && value <= 365
const uuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
const hash = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)
/** Trusted composition only. Fixed reservations/caps cannot be weakened by a
* caller; only retention and its safety window are configurable. */
export function pollStoragePolicy(raw: unknown = SERVER_POLL_STORAGE_POLICY): Readonly<PollStoragePolicy> {
if (!record(raw) || Object.keys(raw).some(key => !Object.hasOwn(SERVER_POLL_STORAGE_POLICY, key))) refuse()
for (const key of Object.keys(SERVER_POLL_STORAGE_POLICY) as (keyof PollStoragePolicy)[]) {
if (key !== "retentionDays" && key !== "safetyWindowMs" && raw[key] !== SERVER_POLL_STORAGE_POLICY[key]) refuse()
}
if (!days(raw.retentionDays) || !integer(raw.safetyWindowMs) || raw.safetyWindowMs < 60000 || raw.safetyWindowMs > DAY_MS) refuse()
return Object.freeze({ ...SERVER_POLL_STORAGE_POLICY, retentionDays: raw.retentionDays, safetyWindowMs: raw.safetyWindowMs })
}
export interface PollStorageScope {
receiptId: string
kind: "rest" | "mcp"
executionId: string
ownerId: string
bindingProof: string
contextProof: string
issuedAt: number
expiresAt: number
}
/** Expired scope parsing is exclusively for retained metadata, never a claim. */
export function pollStorageScope(raw: unknown, options: { now: number; allowExpired?: boolean }): Readonly<PollStorageScope> {
if (!record(raw) || !timestamp(options.now) || options.allowExpired !== undefined && typeof options.allowExpired !== "boolean"
|| Object.keys(raw).some(key => !["receiptId", "kind", "executionId", "ownerId", "bindingProof", "contextProof", "issuedAt", "expiresAt"].includes(key))
|| !uuid(raw.receiptId) || !uuid(raw.executionId) || raw.kind !== "rest" && raw.kind !== "mcp"
|| typeof raw.ownerId !== "string" || !raw.ownerId.length || raw.ownerId.length > 200 || raw.ownerId.trim() !== raw.ownerId || /[\x00-\x1f\x7f-\x9f]/.test(raw.ownerId)
|| !hash(raw.bindingProof) || !hash(raw.contextProof) || !timestamp(raw.issuedAt) || !timestamp(raw.expiresAt)
|| raw.expiresAt <= raw.issuedAt || raw.issuedAt > options.now || !options.allowExpired && options.now >= raw.expiresAt) refuse()
return Object.freeze({ receiptId: raw.receiptId, kind: raw.kind, executionId: raw.executionId, ownerId: raw.ownerId,
bindingProof: raw.bindingProof, contextProof: raw.contextProof, issuedAt: raw.issuedAt, expiresAt: raw.expiresAt })
}
export interface PollStorageUsage { attempts: number; bytes: number; rows: number; compactedAttempts?: number; archiveBytes?: number }
function usage(raw: PollStorageUsage) {
if (!record(raw)) refuse()
const compact = Object.hasOwn(raw,"compactedAttempts"), archive = Object.hasOwn(raw,"archiveBytes")
const keys = Object.keys(raw).sort().join(",")
if (compact !== archive || keys !== (compact ? "archiveBytes,attempts,bytes,compactedAttempts,rows" : "attempts,bytes,rows")) refuse()
const count = compact ? raw.compactedAttempts : 0, encoded = archive ? raw.archiveBytes : 0
if (!integer(raw.attempts) || !integer(raw.bytes) || !integer(raw.rows) || !integer(count) || !integer(encoded)
|| encoded < count * 40 || encoded > count * 131072) refuse()
const expectedRows = raw.attempts * ATTEMPT_ROWS + count * 2
const expectedBytes = raw.attempts * (ATTEMPT_BYTES + ADMISSION_BYTES) + count * ADMISSION_BYTES + encoded
if (!integer(expectedRows) || !integer(expectedBytes) || raw.rows !== expectedRows || raw.bytes !== expectedBytes) refuse()
return { ...raw, compactedAttempts:count, archiveBytes:encoded }
}
/** Available NEW full reservations under every unchanged cap. SQL aggregate
* equality and all retained charge MACs must be checked by the admission owner;
* these arithmetic checks cannot authenticate compaction or free UNKNOWN. */
export function pollStorageUsageRemaining(raw: PollStorageUsage, scope: "global"|"owner", rawPolicy:PollStoragePolicy=SERVER_POLL_STORAGE_POLICY):number|undefined {
try {
const policy=pollStoragePolicy(rawPolicy), value=usage(raw)
if(scope!=="global"&&scope!=="owner")refuse()
const attempts=scope==="global"?policy.globalAttempts:policy.ownerAttempts
const bytes=scope==="global"?policy.globalBytes:policy.ownerBytes, rows=scope==="global"?policy.globalRows:policy.ownerRows
if(value.attempts>attempts||value.bytes>bytes||value.rows>rows)refuse()
return Math.min(attempts-value.attempts,Math.floor((bytes-value.bytes)/(policy.attemptBytes+policy.admissionBytes)),Math.floor((rows-value.rows)/policy.attemptRows))
}catch{return undefined}
}
/** Exact counters, including full reservations and paid retained archives. */
export function pollStorageQuotaFit(globalUsage: PollStorageUsage, ownerUsage: PollStorageUsage,
rawPolicy: PollStoragePolicy = SERVER_POLL_STORAGE_POLICY): boolean {
try {
const policy = pollStoragePolicy(rawPolicy)
const global=usage(globalUsage),owner=usage(ownerUsage)
return (pollStorageUsageRemaining(global,"global",policy)??0)>0 && (pollStorageUsageRemaining(owner,"owner",policy)??0)>0
&& owner.attempts<=global.attempts && owner.bytes<=global.bytes && owner.rows<=global.rows
&& owner.compactedAttempts<=global.compactedAttempts && owner.archiveBytes<=global.archiveBytes
&& global.archiveBytes-owner.archiveBytes >= (global.compactedAttempts-owner.compactedAttempts)*40
&& global.archiveBytes-owner.archiveBytes <= (global.compactedAttempts-owner.compactedAttempts)*131072
} catch { return false }
}
export interface PollStorageGcCandidate {
scope: PollStorageScope
lastActivityAt: number
gcNotBefore: number
retentionDays: number
safetyWindowMs: number
/** Caller must verify the actual operation, never infer terminality from a receipt. */
mcpTerminal: boolean
}
/** No forced-delete branch: malformed/overflowed dates and live MCP operations
* remain reserved. Increasing retention applies to already persisted attempts. */
export function pollStorageGcEligible(candidate: PollStorageGcCandidate, rawPolicy: PollStoragePolicy, now: number): boolean {
try {
const policy = pollStoragePolicy(rawPolicy)
if (!record(candidate) || !timestamp(now)) return false
const scope = pollStorageScope(candidate.scope, { now, allowExpired: true })
if (!timestamp(candidate.lastActivityAt) || candidate.lastActivityAt < scope.issuedAt || candidate.lastActivityAt > now
|| !timestamp(candidate.gcNotBefore) || !days(candidate.retentionDays) || !integer(candidate.safetyWindowMs)
|| candidate.safetyWindowMs < 60000 || candidate.safetyWindowMs > DAY_MS || typeof candidate.mcpTerminal !== "boolean"
|| scope.kind === "mcp" && !candidate.mcpTerminal) return false
const retainedUntil = Math.max(scope.expiresAt, candidate.lastActivityAt)
+ Math.max(policy.retentionDays, candidate.retentionDays) * DAY_MS + Math.max(policy.safetyWindowMs, candidate.safetyWindowMs)
return timestamp(retainedUntil) && now > Math.max(candidate.gcNotBefore, retainedUntil)
} catch { return false }
}