tledger 0.2.0 → 0.3.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.
@@ -0,0 +1,267 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ mkdir,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { dirname, resolve } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import {
14
+ gunzip as gunzipCallback,
15
+ gzip as gzipCallback,
16
+ } from "node:zlib";
17
+
18
+ import {
19
+ ADAPTIVE_USAGE_RESOLUTIONS_SECONDS,
20
+ coarsenUsageBuckets,
21
+ SNAPSHOT_SCHEMA_VERSION,
22
+ usageBucketStats,
23
+ usageBuckets,
24
+ } from "./token-ledger-usage.mjs";
25
+
26
+ export const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
27
+ export const DEFAULT_SNAPSHOT_TARGET_BYTES = 12 * 1024 * 1024;
28
+ export const DEFAULT_SNAPSHOT_MAX_JSON_BYTES = 64 * 1024 * 1024;
29
+ export const DEFAULT_SNAPSHOT_TARGET_JSON_BYTES = 48 * 1024 * 1024;
30
+
31
+ const PRECOMPACT_BUCKET_COUNT = 50_000;
32
+
33
+ const gzip = promisify(gzipCallback);
34
+ const gunzip = promisify(gunzipCallback);
35
+
36
+ function snapshotEncoding(path) {
37
+ return path.toLowerCase().endsWith(".gz") ? "gzip" : "json";
38
+ }
39
+
40
+ function formatMebibytes(bytes) {
41
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
42
+ }
43
+
44
+ function snapshotWithStorageMetadata(snapshot, buckets, adaptiveResolutionSeconds) {
45
+ const stats = usageBucketStats(buckets);
46
+ return {
47
+ ...snapshot,
48
+ coverage: {
49
+ ...snapshot.coverage,
50
+ observedModelCalls: stats.callCount,
51
+ usageBucketCount: stats.bucketCount,
52
+ maximumUsageResolutionSeconds: stats.maximumResolutionSeconds,
53
+ },
54
+ storage: {
55
+ format: "bounded-usage-buckets",
56
+ modelCalls: stats.callCount,
57
+ usageBuckets: stats.bucketCount,
58
+ maximumResolutionSeconds: stats.maximumResolutionSeconds,
59
+ adaptiveResolutionSeconds,
60
+ },
61
+ events: buckets,
62
+ };
63
+ }
64
+
65
+ async function encodeSnapshot(snapshot, encoding) {
66
+ const serialized = Buffer.from(`${JSON.stringify(snapshot)}\n`, "utf8");
67
+ const encoded = encoding === "gzip" ? await gzip(serialized) : serialized;
68
+ return { serialized, encoded };
69
+ }
70
+
71
+ async function boundedEncoding(
72
+ snapshot,
73
+ encoding,
74
+ targetBytes,
75
+ targetJsonBytes,
76
+ ) {
77
+ if (
78
+ snapshot?.schemaVersion !== SNAPSHOT_SCHEMA_VERSION ||
79
+ !Array.isArray(snapshot?.events)
80
+ ) {
81
+ const result = await encodeSnapshot(snapshot, encoding);
82
+ return { ...result, snapshot, adaptiveResolutionSeconds: 0 };
83
+ }
84
+
85
+ let buckets = usageBuckets(snapshot);
86
+ let adaptiveResolutionSeconds = 0;
87
+ if (buckets.length > PRECOMPACT_BUCKET_COUNT) {
88
+ adaptiveResolutionSeconds = ADAPTIVE_USAGE_RESOLUTIONS_SECONDS[0];
89
+ buckets = coarsenUsageBuckets(buckets, adaptiveResolutionSeconds);
90
+ }
91
+ let candidate = snapshotWithStorageMetadata(
92
+ snapshot,
93
+ buckets,
94
+ adaptiveResolutionSeconds,
95
+ );
96
+ let result = await encodeSnapshot(candidate, encoding);
97
+ for (const resolutionSeconds of ADAPTIVE_USAGE_RESOLUTIONS_SECONDS) {
98
+ if (
99
+ result.encoded.byteLength <= targetBytes &&
100
+ result.serialized.byteLength <= targetJsonBytes
101
+ ) {
102
+ break;
103
+ }
104
+ if (resolutionSeconds <= adaptiveResolutionSeconds) continue;
105
+ buckets = coarsenUsageBuckets(buckets, resolutionSeconds);
106
+ adaptiveResolutionSeconds = resolutionSeconds;
107
+ candidate = snapshotWithStorageMetadata(
108
+ snapshot,
109
+ buckets,
110
+ adaptiveResolutionSeconds,
111
+ );
112
+ result = await encodeSnapshot(candidate, encoding);
113
+ }
114
+ return {
115
+ ...result,
116
+ snapshot: candidate,
117
+ adaptiveResolutionSeconds,
118
+ };
119
+ }
120
+
121
+ export async function readPrivateSnapshot(
122
+ input,
123
+ {
124
+ maxBytes = DEFAULT_SNAPSHOT_MAX_BYTES,
125
+ maxJsonBytes = DEFAULT_SNAPSHOT_MAX_JSON_BYTES,
126
+ } = {},
127
+ ) {
128
+ const source = resolve(input);
129
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
130
+ throw new Error("Snapshot size limit must be a positive safe integer.");
131
+ }
132
+ if (!Number.isSafeInteger(maxJsonBytes) || maxJsonBytes < 1) {
133
+ throw new Error("Snapshot JSON safety limit must be a positive safe integer.");
134
+ }
135
+ const encoding = snapshotEncoding(source);
136
+ const sourceLimit = encoding === "gzip" ? maxBytes : maxJsonBytes;
137
+ const sourceStats = await stat(source);
138
+ if (sourceStats.size > sourceLimit) {
139
+ const error = new Error(
140
+ `Snapshot input is ${formatMebibytes(sourceStats.size)}, exceeding the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
141
+ );
142
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
143
+ throw error;
144
+ }
145
+ const encoded = await readFile(source);
146
+ if (encoded.byteLength > sourceLimit) {
147
+ const error = new Error(
148
+ `Snapshot input grew beyond the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
149
+ );
150
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
151
+ throw error;
152
+ }
153
+ let decoded;
154
+ if (encoding === "gzip") {
155
+ try {
156
+ decoded = await gunzip(encoded, { maxOutputLength: maxJsonBytes });
157
+ } catch (cause) {
158
+ if (cause?.code !== "ERR_BUFFER_TOO_LARGE") throw cause;
159
+ const error = new Error(
160
+ `Snapshot expands beyond the ${formatMebibytes(maxJsonBytes)} JSON read limit.`,
161
+ { cause },
162
+ );
163
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
164
+ throw error;
165
+ }
166
+ } else {
167
+ decoded = encoded;
168
+ }
169
+ if (decoded.byteLength > maxJsonBytes) {
170
+ const error = new Error(
171
+ `Snapshot JSON representation is ${formatMebibytes(decoded.byteLength)}, exceeding the ${formatMebibytes(maxJsonBytes)} read limit.`,
172
+ );
173
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
174
+ throw error;
175
+ }
176
+ return JSON.parse(decoded.toString("utf8"));
177
+ }
178
+
179
+ export async function writePrivateSnapshot(
180
+ output,
181
+ snapshot,
182
+ {
183
+ maxBytes = DEFAULT_SNAPSHOT_MAX_BYTES,
184
+ targetBytes = Math.min(DEFAULT_SNAPSHOT_TARGET_BYTES, maxBytes),
185
+ maxJsonBytes = DEFAULT_SNAPSHOT_MAX_JSON_BYTES,
186
+ targetJsonBytes = Math.min(
187
+ DEFAULT_SNAPSHOT_TARGET_JSON_BYTES,
188
+ maxJsonBytes,
189
+ ),
190
+ } = {},
191
+ ) {
192
+ const destination = resolve(output);
193
+ const directory = dirname(destination);
194
+ const temporary = resolve(
195
+ directory,
196
+ `.token-ledger-${process.pid}-${randomUUID()}.tmp`,
197
+ );
198
+ const encoding = snapshotEncoding(destination);
199
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
200
+ throw new Error("Snapshot size limit must be a positive safe integer.");
201
+ }
202
+ if (!Number.isSafeInteger(targetBytes) || targetBytes < 1 || targetBytes > maxBytes) {
203
+ throw new Error("Snapshot target size must be a positive safe integer at or below the safety limit.");
204
+ }
205
+ if (!Number.isSafeInteger(maxJsonBytes) || maxJsonBytes < 1) {
206
+ throw new Error("Snapshot JSON safety limit must be a positive safe integer.");
207
+ }
208
+ if (
209
+ !Number.isSafeInteger(targetJsonBytes) ||
210
+ targetJsonBytes < 1 ||
211
+ targetJsonBytes > maxJsonBytes
212
+ ) {
213
+ throw new Error(
214
+ "Snapshot JSON target size must be a positive safe integer at or below its safety limit.",
215
+ );
216
+ }
217
+ const {
218
+ serialized,
219
+ encoded,
220
+ snapshot: storedSnapshot,
221
+ adaptiveResolutionSeconds,
222
+ } = await boundedEncoding(
223
+ snapshot,
224
+ encoding,
225
+ targetBytes,
226
+ targetJsonBytes,
227
+ );
228
+ if (serialized.byteLength > maxJsonBytes) {
229
+ const error = new Error(
230
+ `Snapshot JSON representation would be ${formatMebibytes(serialized.byteLength)}, exceeding the ${formatMebibytes(maxJsonBytes)} in-memory safety limit. Use --since or --no-archived to reduce high-cardinality history.`,
231
+ );
232
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
233
+ throw error;
234
+ }
235
+ if (encoded.byteLength > maxBytes) {
236
+ const error = new Error(
237
+ `Snapshot would be ${formatMebibytes(encoded.byteLength)}, exceeding the ${formatMebibytes(maxBytes)} safety limit. Use a .json.gz output plus --since or --no-archived to reduce the snapshot.`,
238
+ );
239
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
240
+ throw error;
241
+ }
242
+
243
+ await mkdir(directory, { recursive: true });
244
+ try {
245
+ await writeFile(temporary, encoded, {
246
+ flag: "wx",
247
+ mode: 0o600,
248
+ });
249
+ await chmod(temporary, 0o600);
250
+ await rename(temporary, destination);
251
+ await chmod(destination, 0o600);
252
+ } finally {
253
+ await rm(temporary, { force: true });
254
+ }
255
+
256
+ return {
257
+ encoding,
258
+ bytesWritten: encoded.byteLength,
259
+ jsonBytes: serialized.byteLength,
260
+ maxBytes,
261
+ targetBytes,
262
+ maxJsonBytes,
263
+ targetJsonBytes,
264
+ adaptiveResolutionSeconds,
265
+ snapshot: storedSnapshot,
266
+ };
267
+ }