@sigloch/graph-api-core 3.1.0 → 3.2.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 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
- const archive = this.path.replace(/\.jsonl$/, `-${stamp}.jsonl`);
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.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",