@verax-ai/proxy 0.1.1 → 0.1.2

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.
@@ -0,0 +1,225 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "./atomic-write.js";
4
+ export const LEGACY_PIECE_ID = "legacy";
5
+ export const DEFAULT_PIECE_MAX_ROWS = 50_000;
6
+ export const DEFAULT_PIECE_MAX_BYTES = 200 * 1024 * 1024;
7
+ export const MANIFEST_NAME = "ledger-manifest.json";
8
+ export const INDEX_NAME = "index.jsonl";
9
+ export function manifestPath(dir) {
10
+ return join(dir, MANIFEST_NAME);
11
+ }
12
+ export function indexPath(dir) {
13
+ return join(dir, INDEX_NAME);
14
+ }
15
+ export function legacyPieceRow() {
16
+ return {
17
+ id: LEGACY_PIECE_ID,
18
+ decisions: "decisions.jsonl",
19
+ effects: "effects.jsonl",
20
+ inputs: "inputs.jsonl",
21
+ n: 0,
22
+ effectN: 0,
23
+ firstMs: null,
24
+ lastMs: null,
25
+ lastHash: null,
26
+ closed: false,
27
+ };
28
+ }
29
+ export function newPieceRow(id) {
30
+ return {
31
+ id,
32
+ decisions: `pieces/${id}/decisions.jsonl`,
33
+ effects: `pieces/${id}/effects.jsonl`,
34
+ inputs: `pieces/${id}/inputs.jsonl`,
35
+ n: 0,
36
+ effectN: 0,
37
+ firstMs: null,
38
+ lastMs: null,
39
+ lastHash: null,
40
+ closed: false,
41
+ };
42
+ }
43
+ export function copyRel(pieceId, name) {
44
+ if (pieceId === LEGACY_PIECE_ID)
45
+ return `evidence-copy/${name}`;
46
+ return `evidence-copy/pieces/${pieceId}/${name}`;
47
+ }
48
+ export function nextPieceId(existingIds, atMs) {
49
+ const d = new Date(atMs);
50
+ const ym = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
51
+ const letters = "abcdefghijklmnopqrstuvwxyz";
52
+ for (let i = 0; i < letters.length; i += 1) {
53
+ const id = `${ym}-${letters[i]}`;
54
+ if (!existingIds.includes(id))
55
+ return id;
56
+ }
57
+ let n = letters.length;
58
+ for (;;) {
59
+ const id = `${ym}-z${n}`;
60
+ if (!existingIds.includes(id))
61
+ return id;
62
+ n += 1;
63
+ }
64
+ }
65
+ /**
66
+ * Null only when there is no manifest (the flat legacy layout). A manifest
67
+ * that is there but cannot be read or parsed throws: opening as a single
68
+ * legacy piece would chain new rows onto a closed file and hide every piece
69
+ * the manifest named.
70
+ */
71
+ export function readLedgerManifest(dir) {
72
+ let text;
73
+ try {
74
+ text = readFileSync(manifestPath(dir), "utf8");
75
+ }
76
+ catch (err) {
77
+ if (err.code === "ENOENT")
78
+ return null;
79
+ throw new Error("ledger-manifest-unreadable: " + err.message);
80
+ }
81
+ let raw;
82
+ try {
83
+ raw = JSON.parse(text);
84
+ }
85
+ catch (err) {
86
+ throw new Error("ledger-manifest-corrupt: " + err.message);
87
+ }
88
+ if (!raw || raw.version !== 1 || !Array.isArray(raw.pieces) || raw.pieces.length === 0) {
89
+ throw new Error("ledger-manifest-invalid: version 1 with at least one piece expected");
90
+ }
91
+ const pieces = raw.pieces.map(asPieceRow);
92
+ if (pieces.some((p) => p === null))
93
+ throw new Error("ledger-manifest-invalid: piece row missing a field");
94
+ return {
95
+ version: 1,
96
+ pieces: pieces,
97
+ countedAtMs: Array.isArray(raw.countedAtMs)
98
+ ? raw.countedAtMs.filter((n) => typeof n === "number")
99
+ : [],
100
+ };
101
+ }
102
+ function asPieceRow(raw) {
103
+ if (!raw || typeof raw !== "object")
104
+ return null;
105
+ const r = raw;
106
+ if (typeof r.id !== "string" || typeof r.decisions !== "string")
107
+ return null;
108
+ if (typeof r.effects !== "string" || typeof r.inputs !== "string")
109
+ return null;
110
+ if (typeof r.n !== "number" || typeof r.effectN !== "number")
111
+ return null;
112
+ if (typeof r.closed !== "boolean")
113
+ return null;
114
+ return {
115
+ id: r.id,
116
+ decisions: r.decisions,
117
+ effects: r.effects,
118
+ inputs: r.inputs,
119
+ n: r.n,
120
+ effectN: r.effectN,
121
+ firstMs: typeof r.firstMs === "number" ? r.firstMs : null,
122
+ lastMs: typeof r.lastMs === "number" ? r.lastMs : null,
123
+ lastHash: typeof r.lastHash === "string" ? r.lastHash : null,
124
+ closed: r.closed,
125
+ ...(typeof r.closedAtMs === "number" ? { closedAtMs: r.closedAtMs } : {}),
126
+ };
127
+ }
128
+ export function writeLedgerManifest(dir, manifest) {
129
+ writeFileAtomic(manifestPath(dir), `${JSON.stringify(manifest)}\n`);
130
+ }
131
+ export function listPieceFiles(dir) {
132
+ const manifest = readLedgerManifest(dir);
133
+ const pieces = manifest?.pieces ?? [legacyPieceRow()];
134
+ return pieces.map((p) => ({
135
+ id: p.id,
136
+ decisions: join(dir, p.decisions),
137
+ effects: join(dir, p.effects),
138
+ inputs: join(dir, p.inputs),
139
+ copyDecisions: join(dir, copyRel(p.id, "decisions.jsonl")),
140
+ copyEffects: join(dir, copyRel(p.id, "effects.jsonl")),
141
+ closed: p.closed,
142
+ firstMs: p.firstMs,
143
+ lastMs: p.lastMs,
144
+ n: p.n,
145
+ effectN: p.effectN,
146
+ }));
147
+ }
148
+ export function pieceOverlaps(piece, fromMs, toMs) {
149
+ if (piece.firstMs === null || piece.lastMs === null)
150
+ return piece.n > 0 || !piece.closed;
151
+ return piece.lastMs >= fromMs && piece.firstMs < toMs;
152
+ }
153
+ export function parseIndexText(text) {
154
+ const rows = [];
155
+ for (const line of text.split("\n")) {
156
+ if (line === "")
157
+ continue;
158
+ // The index is appended without fsync, so a crash can leave its last
159
+ // line half written. Such a line is skipped; the ref count below then
160
+ // falls short of the ledger's and the index is rebuilt from the pieces.
161
+ let raw;
162
+ try {
163
+ raw = JSON.parse(line);
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (typeof raw.ref !== "string" || typeof raw.piece !== "string")
169
+ continue;
170
+ rows.push({
171
+ ref: raw.ref,
172
+ tenantRef: typeof raw.tenantRef === "string" ? raw.tenantRef : "",
173
+ piece: raw.piece,
174
+ ts: typeof raw.ts === "number" ? raw.ts : 0,
175
+ kind: typeof raw.kind === "string" ? raw.kind : "",
176
+ requestHash: typeof raw.requestHash === "string" ? raw.requestHash : "",
177
+ resolves: typeof raw.resolves === "string" ? raw.resolves : null,
178
+ hasEffect: raw.hasEffect === true,
179
+ reasonCode: typeof raw.reasonCode === "string" ? raw.reasonCode : "",
180
+ subject: typeof raw.subject === "string" ? raw.subject : "",
181
+ policyHash: typeof raw.policyHash === "string" ? raw.policyHash : "",
182
+ });
183
+ }
184
+ return rows;
185
+ }
186
+ export function readLedgerIndex(dir) {
187
+ try {
188
+ return parseIndexText(readFileSync(indexPath(dir), "utf8"));
189
+ }
190
+ catch (err) {
191
+ if (err.code === "ENOENT")
192
+ return [];
193
+ throw err;
194
+ }
195
+ }
196
+ /**
197
+ * Decisions the index knows, by distinct ref. An effect adds a second line
198
+ * for its decision's ref, so a line total would overstate what the index
199
+ * holds and let a short index pass as complete.
200
+ */
201
+ export function decisionIndexCount(lines) {
202
+ const refs = new Set();
203
+ for (const l of lines) {
204
+ if (l.kind === "allow" || l.kind === "deny" || l.kind === "defer")
205
+ refs.add(l.ref);
206
+ }
207
+ return refs.size;
208
+ }
209
+ /**
210
+ * What the on-disk index names, or null when the file is missing. Doctor
211
+ * compares this to the pieces; a short index is rebuilt on the next open
212
+ * but a running body does not see the missing refs until then.
213
+ */
214
+ export function indexCoverage(dir) {
215
+ if (!existsSync(indexPath(dir)))
216
+ return null;
217
+ const lines = readLedgerIndex(dir);
218
+ const pieces = new Set();
219
+ for (const line of lines)
220
+ pieces.add(line.piece);
221
+ return { refs: decisionIndexCount(lines), pieces };
222
+ }
223
+ export function manifestExists(dir) {
224
+ return existsSync(manifestPath(dir));
225
+ }
package/dist/ledger.d.ts CHANGED
@@ -7,9 +7,24 @@ export declare const ledgerFs: {
7
7
  open: typeof open;
8
8
  };
9
9
  import { type EffectRow, type SignedEffectExtract } from "@cedulon/effect-extract";
10
- import type { EffectSigner, ExtractWindow, Ledger, LedgerEffect, WitnessClass } from "./types.ts";
10
+ import { type TailVisit } from "./jsonl-tail.ts";
11
+ import type { DecisionInputs, EffectSigner, ExtractWindow, Ledger, LedgerEffect, WitnessClass } from "./types.ts";
11
12
  import type { DecisionKind, SignedDecisionRecord } from "@cedulon/core";
12
13
  export type PermissionCheck = "owner-only" | "not checked on this platform";
14
+ /**
15
+ * A ledger file read from its end through the same seam the reads above use,
16
+ * so a test can count what a window costs.
17
+ */
18
+ export declare function readLedgerTail<T>(path: string, visit: (row: T) => TailVisit, chunkBytesOrOpts?: number | {
19
+ chunkBytes?: number;
20
+ endOffset?: number;
21
+ }): Promise<T[]>;
22
+ /**
23
+ * How far past a window's lower edge the tail reader keeps looking before it
24
+ * stops. Rows are in file order; their stamps are the clock's, and a clock
25
+ * can be set back. A row stamped a minute before its neighbour is still seen.
26
+ */
27
+ export declare const WINDOW_SLACK_MS = 60000;
13
28
  /** Write then fsync so a crash cannot drop a committed line. */
14
29
  export declare function appendDurable(path: string, line: string): Promise<void>;
15
30
  export type RemoteWitnessSign = (row: EffectRow, resultHash: string | undefined) => Promise<Pick<LedgerEffect, "receipt" | "attestation" | "witnessClass"> | null>;
@@ -55,14 +70,37 @@ export declare class MemoryLedger implements Ledger {
55
70
  lastDecisionHash(): Promise<string | null>;
56
71
  exportExtract(window: ExtractWindow, signer: EffectSigner): Promise<SignedEffectExtract>;
57
72
  }
73
+ export type FileLedgerOpts = {
74
+ pieceMaxRows?: number;
75
+ pieceMaxBytes?: number;
76
+ onPieceClose?: (window: {
77
+ startMs: number;
78
+ endMs: number;
79
+ pieceId: string;
80
+ }) => Promise<void>;
81
+ };
82
+ export type LedgerCounts = {
83
+ decisions: number;
84
+ effects: number;
85
+ lastDecisionMs: number | null;
86
+ activeDecisions: number;
87
+ pieces: number;
88
+ };
58
89
  export declare class FileLedger implements Ledger {
59
90
  readonly permissionCheck: PermissionCheck;
60
91
  readonly dir: string;
61
92
  effectSigner?: EffectSigner;
62
93
  /** Optional signer in another process. Null means stay `self`. */
63
94
  remoteWitness?: RemoteWitnessSign;
64
- private readonly decisionsPath;
65
- private readonly effectsPath;
95
+ /** Asked after a piece closes. A missing or failing hook does not block the close. */
96
+ onPieceClose?: FileLedgerOpts["onPieceClose"];
97
+ pieceMaxRows: number;
98
+ pieceMaxBytes: number;
99
+ private decisionsPath;
100
+ private effectsPath;
101
+ private inputsPath;
102
+ private copyDecisionsPath;
103
+ private copyEffectsPath;
66
104
  private readonly lockPath;
67
105
  private readonly q;
68
106
  private readonly token;
@@ -75,17 +113,37 @@ export declare class FileLedger implements Ledger {
75
113
  private readonly reauthByHash;
76
114
  private readonly countedAt;
77
115
  countsReadable: boolean;
78
- private readonly copyDir;
79
116
  private readonly heartbeatPath;
80
117
  private decisionCount;
81
118
  private effectCount;
82
- constructor(dir: string);
119
+ private lastDecisionMs;
120
+ private pieces;
121
+ /** Inputs rows appended through this instance, until their decision lands. */
122
+ private readonly recentInputs;
123
+ private activeId;
124
+ private activeDecisionN;
125
+ private activeEffectN;
126
+ constructor(dir: string, opts?: FileLedgerOpts);
127
+ noteInputs(ref: string, inputs: DecisionInputs): void;
128
+ inputsPaths(): {
129
+ active: string;
130
+ all: string[];
131
+ };
83
132
  /** After lock handoff a new instance reloads; the lost owner cannot append. */
84
133
  private loadCaches;
85
- /** Pair inputs.jsonl with decisions so a restart keeps per-tenant `_ref` keys. */
86
- private loadTenantRefs;
87
- /** Single pass over inputs.jsonl. FileInputsLog itself stays cache-less. */
88
- private loadResolvedBy;
134
+ private applyActivePaths;
135
+ private activePiece;
136
+ private applyIndexLine;
137
+ private pruneCounted;
138
+ private rebuildIndex;
139
+ private writeIndexForDecision;
140
+ private writeIndexHasEffect;
141
+ private noteActiveStamp;
142
+ private persistManifest;
143
+ private fsyncPath;
144
+ private ensurePieceFiles;
145
+ private closeActivePiece;
146
+ private maybeRotate;
89
147
  private lockBody;
90
148
  private ownsLock;
91
149
  /** Lock file on disk, not a process counter. */
@@ -97,6 +155,27 @@ export declare class FileLedger implements Ledger {
97
155
  close(): void;
98
156
  appendDecision(signed: SignedDecisionRecord): Promise<void>;
99
157
  appendDecisionChained(build: (prevRecordHash: string | null) => SignedDecisionRecord): Promise<void>;
158
+ /**
159
+ * What /healthz reports, from memory: the counts are kept on load and on
160
+ * every append. Rereading both files to count lines cost a second per call
161
+ * on a 100k-decision ledger (measured 17 Sep 2026), and the panel asks
162
+ * every five seconds. Null when the decision file could not be parsed on
163
+ * load; then the caller reads for itself and fails the way it always did.
164
+ */
165
+ counts(): LedgerCounts | null;
166
+ /**
167
+ * Decisions stamped in [fromMs, toMs), read from the end of each overlapping
168
+ * piece so the cost is the window's, not the ledger's. With a limit, the
169
+ * newest rows of the window come back and `more` says the window went on,
170
+ * including in a closed piece.
171
+ */
172
+ decisionsWindow(fromMs: number, toMs: number, limit?: number): Promise<{
173
+ rows: SignedDecisionRecord[];
174
+ more: boolean;
175
+ piecesTouched: string[];
176
+ }>;
177
+ /** Effects stamped in [fromMs, toMs), read from the end of each overlapping piece. */
178
+ effectsWindow(fromMs: number, toMs: number): Promise<LedgerEffect[]>;
100
179
  appendEffect(row: EffectRow, witnessClass?: WitnessClass, resultHash?: string): Promise<void>;
101
180
  private appendEffectUnlocked;
102
181
  decisions(): Promise<SignedDecisionRecord[]>;