@ceralive/modem-control 0.2.0 → 1.0.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.
@@ -12,6 +12,29 @@ export {
12
12
  } from './accounting';
13
13
  export { clampCycleDay, cycleStart, daysInMonth } from './billing-cycle';
14
14
  export { readBootId } from './boot-id';
15
+ export {
16
+ createUsagePolicyFileStore,
17
+ isValidCycleDay,
18
+ isValidThresholdBytes,
19
+ type PersistedUsagePolicy,
20
+ type PersistedUsagePolicySlot,
21
+ selectUsagePolicy,
22
+ USAGE_POLICY_SCHEMA_VERSION,
23
+ type UsagePolicyFileStoreOptions,
24
+ type UsagePolicyLogEvent,
25
+ type UsagePolicyLogger,
26
+ type UsagePolicyStore,
27
+ } from './policy-store';
28
+ export {
29
+ getUsagePolicy,
30
+ type SetUsagePolicyDeps,
31
+ type SetUsagePolicyRejection,
32
+ type SetUsagePolicyRequest,
33
+ type SetUsagePolicyResult,
34
+ setUsagePolicy,
35
+ type UsagePolicyApplication,
36
+ type UsagePolicyTarget,
37
+ } from './policy-write';
15
38
  export {
16
39
  type CounterSource,
17
40
  parseProcNetDev,
@@ -0,0 +1,164 @@
1
+ // Usage-policy persistence contract — mode 0600 (real fs.stat), fail-soft
2
+ // corruption recovery with METADATA-ONLY logging, and the per-slot selector.
3
+
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import {
9
+ createUsagePolicyFileStore,
10
+ isValidCycleDay,
11
+ isValidThresholdBytes,
12
+ type PersistedUsagePolicy,
13
+ selectUsagePolicy,
14
+ USAGE_POLICY_SCHEMA_VERSION,
15
+ type UsagePolicyLogEvent,
16
+ } from './policy-store';
17
+
18
+ let dir: string;
19
+ let path: string;
20
+
21
+ beforeEach(async () => {
22
+ dir = await mkdtemp(join(tmpdir(), 'usage-policy-'));
23
+ path = join(dir, 'policy.json');
24
+ });
25
+
26
+ afterEach(async () => {
27
+ await rm(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ const sample: PersistedUsagePolicy = {
31
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
32
+ savedAtMs: 1_700_000_000_000,
33
+ slots: [
34
+ { logicalSlotId: 'slot-a', cycleDay: 15, thresholdBytes: 5_000_000_000 },
35
+ { logicalSlotId: 'slot-b', cycleDay: 1 },
36
+ { logicalSlotId: 'slot-c', thresholdBytes: 0 },
37
+ ],
38
+ };
39
+
40
+ describe('UsagePolicyStore — versioned round-trip', () => {
41
+ test('save then load returns identical, schema-versioned state', async () => {
42
+ const store = createUsagePolicyFileStore({ path });
43
+ await store.save(sample);
44
+ expect(await store.load(1)).toEqual(sample);
45
+ });
46
+
47
+ test('an absent file loads as a fresh empty document and writes nothing', async () => {
48
+ const store = createUsagePolicyFileStore({ path });
49
+ const loaded = await store.load(4242);
50
+ expect(loaded).toEqual({
51
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
52
+ savedAtMs: 4242,
53
+ slots: [],
54
+ });
55
+ expect(readFile(path, 'utf8')).rejects.toThrow();
56
+ });
57
+
58
+ test('the written file is mode 0600 regardless of umask', async () => {
59
+ const store = createUsagePolicyFileStore({ path });
60
+ await store.save(sample);
61
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
62
+ });
63
+ });
64
+
65
+ describe('UsagePolicyStore — fail-soft corruption', () => {
66
+ test('invalid JSON is replaced by a fresh 0600 file and logged as METADATA ONLY', async () => {
67
+ const events: UsagePolicyLogEvent[] = [];
68
+ await writeFile(path, '{"schemaVersion":1,"slots":');
69
+ const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) });
70
+
71
+ const loaded = await store.load(7);
72
+
73
+ expect(loaded.slots).toEqual([]);
74
+ expect(events).toHaveLength(1);
75
+ expect(events[0]?.kind).toBe('corrupt-policy');
76
+ expect(events[0]?.reason).toContain('invalid-json');
77
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
78
+ });
79
+
80
+ test.each([
81
+ [
82
+ 'schemaVersion',
83
+ JSON.stringify({ ...sample, schemaVersion: 99 }),
84
+ 'schema-mismatch: schemaVersion',
85
+ ],
86
+ ['savedAtMs', JSON.stringify({ ...sample, savedAtMs: 'soon' }), 'schema-mismatch: savedAtMs'],
87
+ ['slots', JSON.stringify({ ...sample, slots: {} }), 'schema-mismatch: slots'],
88
+ [
89
+ 'logicalSlotId',
90
+ JSON.stringify({ ...sample, slots: [{ cycleDay: 3 }] }),
91
+ 'schema-mismatch: logicalSlotId',
92
+ ],
93
+ [
94
+ 'cycleDay',
95
+ JSON.stringify({ ...sample, slots: [{ logicalSlotId: 'a', cycleDay: 32 }] }),
96
+ 'schema-mismatch: cycleDay',
97
+ ],
98
+ [
99
+ 'thresholdBytes',
100
+ JSON.stringify({ ...sample, slots: [{ logicalSlotId: 'a', thresholdBytes: -1 }] }),
101
+ 'schema-mismatch: thresholdBytes',
102
+ ],
103
+ ])('a bad %s is rejected by name and never throws', async (_field, text, reason) => {
104
+ const events: UsagePolicyLogEvent[] = [];
105
+ await writeFile(path, text);
106
+ const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) });
107
+
108
+ expect((await store.load(9)).slots).toEqual([]);
109
+ expect(events[0]?.reason).toBe(reason);
110
+ });
111
+
112
+ test('the corruption log carries a byte count and NEVER the file content', async () => {
113
+ const secretish = JSON.stringify({ schemaVersion: 1, slots: 'iccid-8991101200003204514' });
114
+ const events: UsagePolicyLogEvent[] = [];
115
+ await writeFile(path, secretish);
116
+ const store = createUsagePolicyFileStore({ path, logger: (e) => events.push(e) });
117
+
118
+ await store.load(11);
119
+
120
+ expect(events[0]?.bytes).toBe(Buffer.byteLength(secretish, 'utf8'));
121
+ expect(JSON.stringify(events[0])).not.toContain('8991101200003204514');
122
+ });
123
+ });
124
+
125
+ describe('selectUsagePolicy', () => {
126
+ test('returns the slot policy, omitting fields the slot never set', () => {
127
+ expect(selectUsagePolicy(sample, 'slot-a')).toEqual({
128
+ cycleDay: 15,
129
+ thresholdBytes: 5_000_000_000,
130
+ });
131
+ expect(selectUsagePolicy(sample, 'slot-b')).toEqual({ cycleDay: 1 });
132
+ expect(selectUsagePolicy(sample, 'slot-c')).toEqual({ thresholdBytes: 0 });
133
+ });
134
+
135
+ test('an unknown slot answers "no policy set", not a default', () => {
136
+ expect(selectUsagePolicy(sample, 'nope')).toEqual({});
137
+ });
138
+ });
139
+
140
+ describe('validators', () => {
141
+ test.each([
142
+ [1, true],
143
+ [31, true],
144
+ [15, true],
145
+ [0, false],
146
+ [32, false],
147
+ [1.5, false],
148
+ [Number.NaN, false],
149
+ ['3', false],
150
+ ])('isValidCycleDay(%p) === %p', (value, expected) => {
151
+ expect(isValidCycleDay(value)).toBe(expected);
152
+ });
153
+
154
+ test.each([
155
+ [0, true],
156
+ [5_000_000_000, true],
157
+ [-1, false],
158
+ [1.5, false],
159
+ [Number.POSITIVE_INFINITY, false],
160
+ ['5', false],
161
+ ])('isValidThresholdBytes(%p) === %p', (value, expected) => {
162
+ expect(isValidThresholdBytes(value)).toBe(expected);
163
+ });
164
+ });
@@ -0,0 +1,216 @@
1
+ // Durable persistence for the operator's data-usage POLICY (cycle day + advisory
2
+ // threshold), the write-side counterpart of `store.ts`'s counter persistence.
3
+ //
4
+ // WHY THIS IS LOCAL STATE AND NOT A MODEM WRITE. ModemManager has no data-usage
5
+ // API at all. Verified against a live MM 1.24.2 (`mmcli --help-all`, plus a D-Bus
6
+ // introspection of a real `…/ModemManager1/Modem/N`): the only `Setup`/threshold
7
+ // surface on the whole object is `Modem.Signal.Setup` /
8
+ // `Modem.Signal.SetupThresholds`, whose keys are `rssi-threshold` and
9
+ // `error-rate-threshold` — RADIO QUALITY, not bytes. The only byte counters MM
10
+ // offers are the per-BEARER read-only `Stats` (`rx-bytes`/`tx-bytes`), which
11
+ // reset with every connection and therefore cannot carry a monthly cycle.
12
+ //
13
+ // That is exactly why the sampler in this directory counts `/proc/net/dev`
14
+ // instead, and why `ports/README.md`'s ownership table records usage policy as
15
+ // LOCAL-CONTROLLER owned. So the write path is a local, versioned, fail-soft
16
+ // file — never a D-Bus mutation. (`Modem.Signal.Setup` is additionally forbidden
17
+ // outright by the shadow-mode mutation-freedom contract; nothing here goes near
18
+ // it.)
19
+ //
20
+ // The two hard guarantees are the SAME ones `store.ts` makes, and deliberately
21
+ // implemented the same way so the pair can be read side by side:
22
+ // - MODE 0600 via temp → chmod → atomic rename, regardless of umask.
23
+ // - FAIL-SOFT ON CORRUPTION: an unparseable/incompatible file logs METADATA
24
+ // ONLY (byte length + a classification reason, never the content) and is
25
+ // replaced by a fresh empty 0600 file rather than throwing.
26
+ //
27
+ // A policy row carries ONLY an opaque slot id and two numbers. By construction
28
+ // there is no subscriber or device identity here (no ICCID/IMSI/IMEI, no
29
+ // operator, no model) — the same no-PII property the counter store holds.
30
+
31
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
32
+ import { dirname } from 'node:path';
33
+ import type { DesiredUsage } from '../../domain';
34
+
35
+ /** The current on-disk schema version. Bump when the persisted shape changes. */
36
+ export const USAGE_POLICY_SCHEMA_VERSION = 1;
37
+
38
+ /** One slot's persisted usage policy. Both fields are absent when unset. */
39
+ export interface PersistedUsagePolicySlot {
40
+ readonly logicalSlotId: string;
41
+ /** Day of month (1–31) the cycle resets; UTC, month-length clamped (A4.3). */
42
+ readonly cycleDay?: number;
43
+ /** Advisory threshold in bytes; crossing it raises an advisory, never gates. */
44
+ readonly thresholdBytes?: number;
45
+ }
46
+
47
+ /** The full persisted policy document. */
48
+ export interface PersistedUsagePolicy {
49
+ readonly schemaVersion: typeof USAGE_POLICY_SCHEMA_VERSION;
50
+ readonly savedAtMs: number;
51
+ readonly slots: readonly PersistedUsagePolicySlot[];
52
+ }
53
+
54
+ /** A metadata-only log event. Corruption NEVER carries the raw file content. */
55
+ export type UsagePolicyLogEvent = {
56
+ readonly kind: 'corrupt-policy';
57
+ readonly bytes: number;
58
+ readonly reason: string;
59
+ };
60
+
61
+ /** Sink for policy-store log events. Defaults to a metadata-only `console.warn`. */
62
+ export type UsagePolicyLogger = (event: UsagePolicyLogEvent) => void;
63
+
64
+ /** The persistence seam `setUsagePolicy` drives. */
65
+ export interface UsagePolicyStore {
66
+ /** Load persisted policy; recreate a fresh 0600 file if absent or corrupt. */
67
+ load(nowMs: number): Promise<PersistedUsagePolicy>;
68
+ /** Atomically write policy with mode 0600 (temp → chmod → rename). */
69
+ save(state: PersistedUsagePolicy): Promise<void>;
70
+ }
71
+
72
+ export interface UsagePolicyFileStoreOptions {
73
+ readonly path: string;
74
+ readonly logger?: UsagePolicyLogger;
75
+ }
76
+
77
+ function defaultLogger(event: UsagePolicyLogEvent): void {
78
+ console.warn(`[usage-policy] ${event.kind}: bytes=${event.bytes} reason=${event.reason}`);
79
+ }
80
+
81
+ function freshState(nowMs: number): PersistedUsagePolicy {
82
+ return { schemaVersion: USAGE_POLICY_SCHEMA_VERSION, savedAtMs: nowMs, slots: [] };
83
+ }
84
+
85
+ /** A schema violation naming only the offending FIELD (never file content). */
86
+ class PolicySchemaError extends Error {
87
+ constructor(field: string) {
88
+ super(`schema-mismatch: ${field}`);
89
+ }
90
+ }
91
+
92
+ /** True for a value that is a legal cycle day (integer 1–31). */
93
+ export function isValidCycleDay(value: unknown): value is number {
94
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 31;
95
+ }
96
+
97
+ /** True for a value that is a legal advisory threshold (non-negative integer). */
98
+ export function isValidThresholdBytes(value: unknown): value is number {
99
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
100
+ }
101
+
102
+ function validateSlot(raw: unknown): PersistedUsagePolicySlot {
103
+ if (typeof raw !== 'object' || raw === null) {
104
+ throw new PolicySchemaError('slot');
105
+ }
106
+ const slot = raw as Record<string, unknown>;
107
+ if (typeof slot.logicalSlotId !== 'string' || slot.logicalSlotId.length === 0) {
108
+ throw new PolicySchemaError('logicalSlotId');
109
+ }
110
+ if (slot.cycleDay !== undefined && !isValidCycleDay(slot.cycleDay)) {
111
+ throw new PolicySchemaError('cycleDay');
112
+ }
113
+ if (slot.thresholdBytes !== undefined && !isValidThresholdBytes(slot.thresholdBytes)) {
114
+ throw new PolicySchemaError('thresholdBytes');
115
+ }
116
+ return {
117
+ logicalSlotId: slot.logicalSlotId,
118
+ ...(slot.cycleDay !== undefined ? { cycleDay: slot.cycleDay } : {}),
119
+ ...(slot.thresholdBytes !== undefined ? { thresholdBytes: slot.thresholdBytes } : {}),
120
+ };
121
+ }
122
+
123
+ /** Parse + validate the document. Throws `PolicySchemaError` (metadata-only). */
124
+ function validate(raw: unknown): PersistedUsagePolicy {
125
+ if (typeof raw !== 'object' || raw === null) {
126
+ throw new PolicySchemaError('document');
127
+ }
128
+ const doc = raw as Record<string, unknown>;
129
+ if (doc.schemaVersion !== USAGE_POLICY_SCHEMA_VERSION) {
130
+ throw new PolicySchemaError('schemaVersion');
131
+ }
132
+ if (typeof doc.savedAtMs !== 'number' || !Number.isFinite(doc.savedAtMs)) {
133
+ throw new PolicySchemaError('savedAtMs');
134
+ }
135
+ if (!Array.isArray(doc.slots)) {
136
+ throw new PolicySchemaError('slots');
137
+ }
138
+ return {
139
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
140
+ savedAtMs: doc.savedAtMs,
141
+ slots: doc.slots.map(validateSlot),
142
+ };
143
+ }
144
+
145
+ /** Classify a load failure into a metadata-only reason string (no raw content). */
146
+ function classifyFailure(error: unknown): string {
147
+ if (error instanceof PolicySchemaError) {
148
+ return error.message;
149
+ }
150
+ if (error instanceof SyntaxError) {
151
+ const offset = /position (\d+)/.exec(error.message)?.[1];
152
+ return offset !== undefined ? `invalid-json at offset ${offset}` : 'invalid-json';
153
+ }
154
+ return 'unreadable';
155
+ }
156
+
157
+ /**
158
+ * Read one slot's policy out of a loaded document.
159
+ *
160
+ * This is the read half the composition root uses to build each slot's
161
+ * `UsageObservation.usage`, so the persisted file — not an in-memory guess — is
162
+ * what the sampler accounts against. An unknown slot answers `{}`, i.e. "no
163
+ * policy set", which is exactly what `defaultCellularPolicy` starts from.
164
+ */
165
+ export function selectUsagePolicy(
166
+ state: PersistedUsagePolicy,
167
+ logicalSlotId: string,
168
+ ): DesiredUsage {
169
+ const slot = state.slots.find((entry) => entry.logicalSlotId === logicalSlotId);
170
+ if (slot === undefined) {
171
+ return {};
172
+ }
173
+ return {
174
+ ...(slot.cycleDay !== undefined ? { cycleDay: slot.cycleDay } : {}),
175
+ ...(slot.thresholdBytes !== undefined ? { thresholdBytes: slot.thresholdBytes } : {}),
176
+ };
177
+ }
178
+
179
+ export function createUsagePolicyFileStore(options: UsagePolicyFileStoreOptions): UsagePolicyStore {
180
+ const logger = options.logger ?? defaultLogger;
181
+ const { path } = options;
182
+
183
+ async function writeAtomic(state: PersistedUsagePolicy): Promise<void> {
184
+ await mkdir(dirname(path), { recursive: true });
185
+ const tmp = `${path}.tmp`;
186
+ await writeFile(tmp, JSON.stringify(state));
187
+ // chmod AFTER the write (not an open flag) so mode is 0600 regardless of umask.
188
+ await chmod(tmp, 0o600);
189
+ await rename(tmp, path);
190
+ }
191
+
192
+ return {
193
+ async load(nowMs: number): Promise<PersistedUsagePolicy> {
194
+ let text: string;
195
+ try {
196
+ text = await readFile(path, 'utf8');
197
+ } catch {
198
+ // Absent (or unreadable) → start empty; the first save lays down a 0600 file.
199
+ return freshState(nowMs);
200
+ }
201
+ try {
202
+ return validate(JSON.parse(text));
203
+ } catch (error) {
204
+ logger({
205
+ kind: 'corrupt-policy',
206
+ bytes: Buffer.byteLength(text, 'utf8'),
207
+ reason: classifyFailure(error),
208
+ });
209
+ const fresh = freshState(nowMs);
210
+ await writeAtomic(fresh);
211
+ return fresh;
212
+ }
213
+ },
214
+ save: writeAtomic,
215
+ };
216
+ }
@@ -0,0 +1,198 @@
1
+ // `setUsagePolicy` contract — tri-state merge, typed refusals, persist-before-apply
2
+ // ordering, and the live-sampler cycle-reset rule.
3
+
4
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
5
+ import { mkdtemp, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import type { DesiredUsage } from '../../domain';
9
+ import {
10
+ createUsagePolicyFileStore,
11
+ type PersistedUsagePolicy,
12
+ selectUsagePolicy,
13
+ USAGE_POLICY_SCHEMA_VERSION,
14
+ type UsagePolicyStore,
15
+ } from './policy-store';
16
+ import { getUsagePolicy, setUsagePolicy, type UsagePolicyTarget } from './policy-write';
17
+
18
+ let dir: string;
19
+ let path: string;
20
+ let store: UsagePolicyStore;
21
+
22
+ const SLOT = 'slot-a';
23
+ const NOW = 1_700_000_000_000;
24
+
25
+ beforeEach(async () => {
26
+ dir = await mkdtemp(join(tmpdir(), 'usage-policy-write-'));
27
+ path = join(dir, 'policy.json');
28
+ store = createUsagePolicyFileStore({ path });
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ function recordingSampler(): UsagePolicyTarget & {
36
+ calls: { slot: string; usage: DesiredUsage; at?: number }[];
37
+ } {
38
+ const calls: { slot: string; usage: DesiredUsage; at?: number }[] = [];
39
+ return {
40
+ calls,
41
+ applyUsagePolicy(slot, usage, at) {
42
+ calls.push({ slot, usage, ...(at !== undefined ? { at } : {}) });
43
+ return { cycleStartMs: NOW, cycleReset: true };
44
+ },
45
+ };
46
+ }
47
+
48
+ describe('setUsagePolicy — persistence', () => {
49
+ test('writes a policy that reads back through getUsagePolicy', async () => {
50
+ const result = await setUsagePolicy(
51
+ { store, now: () => NOW },
52
+ { logicalSlotId: SLOT, cycleDay: 15, thresholdBytes: 5_000_000_000 },
53
+ );
54
+
55
+ expect(result.status).toBe('applied');
56
+ expect(await getUsagePolicy({ store }, SLOT)).toEqual({
57
+ cycleDay: 15,
58
+ thresholdBytes: 5_000_000_000,
59
+ });
60
+ });
61
+
62
+ test('an OMITTED field is left alone — a threshold write never drops the cycle day', async () => {
63
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9 });
64
+
65
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, thresholdBytes: 100 });
66
+
67
+ expect(await getUsagePolicy({ store }, SLOT)).toEqual({ cycleDay: 9, thresholdBytes: 100 });
68
+ });
69
+
70
+ test('an explicit null CLEARS that field and leaves the sibling standing', async () => {
71
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9, thresholdBytes: 100 });
72
+
73
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: null });
74
+
75
+ expect(await getUsagePolicy({ store }, SLOT)).toEqual({ thresholdBytes: 100 });
76
+ });
77
+
78
+ test('clearing BOTH fields removes the row rather than storing an empty one', async () => {
79
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9, thresholdBytes: 100 });
80
+
81
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: null, thresholdBytes: null });
82
+
83
+ expect((await store.load(NOW)).slots).toEqual([]);
84
+ });
85
+
86
+ test('a write to one slot never disturbs another', async () => {
87
+ await setUsagePolicy({ store }, { logicalSlotId: 'slot-a', cycleDay: 1 });
88
+ await setUsagePolicy({ store }, { logicalSlotId: 'slot-b', cycleDay: 20 });
89
+
90
+ await setUsagePolicy({ store }, { logicalSlotId: 'slot-a', cycleDay: 5 });
91
+
92
+ const state = await store.load(NOW);
93
+ expect(selectUsagePolicy(state, 'slot-a')).toEqual({ cycleDay: 5 });
94
+ expect(selectUsagePolicy(state, 'slot-b')).toEqual({ cycleDay: 20 });
95
+ expect(state.slots).toHaveLength(2);
96
+ });
97
+ });
98
+
99
+ describe('setUsagePolicy — typed refusals', () => {
100
+ test.each([
101
+ [{ logicalSlotId: '', cycleDay: 1 }, 'invalid-slot-id'],
102
+ [{ logicalSlotId: SLOT, cycleDay: 0 }, 'invalid-cycle-day'],
103
+ [{ logicalSlotId: SLOT, cycleDay: 32 }, 'invalid-cycle-day'],
104
+ [{ logicalSlotId: SLOT, cycleDay: 3.5 }, 'invalid-cycle-day'],
105
+ [{ logicalSlotId: SLOT, thresholdBytes: -1 }, 'invalid-threshold-bytes'],
106
+ [{ logicalSlotId: SLOT, thresholdBytes: 1.5 }, 'invalid-threshold-bytes'],
107
+ ] as const)('%p is rejected as %s and writes nothing', async (request, reason) => {
108
+ const result = await setUsagePolicy({ store }, request);
109
+
110
+ expect(result).toMatchObject({ status: 'rejected', reason });
111
+ expect((await store.load(NOW)).slots).toEqual([]);
112
+ });
113
+
114
+ test('a refusal is returned, never thrown', async () => {
115
+ expect(setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 99 })).resolves.toMatchObject(
116
+ { status: 'rejected' },
117
+ );
118
+ });
119
+
120
+ test('a store that throws yields a typed failure and never reaches the sampler', async () => {
121
+ const sampler = recordingSampler();
122
+ const broken: UsagePolicyStore = {
123
+ load: async () => ({
124
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
125
+ savedAtMs: NOW,
126
+ slots: [],
127
+ }),
128
+ save: async () => {
129
+ throw new Error('disk full');
130
+ },
131
+ };
132
+
133
+ const result = await setUsagePolicy(
134
+ { store: broken, sampler },
135
+ { logicalSlotId: SLOT, cycleDay: 3 },
136
+ );
137
+
138
+ expect(result).toMatchObject({ status: 'failed', reason: 'disk full' });
139
+ expect(sampler.calls).toEqual([]);
140
+ });
141
+ });
142
+
143
+ describe('setUsagePolicy — live apply', () => {
144
+ test('the merged policy (not the raw request) reaches the sampler', async () => {
145
+ await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 9 });
146
+ const sampler = recordingSampler();
147
+
148
+ const result = await setUsagePolicy(
149
+ { store, sampler, now: () => NOW },
150
+ { logicalSlotId: SLOT, thresholdBytes: 250 },
151
+ );
152
+
153
+ expect(sampler.calls).toEqual([
154
+ { slot: SLOT, usage: { cycleDay: 9, thresholdBytes: 250 }, at: NOW },
155
+ ]);
156
+ expect(result).toMatchObject({ status: 'applied', applied: { cycleReset: true } });
157
+ });
158
+
159
+ test('with no sampler the write is persistence-only and reports no application', async () => {
160
+ const result = await setUsagePolicy({ store }, { logicalSlotId: SLOT, cycleDay: 2 });
161
+
162
+ expect(result).toEqual({ status: 'applied', logicalSlotId: SLOT, usage: { cycleDay: 2 } });
163
+ });
164
+
165
+ test('a sampler that throws is a typed failure AFTER the write already landed', async () => {
166
+ const result = await setUsagePolicy(
167
+ {
168
+ store,
169
+ sampler: {
170
+ applyUsagePolicy() {
171
+ throw new Error('sampler gone');
172
+ },
173
+ },
174
+ },
175
+ { logicalSlotId: SLOT, cycleDay: 4 },
176
+ );
177
+
178
+ expect(result).toMatchObject({ status: 'failed', reason: 'sampler gone' });
179
+ expect(await getUsagePolicy({ store }, SLOT)).toEqual({ cycleDay: 4 });
180
+ });
181
+ });
182
+
183
+ describe('getUsagePolicy', () => {
184
+ test('an unwritten slot answers "no policy set"', async () => {
185
+ expect(await getUsagePolicy({ store }, 'never-written')).toEqual({});
186
+ });
187
+
188
+ test('reads a policy laid down by a previous process', async () => {
189
+ const seeded: PersistedUsagePolicy = {
190
+ schemaVersion: USAGE_POLICY_SCHEMA_VERSION,
191
+ savedAtMs: NOW,
192
+ slots: [{ logicalSlotId: SLOT, cycleDay: 28 }],
193
+ };
194
+ await store.save(seeded);
195
+
196
+ expect(await getUsagePolicy({ store }, SLOT)).toEqual({ cycleDay: 28 });
197
+ });
198
+ });