@dogfood-lab/ingest 1.2.2 → 1.3.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/persist.js CHANGED
@@ -1,172 +1,172 @@
1
- /**
2
- * Persist layer
3
- *
4
- * Writes verified records to the canonical sharded path.
5
- * Handles: accepted/rejected routing, atomic write (temp+rename),
6
- * duplicate detection by run_id, directory creation.
7
- */
8
-
9
- import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
10
- import { join, dirname } from 'node:path';
11
- import { randomBytes } from 'node:crypto';
12
-
13
- import { validateRecord } from './validate-record.js';
14
- import { isUnsafeSegment } from './lib/unsafe-segment.js';
15
-
16
- /**
17
- * Error thrown when writeRecord loses a TOCTOU race for the same canonical path.
18
- * The first concurrent writer wins; the loser sees this error.
19
- */
20
- export class DuplicateRunIdError extends Error {
21
- constructor(runId, path) {
22
- super(`duplicate run_id: ${runId} — another writer won the race for ${path}`);
23
- this.name = 'DuplicateRunIdError';
24
- this.code = 'DUPLICATE_RUN_ID';
25
- this.runId = runId;
26
- this.path = path;
27
- }
28
- }
29
-
30
- /**
31
- * Compute the canonical file path for a persisted record.
32
- *
33
- * Accepted: records/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
34
- * Rejected: records/_rejected/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
35
- *
36
- * @param {object} record - Persisted record
37
- * @param {string} repoRoot - Absolute path to dogfood-labs repo root
38
- * @returns {string} Absolute file path
39
- */
40
- export function computeRecordPath(record, repoRoot) {
41
- const status = record.verification?.status;
42
- const base = status === 'rejected' ? 'records/_rejected' : 'records';
43
-
44
- const [org, repo] = (record.repo || '').split('/');
45
- if (!org || !repo) {
46
- throw new Error(`invalid repo format: ${record.repo}`);
47
- }
48
-
49
- // Path-traversal guard: reject `..` substrings and any path separator.
50
- // Single dots are legal in GitHub org/repo names (e.g. `next.js`,
51
- // `mcp-tool-shop.github.io`) and the submission schema's repo pattern
52
- // `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` allows them. Centralized in
53
- // ./lib/unsafe-segment.js so all three callsites (persist, load-context,
54
- // findings/derive/load-records) agree by import — F-916867-005.
55
- if (isUnsafeSegment(org) || isUnsafeSegment(repo)) {
56
- throw new Error(`unsafe repo segment: ${record.repo}`);
57
- }
58
-
59
- if (!/^[\w-]+$/.test(record.run_id)) {
60
- throw new Error(`unsafe run_id: ${record.run_id}`);
61
- }
62
-
63
- const finishedAt = record.timing?.finished_at;
64
- if (!finishedAt) {
65
- throw new Error('record missing timing.finished_at');
66
- }
67
-
68
- const date = new Date(finishedAt);
69
- if (isNaN(date.getTime())) {
70
- throw new Error('Invalid finished_at timestamp');
71
- }
72
- const year = String(date.getUTCFullYear());
73
- const month = String(date.getUTCMonth() + 1).padStart(2, '0');
74
- const day = String(date.getUTCDate()).padStart(2, '0');
75
-
76
- const filename = `run-${record.run_id}.json`;
77
-
78
- return join(repoRoot, base, org, repo, year, month, day, filename);
79
- }
80
-
81
- /**
82
- * Check if a record with this run_id already exists (accepted or rejected).
83
- *
84
- * @param {string} runId
85
- * @param {object} record - The record (used for repo/timing to compute path)
86
- * @param {string} repoRoot
87
- * @returns {boolean}
88
- */
89
- export function isDuplicate(runId, record, repoRoot) {
90
- // Check accepted path
91
- const acceptedRecord = { ...record, verification: { ...record.verification, status: 'accepted' } };
92
- const acceptedPath = computeRecordPath(acceptedRecord, repoRoot);
93
- if (existsSync(acceptedPath)) return true;
94
-
95
- // Check rejected path
96
- const rejectedRecord = { ...record, verification: { ...record.verification, status: 'rejected' } };
97
- const rejectedPath = computeRecordPath(rejectedRecord, repoRoot);
98
- if (existsSync(rejectedPath)) return true;
99
-
100
- return false;
101
- }
102
-
103
- /**
104
- * Write a record atomically: write to temp file, then exclusive-rename into place.
105
- *
106
- * Race semantics: the canonical path is created via `open(path, 'wx')` (exclusive
107
- * create — fails if the path exists). Two concurrent ingests for the same run_id
108
- * can both pass `isDuplicate` (no file yet); the FIRST `open(wx)` wins and the
109
- * SECOND throws `DuplicateRunIdError` instead of silently overwriting. The
110
- * temp+rename pattern still provides crash-atomicity — the canonical file is
111
- * either fully written or absent, never partial.
112
- *
113
- * Why not just `existsSync` then `writeFileSync`? That's the original race —
114
- * the existsSync check and the write are not atomic. `open(wx)` collapses both
115
- * into a single OS-level call.
116
- *
117
- * @param {object} record - Persisted record
118
- * @param {string} repoRoot - Absolute path to dogfood-labs repo root
119
- * @returns {{ path: string, written: boolean }} path and whether a write occurred
120
- * @throws {DuplicateRunIdError} when a concurrent writer won the race
121
- */
122
- export function writeRecord(record, repoRoot) {
123
- if (isDuplicate(record.run_id, record, repoRoot)) {
124
- const path = computeRecordPath(record, repoRoot);
125
- return { path, written: false };
126
- }
127
-
128
- // Enforce dogfood-record.schema.json BEFORE touching the filesystem.
129
- // Better to throw loudly than silently persist a malformed record — the
130
- // schema is the contract every downstream consumer relies on.
131
- validateRecord(record);
132
-
133
- const path = computeRecordPath(record, repoRoot);
134
- const dir = dirname(path);
135
-
136
- mkdirSync(dir, { recursive: true });
137
-
138
- // Race-safe atomic create: try to claim the canonical path with O_EXCL first.
139
- // If another writer already won the race, fail closed with DuplicateRunIdError
140
- // — never silently overwrite. On success, hold an empty file we'll fill via
141
- // temp+rename so the visible bytes are still atomic.
142
- let claimed = false;
143
- try {
144
- const fd = openSync(path, 'wx');
145
- closeSync(fd);
146
- claimed = true;
147
- } catch (err) {
148
- if (err && err.code === 'EEXIST') {
149
- throw new DuplicateRunIdError(record.run_id, path);
150
- }
151
- throw err;
152
- }
153
-
154
- // Atomic write: temp file → rename over the empty placeholder.
155
- const tmpSuffix = randomBytes(4).toString('hex');
156
- const tmpPath = `${path}.${tmpSuffix}.tmp`;
157
-
158
- try {
159
- writeFileSync(tmpPath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
160
- renameSync(tmpPath, path);
161
- } catch (err) {
162
- // On any failure after we claimed the path, release the claim so a retry
163
- // can succeed. The tmp file is best-effort cleanup.
164
- if (claimed) {
165
- try { unlinkSync(path); } catch { /* placeholder already gone */ }
166
- }
167
- try { unlinkSync(tmpPath); } catch { /* tmp may not exist */ }
168
- throw err;
169
- }
170
-
171
- return { path, written: true };
172
- }
1
+ /**
2
+ * Persist layer
3
+ *
4
+ * Writes verified records to the canonical sharded path.
5
+ * Handles: accepted/rejected routing, atomic write (temp+rename),
6
+ * duplicate detection by run_id, directory creation.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
11
+ import { randomBytes } from 'node:crypto';
12
+
13
+ import { validateRecord } from './validate-record.js';
14
+ import { isUnsafeSegment } from './lib/unsafe-segment.js';
15
+
16
+ /**
17
+ * Error thrown when writeRecord loses a TOCTOU race for the same canonical path.
18
+ * The first concurrent writer wins; the loser sees this error.
19
+ */
20
+ export class DuplicateRunIdError extends Error {
21
+ constructor(runId, path) {
22
+ super(`duplicate run_id: ${runId} — another writer won the race for ${path}`);
23
+ this.name = 'DuplicateRunIdError';
24
+ this.code = 'DUPLICATE_RUN_ID';
25
+ this.runId = runId;
26
+ this.path = path;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Compute the canonical file path for a persisted record.
32
+ *
33
+ * Accepted: records/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
34
+ * Rejected: records/_rejected/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
35
+ *
36
+ * @param {object} record - Persisted record
37
+ * @param {string} repoRoot - Absolute path to dogfood-labs repo root
38
+ * @returns {string} Absolute file path
39
+ */
40
+ export function computeRecordPath(record, repoRoot) {
41
+ const status = record.verification?.status;
42
+ const base = status === 'rejected' ? 'records/_rejected' : 'records';
43
+
44
+ const [org, repo] = (record.repo || '').split('/');
45
+ if (!org || !repo) {
46
+ throw new Error(`invalid repo format: ${record.repo}`);
47
+ }
48
+
49
+ // Path-traversal guard: reject `..` substrings and any path separator.
50
+ // Single dots are legal in GitHub org/repo names (e.g. `next.js`,
51
+ // `mcp-tool-shop.github.io`) and the submission schema's repo pattern
52
+ // `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` allows them. Centralized in
53
+ // ./lib/unsafe-segment.js so all three callsites (persist, load-context,
54
+ // findings/derive/load-records) agree by import — F-916867-005.
55
+ if (isUnsafeSegment(org) || isUnsafeSegment(repo)) {
56
+ throw new Error(`unsafe repo segment: ${record.repo}`);
57
+ }
58
+
59
+ if (!/^[\w-]+$/.test(record.run_id)) {
60
+ throw new Error(`unsafe run_id: ${record.run_id}`);
61
+ }
62
+
63
+ const finishedAt = record.timing?.finished_at;
64
+ if (!finishedAt) {
65
+ throw new Error('record missing timing.finished_at');
66
+ }
67
+
68
+ const date = new Date(finishedAt);
69
+ if (isNaN(date.getTime())) {
70
+ throw new Error('Invalid finished_at timestamp');
71
+ }
72
+ const year = String(date.getUTCFullYear());
73
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
74
+ const day = String(date.getUTCDate()).padStart(2, '0');
75
+
76
+ const filename = `run-${record.run_id}.json`;
77
+
78
+ return join(repoRoot, base, org, repo, year, month, day, filename);
79
+ }
80
+
81
+ /**
82
+ * Check if a record with this run_id already exists (accepted or rejected).
83
+ *
84
+ * @param {string} runId
85
+ * @param {object} record - The record (used for repo/timing to compute path)
86
+ * @param {string} repoRoot
87
+ * @returns {boolean}
88
+ */
89
+ export function isDuplicate(runId, record, repoRoot) {
90
+ // Check accepted path
91
+ const acceptedRecord = { ...record, verification: { ...record.verification, status: 'accepted' } };
92
+ const acceptedPath = computeRecordPath(acceptedRecord, repoRoot);
93
+ if (existsSync(acceptedPath)) return true;
94
+
95
+ // Check rejected path
96
+ const rejectedRecord = { ...record, verification: { ...record.verification, status: 'rejected' } };
97
+ const rejectedPath = computeRecordPath(rejectedRecord, repoRoot);
98
+ if (existsSync(rejectedPath)) return true;
99
+
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Write a record atomically: write to temp file, then exclusive-rename into place.
105
+ *
106
+ * Race semantics: the canonical path is created via `open(path, 'wx')` (exclusive
107
+ * create — fails if the path exists). Two concurrent ingests for the same run_id
108
+ * can both pass `isDuplicate` (no file yet); the FIRST `open(wx)` wins and the
109
+ * SECOND throws `DuplicateRunIdError` instead of silently overwriting. The
110
+ * temp+rename pattern still provides crash-atomicity — the canonical file is
111
+ * either fully written or absent, never partial.
112
+ *
113
+ * Why not just `existsSync` then `writeFileSync`? That's the original race —
114
+ * the existsSync check and the write are not atomic. `open(wx)` collapses both
115
+ * into a single OS-level call.
116
+ *
117
+ * @param {object} record - Persisted record
118
+ * @param {string} repoRoot - Absolute path to dogfood-labs repo root
119
+ * @returns {{ path: string, written: boolean }} path and whether a write occurred
120
+ * @throws {DuplicateRunIdError} when a concurrent writer won the race
121
+ */
122
+ export function writeRecord(record, repoRoot) {
123
+ if (isDuplicate(record.run_id, record, repoRoot)) {
124
+ const path = computeRecordPath(record, repoRoot);
125
+ return { path, written: false };
126
+ }
127
+
128
+ // Enforce dogfood-record.schema.json BEFORE touching the filesystem.
129
+ // Better to throw loudly than silently persist a malformed record — the
130
+ // schema is the contract every downstream consumer relies on.
131
+ validateRecord(record);
132
+
133
+ const path = computeRecordPath(record, repoRoot);
134
+ const dir = dirname(path);
135
+
136
+ mkdirSync(dir, { recursive: true });
137
+
138
+ // Race-safe atomic create: try to claim the canonical path with O_EXCL first.
139
+ // If another writer already won the race, fail closed with DuplicateRunIdError
140
+ // — never silently overwrite. On success, hold an empty file we'll fill via
141
+ // temp+rename so the visible bytes are still atomic.
142
+ let claimed = false;
143
+ try {
144
+ const fd = openSync(path, 'wx');
145
+ closeSync(fd);
146
+ claimed = true;
147
+ } catch (err) {
148
+ if (err && err.code === 'EEXIST') {
149
+ throw new DuplicateRunIdError(record.run_id, path);
150
+ }
151
+ throw err;
152
+ }
153
+
154
+ // Atomic write: temp file → rename over the empty placeholder.
155
+ const tmpSuffix = randomBytes(4).toString('hex');
156
+ const tmpPath = `${path}.${tmpSuffix}.tmp`;
157
+
158
+ try {
159
+ writeFileSync(tmpPath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
160
+ renameSync(tmpPath, path);
161
+ } catch (err) {
162
+ // On any failure after we claimed the path, release the claim so a retry
163
+ // can succeed. The tmp file is best-effort cleanup.
164
+ if (claimed) {
165
+ try { unlinkSync(path); } catch { /* placeholder already gone */ }
166
+ }
167
+ try { unlinkSync(tmpPath); } catch { /* tmp may not exist */ }
168
+ throw err;
169
+ }
170
+
171
+ return { path, written: true };
172
+ }