@volter/world-core 2.0.19 → 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 switched 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;
@@ -41,10 +50,11 @@ export declare function twinRequestJournalEnabled(env?: Record<string, string |
41
50
  /** Where a service's request journal lives: beside its `actions.jsonl`. */
42
51
  export declare function twinRequestJournalPath(service: string, root?: string): string;
43
52
  /**
44
- * Append one served request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0.
45
- * Never throws: an 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.
46
56
  */
47
- export declare function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void;
57
+ export declare function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): boolean;
48
58
  export declare const TWIN_JOURNAL_IDENTITY: unique symbol;
49
59
  export type TwinJournalIdentity = {
50
60
  service: string;
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 switched 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
@@ -112,34 +145,42 @@ export function twinRequestJournalPath(service, root) {
112
145
  return join(dirname(worldPaths(service, root).events), 'requests.jsonl');
113
146
  }
114
147
  /**
115
- * Append one served request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0.
116
- * Never throws: an 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.
117
151
  */
118
152
  export function journalTwinRequest(service, entry, root) {
119
153
  if (!twinRequestJournalEnabled())
120
- 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;
121
165
  try {
122
166
  const store = getActiveWorldStore();
123
- const path = twinRequestJournalPath(service, root);
167
+ path = twinRequestJournalPath(service, root);
124
168
  store.mkdir(dirname(path));
125
- const size = store.stat(path)?.size ?? 0;
126
- if (size >= REQUEST_JOURNAL_MAX_BYTES) {
127
- // one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
128
- 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) ?? '');
129
174
  store.write(path, '');
130
175
  }
131
- const line = JSON.stringify({
132
- at: entry.at ?? new Date().toISOString(),
133
- method: entry.method.toUpperCase(),
134
- path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
135
- status: entry.status,
136
- ...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
137
- ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
138
- });
139
176
  store.append(path, `${line}\n`);
177
+ return true;
140
178
  }
141
- catch {
142
- // the append must not fail the serve it records
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;
143
184
  }
144
185
  }
145
186
  // ── how a wrapped `Bun.serve` learns which twin it is ──────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "2.0.19",
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
@@ -25,9 +25,11 @@ import { getActiveWorldStore } from './world-store.ts';
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
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). Size-capped:
29
- // at ~1 MB the file rotates once to `requests.jsonl.1` (previous rotation overwritten), so the
30
- // journal can never grow unbounded. `volter-world tail --requests` merges these lines live.
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 switched 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
  /**
@@ -168,32 +203,40 @@ export function twinRequestJournalPath(service: string, root?: string): string {
168
203
  }
169
204
 
170
205
  /**
171
- * Append one served request to the journal — a no-op only when VOLTER_TWIN_REQUEST_JOURNAL=0.
172
- * Never throws: an 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.
173
209
  */
174
- export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void {
175
- 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;
176
222
  try {
177
223
  const store = getActiveWorldStore();
178
- const path = twinRequestJournalPath(service, root);
224
+ path = twinRequestJournalPath(service, root);
179
225
  store.mkdir(dirname(path));
180
- const size = store.stat(path)?.size ?? 0;
181
- if (size >= REQUEST_JOURNAL_MAX_BYTES) {
182
- // one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
183
- 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) ?? '');
184
231
  store.write(path, '');
185
232
  }
186
- const line = JSON.stringify({
187
- at: entry.at ?? new Date().toISOString(),
188
- method: entry.method.toUpperCase(),
189
- path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
190
- status: entry.status,
191
- ...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
192
- ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
193
- });
194
233
  store.append(path, `${line}\n`);
195
- } catch {
196
- // the append must not fail the serve it records
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;
197
240
  }
198
241
  }
199
242