@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.
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/serve.d.ts +18 -7
- package/dist/src/serve.js +73 -31
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/serve.ts +79 -35
package/dist/src/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/src/serve.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
43
|
-
* the
|
|
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):
|
|
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
|
|
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
|
|
22
|
-
|
|
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
|
|
25
|
-
for (const file of [`${path}.1`, path]) {
|
|
34
|
+
const parse = (file) => {
|
|
26
35
|
const text = getActiveWorldStore().read(file);
|
|
27
36
|
if (!text)
|
|
28
|
-
|
|
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
|
-
|
|
43
|
+
rows.push(JSON.parse(line));
|
|
34
44
|
}
|
|
35
|
-
catch { /* a torn line
|
|
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
|
|
41
|
-
*
|
|
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
|
|
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
|
|
114
|
-
* the
|
|
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
|
-
|
|
167
|
+
path = twinRequestJournalPath(service, root);
|
|
122
168
|
store.mkdir(dirname(path));
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
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:
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
|
88
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
for (const line of text.split('\n')) { if (!line) continue; try {
|
|
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
|
|
120
|
+
return kept.flat();
|
|
96
121
|
}
|
|
97
122
|
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
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
|
|
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
|
|
170
|
-
* the
|
|
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):
|
|
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
|
-
|
|
224
|
+
path = twinRequestJournalPath(service, root);
|
|
177
225
|
store.mkdir(dirname(path));
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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
|