@sigloch/graph-api-core 3.1.0 → 4.0.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.
- package/dist/audit.d.ts +47 -15
- package/dist/audit.js +69 -3
- package/package.json +2 -2
package/dist/audit.d.ts
CHANGED
|
@@ -76,13 +76,28 @@ export interface AuditEntry {
|
|
|
76
76
|
*/
|
|
77
77
|
intentTruncated?: boolean;
|
|
78
78
|
}
|
|
79
|
+
/** The read horizon of a query (CR-GC-349). */
|
|
80
|
+
export interface AuditQueryFilter {
|
|
81
|
+
consumerId?: string;
|
|
82
|
+
since?: string;
|
|
83
|
+
limit?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Read ARCHIVED segments too, not just the active log (CR-GC-349).
|
|
86
|
+
*
|
|
87
|
+
* Compaction archives by rename and deletes nothing, but a plain `query()` reads only the
|
|
88
|
+
* active file — so the day after the first compaction an aggregation over the trail reports
|
|
89
|
+
* a near-empty window while the full evidence sits untouched beside it. That is worse than a
|
|
90
|
+
* missing measurement: it is a WRONG one shaped like a result.
|
|
91
|
+
*
|
|
92
|
+
* Off by default on purpose. OCC calls `query({})` on the WRITE path and only needs the
|
|
93
|
+
* batches since a fork point, so pulling archives in there would be a slowdown with no
|
|
94
|
+
* consumer. Analysis surfaces (audit_trail / audit_stats) turn it on.
|
|
95
|
+
*/
|
|
96
|
+
includeArchived?: boolean;
|
|
97
|
+
}
|
|
79
98
|
export interface AuditLog {
|
|
80
99
|
record(entry: AuditEntry): Promise<void>;
|
|
81
|
-
query(filter:
|
|
82
|
-
consumerId?: string;
|
|
83
|
-
since?: string;
|
|
84
|
-
limit?: number;
|
|
85
|
-
}): Promise<AuditEntry[]>;
|
|
100
|
+
query(filter: AuditQueryFilter): Promise<AuditEntry[]>;
|
|
86
101
|
}
|
|
87
102
|
/**
|
|
88
103
|
* The durable operations-log surface: append/query (AuditLog) plus the event-
|
|
@@ -103,15 +118,18 @@ export interface OperationsLog extends AuditLog {
|
|
|
103
118
|
archivedTo: string | null;
|
|
104
119
|
checkpointVersion: number;
|
|
105
120
|
};
|
|
121
|
+
/**
|
|
122
|
+
* The archived segments beside the active log, oldest first (CR-GC-349).
|
|
123
|
+
*
|
|
124
|
+
* So an answer can SAY what it did not read instead of silently omitting it — the same rule
|
|
125
|
+
* as "no silent cap" on the projection side.
|
|
126
|
+
*/
|
|
127
|
+
archives(): string[];
|
|
106
128
|
}
|
|
107
129
|
export declare class InMemoryAuditLog implements AuditLog {
|
|
108
130
|
private entries;
|
|
109
131
|
record(entry: AuditEntry): Promise<void>;
|
|
110
|
-
query(filter:
|
|
111
|
-
consumerId?: string;
|
|
112
|
-
since?: string;
|
|
113
|
-
limit?: number;
|
|
114
|
-
}): Promise<AuditEntry[]>;
|
|
132
|
+
query(filter: AuditQueryFilter): Promise<AuditEntry[]>;
|
|
115
133
|
}
|
|
116
134
|
/** Auto-compaction size threshold (bytes). */
|
|
117
135
|
export declare const DEFAULT_COMPACT_BYTES: number;
|
|
@@ -131,11 +149,7 @@ export declare class FileOperationsLog implements OperationsLog {
|
|
|
131
149
|
});
|
|
132
150
|
record(entry: AuditEntry): Promise<void>;
|
|
133
151
|
/** Mirrors InMemoryAuditLog semantics: `since` inclusive, `limit` = last N. */
|
|
134
|
-
query(filter:
|
|
135
|
-
consumerId?: string;
|
|
136
|
-
since?: string;
|
|
137
|
-
limit?: number;
|
|
138
|
-
}): Promise<AuditEntry[]>;
|
|
152
|
+
query(filter: AuditQueryFilter): Promise<AuditEntry[]>;
|
|
139
153
|
/** The compaction anchor: checkpoint version, or 0 on a never-compacted log. */
|
|
140
154
|
baseVersion(): number;
|
|
141
155
|
/**
|
|
@@ -157,4 +171,22 @@ export declare class FileOperationsLog implements OperationsLog {
|
|
|
157
171
|
/** Read all parseable lines; skip a torn tail (crash mid-append) with a warning. */
|
|
158
172
|
private readLines;
|
|
159
173
|
private readEntries;
|
|
174
|
+
/**
|
|
175
|
+
* Archived segments beside the active log, OLDEST FIRST (CR-GC-349).
|
|
176
|
+
*
|
|
177
|
+
* Sorted by (stamp, collision counter) and NOT lexically. Lexical order looks right and is
|
|
178
|
+
* wrong exactly where it matters: `-` (0x2D) sorts before `.` (0x2E), so a collision-suffixed
|
|
179
|
+
* `…-329Z-1.jsonl` would come out BEFORE the `…-329Z.jsonl` it followed, silently inverting
|
|
180
|
+
* the replay order of two adjacent segments. The unsuffixed name is counter 0 because it was
|
|
181
|
+
* written first.
|
|
182
|
+
*/
|
|
183
|
+
archives(): string[];
|
|
184
|
+
/**
|
|
185
|
+
* Every entry across the archives AND the active log, in log order (CR-GC-349).
|
|
186
|
+
*
|
|
187
|
+
* An unreadable archive is SKIPPED, not fatal: the same torn-tail tolerance the active log
|
|
188
|
+
* already has. An analysis must not die on a years-old segment — losing part of the horizon
|
|
189
|
+
* is a gap, losing the answer is a defect.
|
|
190
|
+
*/
|
|
191
|
+
private readAllEntries;
|
|
160
192
|
}
|
package/dist/audit.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* survives every compaction, and torn-tail tolerant so a crash mid-append is a
|
|
17
17
|
* skipped line, never a read failure.
|
|
18
18
|
*/
|
|
19
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
export class InMemoryAuditLog {
|
|
22
22
|
entries = [];
|
|
@@ -63,7 +63,7 @@ export class FileOperationsLog {
|
|
|
63
63
|
}
|
|
64
64
|
/** Mirrors InMemoryAuditLog semantics: `since` inclusive, `limit` = last N. */
|
|
65
65
|
async query(filter) {
|
|
66
|
-
let result = this.readEntries();
|
|
66
|
+
let result = filter.includeArchived ? this.readAllEntries() : this.readEntries();
|
|
67
67
|
if (filter.consumerId)
|
|
68
68
|
result = result.filter(e => e.consumerId === filter.consumerId);
|
|
69
69
|
if (filter.since)
|
|
@@ -102,7 +102,16 @@ export class FileOperationsLog {
|
|
|
102
102
|
return { archivedTo: null, checkpointVersion: 0 };
|
|
103
103
|
const version = this.latestVersion();
|
|
104
104
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
105
|
-
|
|
105
|
+
// COLLISION GUARD (CR-GC-349). The stamp resolves to milliseconds, and `renameSync`
|
|
106
|
+
// overwrites its target silently — so two compactions inside the same millisecond used to
|
|
107
|
+
// destroy the earlier archive. Measured before the guard: 30 records written, 14 left on
|
|
108
|
+
// disk. That is data loss inside the one function whose contract is "deletes nothing";
|
|
109
|
+
// unreachable at the 10 MB default, reachable with a small `maxBytes` or concurrent binds,
|
|
110
|
+
// and exactly the shape of bug that stays invisible because the promise reads as kept.
|
|
111
|
+
let archive = this.path.replace(/\.jsonl$/, `-${stamp}.jsonl`);
|
|
112
|
+
for (let n = 1; existsSync(archive); n++) {
|
|
113
|
+
archive = this.path.replace(/\.jsonl$/, `-${stamp}-${n}.jsonl`);
|
|
114
|
+
}
|
|
106
115
|
renameSync(this.path, archive);
|
|
107
116
|
const cp = { checkpoint: true, version, timestamp: new Date().toISOString(), reason };
|
|
108
117
|
writeFileSync(this.path, JSON.stringify(cp) + '\n', 'utf8');
|
|
@@ -142,4 +151,61 @@ export class FileOperationsLog {
|
|
|
142
151
|
readEntries() {
|
|
143
152
|
return this.readLines().filter((l) => !isCheckpoint(l));
|
|
144
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Archived segments beside the active log, OLDEST FIRST (CR-GC-349).
|
|
156
|
+
*
|
|
157
|
+
* Sorted by (stamp, collision counter) and NOT lexically. Lexical order looks right and is
|
|
158
|
+
* wrong exactly where it matters: `-` (0x2D) sorts before `.` (0x2E), so a collision-suffixed
|
|
159
|
+
* `…-329Z-1.jsonl` would come out BEFORE the `…-329Z.jsonl` it followed, silently inverting
|
|
160
|
+
* the replay order of two adjacent segments. The unsuffixed name is counter 0 because it was
|
|
161
|
+
* written first.
|
|
162
|
+
*/
|
|
163
|
+
archives() {
|
|
164
|
+
const dir = dirname(this.path);
|
|
165
|
+
const base = AUDIT_BASENAME.replace(/\.jsonl$/, '');
|
|
166
|
+
const pattern = new RegExp(`^${base}-(.+?)(?:-(\\d+))?\\.jsonl$`);
|
|
167
|
+
try {
|
|
168
|
+
return readdirSync(dir)
|
|
169
|
+
.flatMap(f => {
|
|
170
|
+
const m = pattern.exec(f);
|
|
171
|
+
return m ? [{ file: f, stamp: m[1], n: m[2] ? Number(m[2]) : 0 }] : [];
|
|
172
|
+
})
|
|
173
|
+
.sort((a, b) => (a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : a.n - b.n))
|
|
174
|
+
.map(a => join(dir, a.file));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Every entry across the archives AND the active log, in log order (CR-GC-349).
|
|
182
|
+
*
|
|
183
|
+
* An unreadable archive is SKIPPED, not fatal: the same torn-tail tolerance the active log
|
|
184
|
+
* already has. An analysis must not die on a years-old segment — losing part of the horizon
|
|
185
|
+
* is a gap, losing the answer is a defect.
|
|
186
|
+
*/
|
|
187
|
+
readAllEntries() {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const archive of this.archives()) {
|
|
190
|
+
try {
|
|
191
|
+
for (const line of readFileSync(archive, 'utf8').split('\n')) {
|
|
192
|
+
if (!line.trim())
|
|
193
|
+
continue;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(line);
|
|
196
|
+
if (!isCheckpoint(parsed))
|
|
197
|
+
out.push(parsed);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// torn line in an archive — skip, same as the active log
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// unreadable archive — skip the segment, keep the answer
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
out.push(...this.readEntries());
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
145
211
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigloch/graph-api-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"prepublishOnly": "npm run build && npm run test"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@sigloch/contracts": "^
|
|
24
|
+
"@sigloch/contracts": "^5.0.0",
|
|
25
25
|
"zod": "^4.3.6"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|