fieldlog 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/quota.ts ADDED
@@ -0,0 +1,122 @@
1
+ // quota.ts — skill-15 quota-guard port for fieldlog.
2
+ //
3
+ // Admission control over on-disk usage. A guard tracks a byte ceiling and a
4
+ // set of files whose size counts as usage; callers `reserve(n)` before
5
+ // growing state (append, snapshot, sync spool) and `release(n)` when the
6
+ // growth is dropped. Every decision re-measures the files, so growth behind
7
+ // the guard's back is caught, not trusted.
8
+ //
9
+ // Fail-closed: usage that cannot be measured denies admission. A stat
10
+ // failure other than "missing file" throws ERR_QUOTA_UNKNOWN and every
11
+ // admission check (`reserve`, `check`, `remaining`) propagates it — the
12
+ // guard never reports "space available" it cannot prove. Over-quota throws
13
+ // ERR_QUOTA_EXCEEDED carrying the measured numbers. Bad inputs throw
14
+ // ERR_QUOTA_INVALID. Standalone module: no kernel wiring (kernel keeps its
15
+ // own ERR_OUTBOX_FULL outbox cap in src/kernel.ts).
16
+ import { statSync } from 'node:fs';
17
+
18
+ export interface QuotaOpts {
19
+ /** Hard ceiling in bytes; must be a positive integer. */
20
+ limitBytes: number;
21
+ /** Files whose on-disk size counts as usage. Missing files count 0. The
22
+ * list is copied at open: mutating the caller's array afterwards, or
23
+ * adding new state files, does not change what the guard measures —
24
+ * open a new guard when the measured set changes. */
25
+ files?: string[];
26
+ }
27
+
28
+ export interface QuotaStatus {
29
+ limit: number;
30
+ used: number;
31
+ reserved: number;
32
+ remaining: number;
33
+ }
34
+
35
+ export interface QuotaGuard {
36
+ readonly limit: number;
37
+ /** Measured on-disk bytes of `files`. Throws ERR_QUOTA_UNKNOWN when unmeasurable. */
38
+ usage(): number;
39
+ /** Bytes currently held via `reserve` and not yet released. */
40
+ held(): number;
41
+ /** `max(0, limit - used - held)`: never negative, so over-quota reality
42
+ * reads as zero headroom (with `check()` still throwing to deny). Throws
43
+ * ERR_QUOTA_UNKNOWN when unmeasurable. */
44
+ remaining(): number;
45
+ /** Hold `bytes`; throws ERR_QUOTA_EXCEEDED / ERR_QUOTA_UNKNOWN / ERR_QUOTA_INVALID. */
46
+ reserve(bytes: number): void;
47
+ /** Free `bytes` previously held; clamps at zero, never throws for
48
+ * over-release. An unbalanced release (freeing more than held, or twice)
49
+ * is silently absorbed — held just pins at zero — so pair every reserve
50
+ * with exactly one release and treat a zero-held release as a caller bug
51
+ * the guard will not flag for you. */
52
+ release(bytes: number): void;
53
+ /** Throw ERR_QUOTA_EXCEEDED if measured + held exceeds the ceiling right now. */
54
+ check(): void;
55
+ /** Full snapshot; throws ERR_QUOTA_UNKNOWN when unmeasurable. */
56
+ status(): QuotaStatus;
57
+ }
58
+
59
+ function invalid(what: string, got: unknown): Error {
60
+ return new Error(`ERR_QUOTA_INVALID: ${what} must be a positive integer (got ${String(got)})`);
61
+ }
62
+
63
+ /** Sum file sizes. ENOENT = 0 (fresh path, provably empty); any other stat
64
+ * failure = ERR_QUOTA_UNKNOWN (fail-closed: unknown usage denies). */
65
+ function measure(files: string[]): number {
66
+ let used = 0;
67
+ for (const f of files) {
68
+ try {
69
+ used += statSync(f).size;
70
+ } catch (e) {
71
+ if (e !== null && typeof e === 'object' && (e as { code?: unknown }).code === 'ENOENT') continue;
72
+ throw new Error(`ERR_QUOTA_UNKNOWN: cannot measure '${f}' (fail-closed, refusing admission)`);
73
+ }
74
+ }
75
+ return used;
76
+ }
77
+
78
+ function checkBytes(bytes: number, what: string): void {
79
+ if (!Number.isInteger(bytes) || bytes < 1) throw invalid(what, bytes);
80
+ }
81
+
82
+ export function openQuotaGuard(opts: QuotaOpts): QuotaGuard {
83
+ if (!opts || !Number.isInteger(opts.limitBytes) || opts.limitBytes < 1) {
84
+ throw invalid('limitBytes', opts?.limitBytes);
85
+ }
86
+ const limit = opts.limitBytes;
87
+ const files = [...(opts.files ?? [])];
88
+ let held = 0;
89
+
90
+ const usage = (): number => measure(files);
91
+
92
+ const deny = (used: number, want: number): Error =>
93
+ new Error(
94
+ `ERR_QUOTA_EXCEEDED: used ${used} + held ${held} + want ${want} exceeds limit ${limit}; ` +
95
+ `release reservations or raise the ceiling before growing state`,
96
+ );
97
+
98
+ return {
99
+ limit,
100
+ usage,
101
+ held: () => held,
102
+ remaining: () => Math.max(0, limit - usage() - held),
103
+ reserve: (bytes: number): void => {
104
+ checkBytes(bytes, 'reserve bytes');
105
+ const used = usage(); // throws ERR_QUOTA_UNKNOWN: unmeasurable denies
106
+ if (used + held + bytes > limit) throw deny(used, bytes);
107
+ held += bytes;
108
+ },
109
+ release: (bytes: number): void => {
110
+ checkBytes(bytes, 'release bytes');
111
+ held = Math.max(0, held - bytes);
112
+ },
113
+ check: (): void => {
114
+ const used = usage(); // throws ERR_QUOTA_UNKNOWN: unmeasurable denies
115
+ if (used + held > limit) throw deny(used, 0);
116
+ },
117
+ status: (): QuotaStatus => {
118
+ const used = usage();
119
+ return { limit, used, reserved: held, remaining: Math.max(0, limit - used - held) };
120
+ },
121
+ };
122
+ }