@volter/world-core 2.0.18 → 2.0.20

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.
@@ -33,7 +33,7 @@ export { SqlWorldStore } from './world-store-sql.js';
33
33
  export type { SqlExec } from './world-store-sql.js';
34
34
  export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from './blob-store.js';
35
35
  export type { BlobStore } from './blob-store.js';
36
- export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from './serve.js';
36
+ export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from './serve.js';
37
37
  export type { AtomicTwinWriteDecision, TwinRequestJournalEntry, TwinResource, TwinWriteInput, TwinWriteResult, } from './serve.js';
38
38
  export { createTwinProxy } from './proxy.js';
39
39
  export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.js';
package/dist/src/index.js CHANGED
@@ -55,7 +55,7 @@ export { SqlWorldStore } from "./world-store-sql.js";
55
55
  // The blob seam (runtime contract R11): byte storage behind byte-carrying handlers, so a
56
56
  // serverless namespace puts bytes in object storage while local worlds keep today's layout.
57
57
  export { FsBlobStore, MemoryBlobStore, blobDigest, getActiveBlobStore, setActiveBlobStore, withBlobStore, readBlobRange, } from "./blob-store.js";
58
- export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
58
+ export { applyTwinWrite, applyTwinWriteAtomic, createTwinServer, journalTwinRequest, twinRequestJournalFailures, twinRequestCredentials, readTwinRequestJournal, resolveTwinRead, twinRequestJournalEnabled, twinRequestJournalPath, twinResources, } from "./serve.js";
59
59
  export { createTwinProxy } from "./proxy.js";
60
60
  export { forkTwin, isFork, readForkMeta, } from "./fork.js";
61
61
  export { appendAction, appendTransactionCommit, checkPrecondition, confirmAction, listActions, listTransactionCommits, pendingActions, pushablePendingActions, isTwinBookkeeping, pendingTransactionCommits, projectResources, projectOwnerResources, OwnerStoreAmbiguousError, revertAction, TwinActionPreconditionError, subjectAliases, resolveSubjectId, runWithCorrelationId, currentCorrelationId } from "./actions.js";
@@ -24,9 +24,18 @@ export type TwinRequestJournalEntry = {
24
24
  ms?: number;
25
25
  /** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
26
26
  credentials?: TwinCredentialShape[];
27
+ /** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
28
+ * audit cannot be written is refused); its outcome is the entry without a phase written after. */
29
+ phase?: 'request';
27
30
  };
28
- /** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is off or empty). */
29
- export declare function readTwinRequestJournal(service: string, root?: string): TwinRequestJournalEntry[];
31
+ /** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
32
+ * With `since` (epoch ms), segments that ended before it are not read. */
33
+ export declare function readTwinRequestJournal(service: string, root?: string, options?: {
34
+ since?: number;
35
+ }): TwinRequestJournalEntry[];
36
+ /** Journal lines this process failed to keep, since it started, for one service's journal under `root`
37
+ * (a World's request report sums its own twins and links). */
38
+ export declare function twinRequestJournalFailures(service: string, root?: string): number;
30
39
  /** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
31
40
  * never collide in a journal, far too little to walk back to a real key. */
32
41
  export declare function credentialFingerprint(value: string): string;
@@ -35,14 +44,17 @@ export declare function credentialFingerprint(value: string): string;
35
44
  * fingerprinted, sorted for stable diffing. Values never leave this function.
36
45
  */
37
46
  export declare function twinRequestCredentials(headers: Headers | Record<string, string>, url?: string | URL): TwinCredentialShape[];
47
+ /** The audit is kept unless it is switched off (VOLTER_TWIN_REQUEST_JOURNAL=0): no audit is the
48
+ * setting someone chooses, never the default. */
38
49
  export declare function twinRequestJournalEnabled(env?: Record<string, string | undefined>): boolean;
39
50
  /** Where a service's request journal lives: beside its `actions.jsonl`. */
40
51
  export declare function twinRequestJournalPath(service: string, root?: string): string;
41
52
  /**
42
- * Append one served request to the journal — a no-op unless VOLTER_TWIN_REQUEST_JOURNAL=1, so
43
- * the default path does zero I/O. Never throws: an observability append must not fail a serve.
53
+ * Append one request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0. Never throws;
54
+ * answers whether the line was kept (true when the journal is switched off: no audit was chosen), so a
55
+ * caller that must not act unaudited (a link) can refuse.
44
56
  */
45
- export declare function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void;
57
+ export declare function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): boolean;
46
58
  export declare const TWIN_JOURNAL_IDENTITY: unique symbol;
47
59
  export type TwinJournalIdentity = {
48
60
  service: string;
@@ -52,8 +64,7 @@ export declare function vendorFromStack(stack: string | undefined): string | und
52
64
  /**
53
65
  * Wrap `Bun.serve` so EVERY twin's HTTP surface journals, with no per-pack line. Idempotent
54
66
  * (a `Symbol.for` marker survives a second copy of this module) and a strict pass-through when
55
- * VOLTER_TWIN_REQUEST_JOURNAL is not `1` — the default path adds one function call per request
56
- * and does zero I/O. Called once at module load, below; exported so a test can assert it.
67
+ * VOLTER_TWIN_REQUEST_JOURNAL=0. Called once at module load, below; exported so a test can assert it.
57
68
  *
58
69
  * A pack that knows its own identity may declare it by putting `{service, root}` on the serve
59
70
  * options under `TWIN_JOURNAL_IDENTITY` (a symbol key Bun's option reader ignores). Nothing in
package/dist/src/serve.js CHANGED
@@ -18,27 +18,60 @@ import { hashFieldValue } from "./hash.js";
18
18
  import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, projectResources } from "./actions.js";
19
19
  import { observeWorldPaths, worldPaths } from "./storage.js";
20
20
  import { getActiveWorldStore } from "./world-store.js";
21
- /** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is off or empty). */
22
- export function readTwinRequestJournal(service, root) {
21
+ /** The journal's closed segments, oldest first: `requests.jsonl.<n>` beside the live file. */
22
+ function journalSegments(path) {
23
+ const base = path.slice(path.lastIndexOf('/') + 1);
24
+ return getActiveWorldStore().list(dirname(path))
25
+ .map((name) => (name.startsWith(`${base}.`) ? Number(name.slice(base.length + 1)) : NaN))
26
+ .filter((n) => Number.isInteger(n) && n > 0)
27
+ .sort((a, b) => a - b)
28
+ .map((n) => `${path}.${n}`);
29
+ }
30
+ /** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
31
+ * With `since` (epoch ms), segments that ended before it are not read. */
32
+ export function readTwinRequestJournal(service, root, options = {}) {
23
33
  const path = twinRequestJournalPath(service, root);
24
- const out = [];
25
- for (const file of [`${path}.1`, path]) {
34
+ const parse = (file) => {
26
35
  const text = getActiveWorldStore().read(file);
27
36
  if (!text)
28
- continue;
37
+ return [];
38
+ const rows = [];
29
39
  for (const line of text.split('\n')) {
30
40
  if (!line)
31
41
  continue;
32
42
  try {
33
- out.push(JSON.parse(line));
43
+ rows.push(JSON.parse(line));
34
44
  }
35
- catch { /* a torn line at rotation */ }
45
+ catch { /* a torn line */ }
36
46
  }
47
+ return rows;
48
+ };
49
+ const kept = [parse(path)];
50
+ // newest first, stopping at the first segment that ended before `since`
51
+ for (const file of journalSegments(path).reverse()) {
52
+ const rows = parse(file);
53
+ const last = rows[rows.length - 1]?.at;
54
+ if (options.since !== undefined && last && Date.parse(last) < options.since)
55
+ break;
56
+ kept.unshift(rows);
57
+ }
58
+ return kept.flat();
59
+ }
60
+ /** Lines not kept, per journal: keyed by the journal's own path, so a process serving many Worlds
61
+ * (a host; an isolate holding several) answers for each World alone. */
62
+ const journalFailures = new Map();
63
+ /** Journal lines this process failed to keep, since it started, for one service's journal under `root`
64
+ * (a World's request report sums its own twins and links). */
65
+ export function twinRequestJournalFailures(service, root) {
66
+ try {
67
+ return journalFailures.get(twinRequestJournalPath(service, root)) ?? 0;
68
+ }
69
+ catch {
70
+ return 0;
37
71
  }
38
- return out;
39
72
  }
40
- /** ~1 MB cap before a one-deep rotation — small enough to never matter on disk, large enough for
41
- * tens of thousands of entries (a shape line is ~80 bytes). */
73
+ /** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
74
+ * recent period reads only the segments that reach into it. */
42
75
  const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
43
76
  /**
44
77
  * Does this header/param NAME look like it carries a credential? Deliberately a generic word
@@ -102,42 +135,52 @@ export function twinRequestCredentials(headers, url) {
102
135
  catch { /* an unparseable URL must not fail a serve */ }
103
136
  return shapes.sort((a, b) => (a.in === b.in ? a.name.localeCompare(b.name) : a.in.localeCompare(b.in)));
104
137
  }
138
+ /** The audit is kept unless it is switched off (VOLTER_TWIN_REQUEST_JOURNAL=0): no audit is the
139
+ * setting someone chooses, never the default. */
105
140
  export function twinRequestJournalEnabled(env = process.env) {
106
- return env.VOLTER_TWIN_REQUEST_JOURNAL === '1';
141
+ return env.VOLTER_TWIN_REQUEST_JOURNAL !== '0';
107
142
  }
108
143
  /** Where a service's request journal lives: beside its `actions.jsonl`. */
109
144
  export function twinRequestJournalPath(service, root) {
110
145
  return join(dirname(worldPaths(service, root).events), 'requests.jsonl');
111
146
  }
112
147
  /**
113
- * Append one served request to the journal — a no-op unless VOLTER_TWIN_REQUEST_JOURNAL=1, so
114
- * the default path does zero I/O. Never throws: an observability append must not fail a serve.
148
+ * Append one request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0. Never throws;
149
+ * answers whether the line was kept (true when the journal is switched off: no audit was chosen), so a
150
+ * caller that must not act unaudited (a link) can refuse.
115
151
  */
116
152
  export function journalTwinRequest(service, entry, root) {
117
153
  if (!twinRequestJournalEnabled())
118
- return;
154
+ return true;
155
+ const line = JSON.stringify({
156
+ at: entry.at ?? new Date().toISOString(),
157
+ ...(entry.phase ? { phase: entry.phase } : {}),
158
+ method: entry.method.toUpperCase(),
159
+ path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
160
+ status: entry.status,
161
+ ...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
162
+ ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
163
+ });
164
+ let path;
119
165
  try {
120
166
  const store = getActiveWorldStore();
121
- const path = twinRequestJournalPath(service, root);
167
+ path = twinRequestJournalPath(service, root);
122
168
  store.mkdir(dirname(path));
123
- const size = store.stat(path)?.size ?? 0;
124
- if (size >= REQUEST_JOURNAL_MAX_BYTES) {
125
- // one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
126
- store.writeAtomic(`${path}.1`, store.read(path) ?? '');
169
+ if ((store.stat(path)?.size ?? 0) >= REQUEST_JOURNAL_MAX_BYTES) {
170
+ // the live file closes into the next segment; no segment is ever overwritten or removed (a crash between
171
+ // the two writes leaves the live lines in both: repeated in the next segment, never lost)
172
+ const next = Number(journalSegments(path).pop()?.slice(path.length + 1) ?? 0) + 1;
173
+ store.writeAtomic(`${path}.${next}`, store.read(path) ?? '');
127
174
  store.write(path, '');
128
175
  }
129
- const line = JSON.stringify({
130
- at: entry.at ?? new Date().toISOString(),
131
- method: entry.method.toUpperCase(),
132
- path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
133
- status: entry.status,
134
- ...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
135
- ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
136
- });
137
176
  store.append(path, `${line}\n`);
177
+ return true;
138
178
  }
139
- catch {
140
- // journaling is best-effort observability; the serve path must not care
179
+ catch (error) {
180
+ const key = path ?? `${root ?? ''}/${service}`;
181
+ journalFailures.set(key, (journalFailures.get(key) ?? 0) + 1);
182
+ console.error(`[request-journal] ${service}: a line was not kept: ${error.message}`);
183
+ return false;
141
184
  }
142
185
  }
143
186
  // ── how a wrapped `Bun.serve` learns which twin it is ──────────────────────────────────────────
@@ -196,8 +239,7 @@ export function vendorFromStack(stack) {
196
239
  /**
197
240
  * Wrap `Bun.serve` so EVERY twin's HTTP surface journals, with no per-pack line. Idempotent
198
241
  * (a `Symbol.for` marker survives a second copy of this module) and a strict pass-through when
199
- * VOLTER_TWIN_REQUEST_JOURNAL is not `1` — the default path adds one function call per request
200
- * and does zero I/O. Called once at module load, below; exported so a test can assert it.
242
+ * VOLTER_TWIN_REQUEST_JOURNAL=0. Called once at module load, below; exported so a test can assert it.
201
243
  *
202
244
  * A pack that knows its own identity may declare it by putting `{service, root}` on the serve
203
245
  * options under `TWIN_JOURNAL_IDENTITY` (a symbol key Bun's option reader ignores). Nothing in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
package/src/index.ts CHANGED
@@ -162,6 +162,7 @@ export {
162
162
  applyTwinWriteAtomic,
163
163
  createTwinServer,
164
164
  journalTwinRequest,
165
+ twinRequestJournalFailures,
165
166
  twinRequestCredentials,
166
167
  readTwinRequestJournal,
167
168
  resolveTwinRead,
package/src/serve.ts CHANGED
@@ -24,10 +24,12 @@ import { getActiveWorldStore } from './world-store.ts';
24
24
  // ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
25
25
  // `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
26
26
  // — an empty-catalog GET that 404s leaves no trace, and "did the app even ask?" is unanswerable.
27
- // The journal is the minimal honest answer: an OPT-IN (VOLTER_TWIN_REQUEST_JOURNAL=1) per-twin
28
- // `requests.jsonl` beside `actions.jsonl`, one line per served HTTP request. Size-capped: at ~1 MB
29
- // the file rotates once to `requests.jsonl.1` (previous rotation overwritten), so an enabled
30
- // journal can never grow unbounded. `volter-world tail --requests` merges these lines live.
27
+ // The journal is the minimal honest answer: a per-twin `requests.jsonl` beside `actions.jsonl`, one
28
+ // line per served HTTP request, kept unless switched off (VOLTER_TWIN_REQUEST_JOURNAL=0). It is an
29
+ // audit trail, so nothing in it is ever deleted: at ~1 MB the live file closes into the next numbered
30
+ // segment (`requests.jsonl.1`, `.2`, … oldest first) and a fresh one starts. An append that fails is
31
+ // said on stderr and counted per journal (`twinRequestJournalFailures`), never silent. `volter-world tail
32
+ // --requests` merges the live lines.
31
33
  //
32
34
  // WHAT A LINE CARRIES. `{at, method, path, status}` — the shape — plus, when the request presented
33
35
  // one, the CREDENTIAL SHAPE: which header/query-param NAMES carried a credential-looking value,
@@ -82,21 +84,54 @@ export type TwinRequestJournalEntry = {
82
84
  ms?: number;
83
85
  /** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
84
86
  credentials?: TwinCredentialShape[];
87
+ /** `request`: written BEFORE the request is served (an audit device's request entry, so a request whose
88
+ * audit cannot be written is refused); its outcome is the entry without a phase written after. */
89
+ phase?: 'request';
85
90
  };
86
91
 
87
- /** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is off or empty). */
88
- export function readTwinRequestJournal(service: string, root?: string): TwinRequestJournalEntry[] {
92
+ /** The journal's closed segments, oldest first: `requests.jsonl.<n>` beside the live file. */
93
+ function journalSegments(path: string): string[] {
94
+ const base = path.slice(path.lastIndexOf('/') + 1);
95
+ return getActiveWorldStore().list(dirname(path))
96
+ .map((name) => (name.startsWith(`${base}.`) ? Number(name.slice(base.length + 1)) : NaN))
97
+ .filter((n) => Number.isInteger(n) && n > 0)
98
+ .sort((a, b) => a - b)
99
+ .map((n) => `${path}.${n}`);
100
+ }
101
+
102
+ /** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is switched off or empty).
103
+ * With `since` (epoch ms), segments that ended before it are not read. */
104
+ export function readTwinRequestJournal(service: string, root?: string, options: { since?: number } = {}): TwinRequestJournalEntry[] {
89
105
  const path = twinRequestJournalPath(service, root);
90
- const out: TwinRequestJournalEntry[] = [];
91
- for (const file of [`${path}.1`, path]) {
92
- const text = getActiveWorldStore().read(file); if (!text) continue;
93
- for (const line of text.split('\n')) { if (!line) continue; try { out.push(JSON.parse(line) as TwinRequestJournalEntry); } catch { /* a torn line at rotation */ } }
106
+ const parse = (file: string): TwinRequestJournalEntry[] => {
107
+ const text = getActiveWorldStore().read(file); if (!text) return [];
108
+ const rows: TwinRequestJournalEntry[] = [];
109
+ for (const line of text.split('\n')) { if (!line) continue; try { rows.push(JSON.parse(line) as TwinRequestJournalEntry); } catch { /* a torn line */ } }
110
+ return rows;
111
+ };
112
+ const kept: TwinRequestJournalEntry[][] = [parse(path)];
113
+ // newest first, stopping at the first segment that ended before `since`
114
+ for (const file of journalSegments(path).reverse()) {
115
+ const rows = parse(file);
116
+ const last = rows[rows.length - 1]?.at;
117
+ if (options.since !== undefined && last && Date.parse(last) < options.since) break;
118
+ kept.unshift(rows);
94
119
  }
95
- return out;
120
+ return kept.flat();
96
121
  }
97
122
 
98
- /** ~1 MB cap before a one-deep rotation — small enough to never matter on disk, large enough for
99
- * tens of thousands of entries (a shape line is ~80 bytes). */
123
+ /** Lines not kept, per journal: keyed by the journal's own path, so a process serving many Worlds
124
+ * (a host; an isolate holding several) answers for each World alone. */
125
+ const journalFailures = new Map<string, number>();
126
+ /** Journal lines this process failed to keep, since it started, for one service's journal under `root`
127
+ * (a World's request report sums its own twins and links). */
128
+ export function twinRequestJournalFailures(service: string, root?: string): number {
129
+ try { return journalFailures.get(twinRequestJournalPath(service, root)) ?? 0; } catch { return 0; }
130
+ }
131
+
132
+
133
+ /** ~1 MB per segment: tens of thousands of entries (a shape line is ~80 bytes), and a report over a
134
+ * recent period reads only the segments that reach into it. */
100
135
  const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
101
136
 
102
137
  /**
@@ -156,8 +191,10 @@ export function twinRequestCredentials(headers: Headers | Record<string, string>
156
191
  return shapes.sort((a, b) => (a.in === b.in ? a.name.localeCompare(b.name) : a.in.localeCompare(b.in)));
157
192
  }
158
193
 
194
+ /** The audit is kept unless it is switched off (VOLTER_TWIN_REQUEST_JOURNAL=0): no audit is the
195
+ * setting someone chooses, never the default. */
159
196
  export function twinRequestJournalEnabled(env: Record<string, string | undefined> = process.env): boolean {
160
- return env.VOLTER_TWIN_REQUEST_JOURNAL === '1';
197
+ return env.VOLTER_TWIN_REQUEST_JOURNAL !== '0';
161
198
  }
162
199
 
163
200
  /** Where a service's request journal lives: beside its `actions.jsonl`. */
@@ -166,32 +203,40 @@ export function twinRequestJournalPath(service: string, root?: string): string {
166
203
  }
167
204
 
168
205
  /**
169
- * Append one served request to the journal — a no-op unless VOLTER_TWIN_REQUEST_JOURNAL=1, so
170
- * the default path does zero I/O. Never throws: an observability append must not fail a serve.
206
+ * Append one request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0. Never throws;
207
+ * answers whether the line was kept (true when the journal is switched off: no audit was chosen), so a
208
+ * caller that must not act unaudited (a link) can refuse.
171
209
  */
172
- export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void {
173
- if (!twinRequestJournalEnabled()) return;
210
+ export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): boolean {
211
+ if (!twinRequestJournalEnabled()) return true;
212
+ const line = JSON.stringify({
213
+ at: entry.at ?? new Date().toISOString(),
214
+ ...(entry.phase ? { phase: entry.phase } : {}),
215
+ method: entry.method.toUpperCase(),
216
+ path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
217
+ status: entry.status,
218
+ ...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
219
+ ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
220
+ });
221
+ let path: string | undefined;
174
222
  try {
175
223
  const store = getActiveWorldStore();
176
- const path = twinRequestJournalPath(service, root);
224
+ path = twinRequestJournalPath(service, root);
177
225
  store.mkdir(dirname(path));
178
- const size = store.stat(path)?.size ?? 0;
179
- if (size >= REQUEST_JOURNAL_MAX_BYTES) {
180
- // one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
181
- store.writeAtomic(`${path}.1`, store.read(path) ?? '');
226
+ if ((store.stat(path)?.size ?? 0) >= REQUEST_JOURNAL_MAX_BYTES) {
227
+ // the live file closes into the next segment; no segment is ever overwritten or removed (a crash between
228
+ // the two writes leaves the live lines in both: repeated in the next segment, never lost)
229
+ const next = Number(journalSegments(path).pop()?.slice(path.length + 1) ?? 0) + 1;
230
+ store.writeAtomic(`${path}.${next}`, store.read(path) ?? '');
182
231
  store.write(path, '');
183
232
  }
184
- const line = JSON.stringify({
185
- at: entry.at ?? new Date().toISOString(),
186
- method: entry.method.toUpperCase(),
187
- path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
188
- status: entry.status,
189
- ...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
190
- ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
191
- });
192
233
  store.append(path, `${line}\n`);
193
- } catch {
194
- // journaling is best-effort observability; the serve path must not care
234
+ return true;
235
+ } catch (error) {
236
+ const key = path ?? `${root ?? ''}/${service}`;
237
+ journalFailures.set(key, (journalFailures.get(key) ?? 0) + 1);
238
+ console.error(`[request-journal] ${service}: a line was not kept: ${(error as Error).message}`);
239
+ return false;
195
240
  }
196
241
  }
197
242
 
@@ -260,8 +305,7 @@ export function vendorFromStack(stack: string | undefined): string | undefined {
260
305
  /**
261
306
  * Wrap `Bun.serve` so EVERY twin's HTTP surface journals, with no per-pack line. Idempotent
262
307
  * (a `Symbol.for` marker survives a second copy of this module) and a strict pass-through when
263
- * VOLTER_TWIN_REQUEST_JOURNAL is not `1` — the default path adds one function call per request
264
- * and does zero I/O. Called once at module load, below; exported so a test can assert it.
308
+ * VOLTER_TWIN_REQUEST_JOURNAL=0. Called once at module load, below; exported so a test can assert it.
265
309
  *
266
310
  * A pack that knows its own identity may declare it by putting `{service, root}` on the serve
267
311
  * options under `TWIN_JOURNAL_IDENTITY` (a symbol key Bun's option reader ignores). Nothing in