@jinn-network/core 0.1.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/canonical-json.d.ts +1 -0
- package/dist/canonical-json.js +67 -0
- package/dist/captured-task.d.ts +140 -0
- package/dist/captured-task.js +122 -0
- package/dist/contribution-store.d.ts +97 -0
- package/dist/contribution-store.js +770 -0
- package/dist/corpus-read/acquire.d.ts +39 -0
- package/dist/corpus-read/acquire.js +212 -0
- package/dist/corpus-read/cache.d.ts +14 -0
- package/dist/corpus-read/cache.js +17 -0
- package/dist/corpus-read/capture-meta.d.ts +16 -0
- package/dist/corpus-read/capture-meta.js +18 -0
- package/dist/corpus-read/create-corpus.d.ts +2 -0
- package/dist/corpus-read/create-corpus.js +97 -0
- package/dist/corpus-read/fetch-artifact.d.ts +21 -0
- package/dist/corpus-read/fetch-artifact.js +32 -0
- package/dist/corpus-read/fetch.d.ts +12 -0
- package/dist/corpus-read/fetch.js +24 -0
- package/dist/corpus-read/http-discovery.d.ts +9 -0
- package/dist/corpus-read/http-discovery.js +128 -0
- package/dist/corpus-read/index.d.ts +10 -0
- package/dist/corpus-read/index.js +10 -0
- package/dist/corpus-read/ipfs.d.ts +5 -0
- package/dist/corpus-read/ipfs.js +72 -0
- package/dist/corpus-read/route-resolver.d.ts +16 -0
- package/dist/corpus-read/route-resolver.js +19 -0
- package/dist/corpus-read/types.d.ts +177 -0
- package/dist/corpus-read/types.js +42 -0
- package/dist/envelope.d.ts +134 -0
- package/dist/envelope.js +162 -0
- package/dist/evidence-adapter.d.ts +26 -0
- package/dist/evidence-adapter.js +321 -0
- package/dist/evidence-filesystem.d.ts +40 -0
- package/dist/evidence-filesystem.js +267 -0
- package/dist/evidence-index.d.ts +117 -0
- package/dist/evidence-index.js +1083 -0
- package/dist/evidence-store-lock.d.ts +2 -0
- package/dist/evidence-store-lock.js +208 -0
- package/dist/execution-envelope.d.ts +3142 -0
- package/dist/execution-envelope.js +203 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/manifest.d.ts +75 -0
- package/dist/manifest.js +184 -0
- package/dist/paired.d.ts +68 -0
- package/dist/paired.js +92 -0
- package/dist/scrub/build.d.ts +52 -0
- package/dist/scrub/build.js +84 -0
- package/dist/scrub/emit-scrub.d.ts +41 -0
- package/dist/scrub/emit-scrub.js +87 -0
- package/dist/scrub/index.d.ts +12 -0
- package/dist/scrub/index.js +12 -0
- package/dist/scrub/key-policy.d.ts +20 -0
- package/dist/scrub/key-policy.js +42 -0
- package/dist/scrub/layer2.d.ts +23 -0
- package/dist/scrub/layer2.js +32 -0
- package/dist/scrub/ml-pii-stage.d.ts +24 -0
- package/dist/scrub/ml-pii-stage.js +42 -0
- package/dist/scrub/openredaction-stage.d.ts +196 -0
- package/dist/scrub/openredaction-stage.js +270 -0
- package/dist/scrub/pii-build.d.ts +38 -0
- package/dist/scrub/pii-build.js +67 -0
- package/dist/scrub/pipeline.d.ts +38 -0
- package/dist/scrub/pipeline.js +83 -0
- package/dist/scrub/plain-patterns-stage.d.ts +31 -0
- package/dist/scrub/plain-patterns-stage.js +84 -0
- package/dist/scrub/secretlint-stage.d.ts +36 -0
- package/dist/scrub/secretlint-stage.js +249 -0
- package/dist/scrub/transformers-detector.d.ts +35 -0
- package/dist/scrub/transformers-detector.js +67 -0
- package/dist/scrub/types.d.ts +25 -0
- package/dist/scrub/types.js +1 -0
- package/dist/session-provenance.d.ts +75 -0
- package/dist/session-provenance.js +20 -0
- package/dist/skill-artifact.d.ts +442 -0
- package/dist/skill-artifact.js +136 -0
- package/dist/trajectory/hash-chain.d.ts +18 -0
- package/dist/trajectory/hash-chain.js +47 -0
- package/dist/trajectory/index.d.ts +4 -0
- package/dist/trajectory/index.js +4 -0
- package/dist/trajectory/schema.d.ts +606 -0
- package/dist/trajectory/schema.js +110 -0
- package/dist/trajectory/transcript-parsers/aider-history.d.ts +42 -0
- package/dist/trajectory/transcript-parsers/aider-history.js +181 -0
- package/dist/trajectory/transcript-parsers/claude-code-jsonl.d.ts +37 -0
- package/dist/trajectory/transcript-parsers/claude-code-jsonl.js +142 -0
- package/dist/trajectory/transcript-parsers/codex-session.d.ts +42 -0
- package/dist/trajectory/transcript-parsers/codex-session.js +235 -0
- package/dist/trajectory/transcript-parsers/continue-devdata.d.ts +41 -0
- package/dist/trajectory/transcript-parsers/continue-devdata.js +190 -0
- package/dist/trajectory/transcript-parsers/cursor-sqlite.d.ts +38 -0
- package/dist/trajectory/transcript-parsers/cursor-sqlite.js +118 -0
- package/dist/trajectory/transcript-parsers/gemini-session.d.ts +35 -0
- package/dist/trajectory/transcript-parsers/gemini-session.js +137 -0
- package/dist/trajectory/transcript-parsers/index.d.ts +7 -0
- package/dist/trajectory/transcript-parsers/index.js +7 -0
- package/dist/trajectory/transcript-parsers/types.d.ts +101 -0
- package/dist/trajectory/transcript-parsers/types.js +32 -0
- package/dist/trajectory/transcript-to-spans/attrs.d.ts +4 -0
- package/dist/trajectory/transcript-to-spans/attrs.js +30 -0
- package/dist/trajectory/transcript-to-spans/claude-code-stream-json.d.ts +8 -0
- package/dist/trajectory/transcript-to-spans/claude-code-stream-json.js +162 -0
- package/dist/trajectory/transcript-to-spans/codex-exec-json.d.ts +8 -0
- package/dist/trajectory/transcript-to-spans/codex-exec-json.js +253 -0
- package/dist/trajectory/transcript-to-spans/index.d.ts +4 -0
- package/dist/trajectory/transcript-to-spans/index.js +4 -0
- package/dist/trajectory/transcript-to-spans/types.d.ts +18 -0
- package/dist/trajectory/transcript-to-spans/types.js +1 -0
- package/dist/window.d.ts +12 -0
- package/dist/window.js +5 -0
- package/package.json +67 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { access, chmod, link, mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
5
|
+
import { ContributionCandidateV1Schema, ContributionCandidateV1ProjectionSchema, EpisodeV1Schema, } from '@jinn-network/plugin';
|
|
6
|
+
import { fsyncDirectory } from './evidence-filesystem.js';
|
|
7
|
+
export const CONTRIBUTION_STORE_SCHEMA_VERSION = 'jinn.contribution-store.v3';
|
|
8
|
+
const CONTRIBUTION_STORE_V2_SCHEMA_VERSION = 'jinn.contribution-store.v2';
|
|
9
|
+
export const CONTRIBUTION_STORE_FILE = 'mineable-traces.json';
|
|
10
|
+
export const CONTRIBUTION_PUBLICATION_DISABLED_FILE = 'publication-disabled';
|
|
11
|
+
export const CONTRIBUTION_PUBLICATION_SCHEMA_VERSION = 'jinn.contribution-publication.v1';
|
|
12
|
+
export const DEFAULT_CONTRIBUTION_STATE_DIR = join(homedir(), '.jinn-client', 'mineable');
|
|
13
|
+
export function resolveContributionStateDir(env = process.env) {
|
|
14
|
+
const configured = env.JINN_MINEABLE_STATE_DIR?.trim();
|
|
15
|
+
return configured || DEFAULT_CONTRIBUTION_STATE_DIR;
|
|
16
|
+
}
|
|
17
|
+
const DEFAULT_LOCK = {
|
|
18
|
+
retryDelayMs: 20,
|
|
19
|
+
maxRetries: 50,
|
|
20
|
+
staleAfterMs: 120_000,
|
|
21
|
+
};
|
|
22
|
+
const localLockTails = new Map();
|
|
23
|
+
function sleep(ms) {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
}
|
|
26
|
+
function nodeErrorCode(error) {
|
|
27
|
+
return error && typeof error === 'object' && 'code' in error
|
|
28
|
+
? String(error.code)
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
function freshStore() {
|
|
32
|
+
return { schemaVersion: CONTRIBUTION_STORE_SCHEMA_VERSION, records: Object.create(null) };
|
|
33
|
+
}
|
|
34
|
+
function contributionRecordMap(entries) {
|
|
35
|
+
const records = Object.create(null);
|
|
36
|
+
for (const [recordId, record] of entries)
|
|
37
|
+
records[recordId] = record;
|
|
38
|
+
return records;
|
|
39
|
+
}
|
|
40
|
+
function ownRecord(file, recordId) {
|
|
41
|
+
return Object.prototype.hasOwnProperty.call(file.records, recordId)
|
|
42
|
+
? file.records[recordId]
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
function optionalNonEmptyString(value, field) {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
49
|
+
throw new Error(`invalid contribution store ${field}`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
function parseLocalState(value) {
|
|
54
|
+
if (value === 'recorded' || value === 'minted' || value === 'rejected')
|
|
55
|
+
return value;
|
|
56
|
+
throw new Error(`invalid contribution local state: ${String(value)}`);
|
|
57
|
+
}
|
|
58
|
+
function parsePublicationState(value) {
|
|
59
|
+
if (value === 'disabled' ||
|
|
60
|
+
value === 'preview-required' ||
|
|
61
|
+
value === 'queued' ||
|
|
62
|
+
value === 'published' ||
|
|
63
|
+
value === 'vetoed')
|
|
64
|
+
return value;
|
|
65
|
+
throw new Error(`invalid contribution publication state: ${String(value)}`);
|
|
66
|
+
}
|
|
67
|
+
function parseRecord(recordId, value) {
|
|
68
|
+
if (!value || typeof value !== 'object')
|
|
69
|
+
throw new Error(`invalid contribution record ${recordId}`);
|
|
70
|
+
const raw = value;
|
|
71
|
+
if (raw['recordId'] !== recordId)
|
|
72
|
+
throw new Error(`contribution record key/id mismatch: ${recordId}`);
|
|
73
|
+
const allowed = new Set([
|
|
74
|
+
'recordId',
|
|
75
|
+
'localState',
|
|
76
|
+
'publicationState',
|
|
77
|
+
'mintRef',
|
|
78
|
+
'publicationRef',
|
|
79
|
+
'rejectionReason',
|
|
80
|
+
]);
|
|
81
|
+
const unknown = Object.keys(raw).filter((key) => !allowed.has(key));
|
|
82
|
+
if (unknown.length > 0) {
|
|
83
|
+
throw new Error(`invalid contribution record ${recordId}: unexpected fields ${unknown.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
const mintRef = optionalNonEmptyString(raw['mintRef'], 'mintRef');
|
|
86
|
+
const publicationRef = optionalNonEmptyString(raw['publicationRef'], 'publicationRef');
|
|
87
|
+
const rejectionReason = optionalNonEmptyString(raw['rejectionReason'], 'rejectionReason');
|
|
88
|
+
return {
|
|
89
|
+
recordId,
|
|
90
|
+
localState: parseLocalState(raw['localState']),
|
|
91
|
+
publicationState: parsePublicationState(raw['publicationState']),
|
|
92
|
+
...(mintRef ? { mintRef } : {}),
|
|
93
|
+
...(publicationRef ? { publicationRef } : {}),
|
|
94
|
+
...(rejectionReason ? { rejectionReason } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function parseV3File(raw) {
|
|
98
|
+
if (!raw || typeof raw !== 'object')
|
|
99
|
+
throw new Error('invalid contribution store: expected object');
|
|
100
|
+
const value = raw;
|
|
101
|
+
if (value['schemaVersion'] !== CONTRIBUTION_STORE_SCHEMA_VERSION) {
|
|
102
|
+
throw new Error(`unsupported contribution store schema: ${String(value['schemaVersion'])}`);
|
|
103
|
+
}
|
|
104
|
+
if (!value['records'] || typeof value['records'] !== 'object' || Array.isArray(value['records'])) {
|
|
105
|
+
throw new Error('invalid contribution store records');
|
|
106
|
+
}
|
|
107
|
+
const previewAt = optionalNonEmptyString(value['firstSharePreviewAcknowledgedAt'], 'firstSharePreviewAcknowledgedAt');
|
|
108
|
+
return {
|
|
109
|
+
schemaVersion: CONTRIBUTION_STORE_SCHEMA_VERSION,
|
|
110
|
+
...(previewAt ? { firstSharePreviewAcknowledgedAt: previewAt } : {}),
|
|
111
|
+
records: contributionRecordMap(Object.entries(value['records'])
|
|
112
|
+
.map(([id, record]) => [id, parseRecord(id, record)])),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function migrateLegacyRecord(recordId, rawValue) {
|
|
116
|
+
if (!rawValue || typeof rawValue !== 'object')
|
|
117
|
+
throw new Error(`invalid v1 record ${recordId}`);
|
|
118
|
+
const raw = rawValue;
|
|
119
|
+
const sourceId = typeof raw['sourceId'] === 'string' && raw['sourceId'].length > 0
|
|
120
|
+
? raw['sourceId']
|
|
121
|
+
: recordId;
|
|
122
|
+
if (sourceId !== recordId) {
|
|
123
|
+
throw new Error(`v1 contribution record key/sourceId mismatch: ${recordId}`);
|
|
124
|
+
}
|
|
125
|
+
// Validate the payload before preserving it in the loss-safe backup only.
|
|
126
|
+
ContributionCandidateV1Schema.parse({
|
|
127
|
+
schemaVersion: 'jinn.contribution-candidate.v1',
|
|
128
|
+
sourceId,
|
|
129
|
+
repositorySlug: raw['repo'],
|
|
130
|
+
baseCommit: raw['baseCommit'],
|
|
131
|
+
acceptedDiff: raw['acceptedDiff'],
|
|
132
|
+
testRuns: Array.isArray(raw['testRuns']) ? raw['testRuns'].map((run) => {
|
|
133
|
+
const value = run;
|
|
134
|
+
return { command: value['command'] ?? value['cmd'], exitCode: value['exitCode'], at: value['at'] };
|
|
135
|
+
}) : [],
|
|
136
|
+
intermediateFailureDiffs: Array.isArray(raw['intermediateFailureDiffs'])
|
|
137
|
+
? raw['intermediateFailureDiffs']
|
|
138
|
+
: [],
|
|
139
|
+
skillEvents: Array.isArray(raw['skillEvents']) ? raw['skillEvents'].map((event) => {
|
|
140
|
+
const value = event;
|
|
141
|
+
return { skillRef: value['skillRef'] ?? value['skill'], action: value['action'] };
|
|
142
|
+
}) : [],
|
|
143
|
+
// Legacy consent never becomes durable v2 publication authorization.
|
|
144
|
+
publishMinedTasksConsent: false,
|
|
145
|
+
createdAt: raw['createdAt'],
|
|
146
|
+
});
|
|
147
|
+
const processed = raw['mined'] === true;
|
|
148
|
+
return {
|
|
149
|
+
recordId,
|
|
150
|
+
localState: processed ? 'rejected' : 'recorded',
|
|
151
|
+
publicationState: 'disabled',
|
|
152
|
+
...(processed ? { rejectionReason: 'legacy-processed-unavailable' } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function migrateV1File(raw) {
|
|
156
|
+
if (!raw || typeof raw !== 'object')
|
|
157
|
+
throw new Error('invalid v1 mineable store: expected object');
|
|
158
|
+
const value = raw;
|
|
159
|
+
if (value['schemaVersion'] !== 'mineable-trace-store.v1') {
|
|
160
|
+
throw new Error(`unsupported contribution store schema: ${String(value['schemaVersion'])}`);
|
|
161
|
+
}
|
|
162
|
+
if (!value['records'] || typeof value['records'] !== 'object' || Array.isArray(value['records'])) {
|
|
163
|
+
throw new Error('invalid v1 mineable store records');
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
schemaVersion: CONTRIBUTION_STORE_SCHEMA_VERSION,
|
|
167
|
+
records: contributionRecordMap(Object.entries(value['records'])
|
|
168
|
+
.map(([id, record]) => [id, migrateLegacyRecord(id, record)])),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function migrateV2Record(recordId, rawValue) {
|
|
172
|
+
if (!rawValue || typeof rawValue !== 'object')
|
|
173
|
+
throw new Error(`invalid v2 record ${recordId}`);
|
|
174
|
+
const raw = rawValue;
|
|
175
|
+
if (raw['recordId'] !== recordId) {
|
|
176
|
+
throw new Error(`v2 contribution record key/id mismatch: ${recordId}`);
|
|
177
|
+
}
|
|
178
|
+
const candidate = ContributionCandidateV1Schema.parse(raw['candidate']);
|
|
179
|
+
if (candidate.sourceId !== recordId) {
|
|
180
|
+
throw new Error(`v2 contribution record key/sourceId mismatch: ${recordId}`);
|
|
181
|
+
}
|
|
182
|
+
const publicationState = parsePublicationState(raw['publicationState']);
|
|
183
|
+
const mintRef = optionalNonEmptyString(raw['mintRef'], 'mintRef');
|
|
184
|
+
const publicationRef = optionalNonEmptyString(raw['publicationRef'], 'publicationRef');
|
|
185
|
+
const rejectionReason = optionalNonEmptyString(raw['rejectionReason'], 'rejectionReason');
|
|
186
|
+
return {
|
|
187
|
+
recordId,
|
|
188
|
+
localState: parseLocalState(raw['localState']),
|
|
189
|
+
publicationState: publicationState === 'preview-required' || publicationState === 'queued'
|
|
190
|
+
? 'disabled'
|
|
191
|
+
: publicationState,
|
|
192
|
+
...(mintRef ? { mintRef } : {}),
|
|
193
|
+
...(publicationRef ? { publicationRef } : {}),
|
|
194
|
+
...(rejectionReason ? { rejectionReason } : {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function migrateV2File(raw) {
|
|
198
|
+
if (!raw || typeof raw !== 'object')
|
|
199
|
+
throw new Error('invalid v2 contribution store: expected object');
|
|
200
|
+
const value = raw;
|
|
201
|
+
if (value['schemaVersion'] !== CONTRIBUTION_STORE_V2_SCHEMA_VERSION) {
|
|
202
|
+
throw new Error(`unsupported contribution store schema: ${String(value['schemaVersion'])}`);
|
|
203
|
+
}
|
|
204
|
+
if (!value['records'] || typeof value['records'] !== 'object' || Array.isArray(value['records'])) {
|
|
205
|
+
throw new Error('invalid v2 contribution store records');
|
|
206
|
+
}
|
|
207
|
+
const previewAt = optionalNonEmptyString(value['firstSharePreviewAcknowledgedAt'], 'firstSharePreviewAcknowledgedAt');
|
|
208
|
+
return {
|
|
209
|
+
schemaVersion: CONTRIBUTION_STORE_SCHEMA_VERSION,
|
|
210
|
+
...(previewAt ? { firstSharePreviewAcknowledgedAt: previewAt } : {}),
|
|
211
|
+
records: contributionRecordMap(Object.entries(value['records'])
|
|
212
|
+
.map(([id, record]) => [id, migrateV2Record(id, record)])),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Cacheless, cross-process-safe contribution store. Every mutation acquires a
|
|
217
|
+
* mkdir-based lock and performs a fresh read-modify-atomic-rename cycle.
|
|
218
|
+
*/
|
|
219
|
+
export class ContributionStore {
|
|
220
|
+
filePath;
|
|
221
|
+
lockPath;
|
|
222
|
+
publicationLockPath;
|
|
223
|
+
publicationDisabledPath;
|
|
224
|
+
lock;
|
|
225
|
+
publicationLock;
|
|
226
|
+
nowMs;
|
|
227
|
+
constructor(options) {
|
|
228
|
+
if (!options.filePath && !options.stateDir) {
|
|
229
|
+
throw new Error('ContributionStore requires stateDir or filePath');
|
|
230
|
+
}
|
|
231
|
+
this.filePath = resolvePath(options.filePath ?? join(options.stateDir, CONTRIBUTION_STORE_FILE));
|
|
232
|
+
this.lockPath = `${this.filePath}.lock`;
|
|
233
|
+
this.publicationLockPath = `${this.filePath}.publication.lock`;
|
|
234
|
+
this.publicationDisabledPath = join(dirname(this.filePath), CONTRIBUTION_PUBLICATION_DISABLED_FILE);
|
|
235
|
+
this.lock = { ...DEFAULT_LOCK, ...options.lock };
|
|
236
|
+
this.publicationLock = {
|
|
237
|
+
...DEFAULT_LOCK,
|
|
238
|
+
maxRetries: 3_000,
|
|
239
|
+
...options.publicationLock,
|
|
240
|
+
};
|
|
241
|
+
this.nowMs = options.nowMs ?? Date.now;
|
|
242
|
+
}
|
|
243
|
+
async tryRecoverStaleLock(lockPath, options) {
|
|
244
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
245
|
+
const recoveryOwner = await this.acquireRecoveryGuard(recoveryPath, options);
|
|
246
|
+
if (!recoveryOwner)
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
let lockStat;
|
|
250
|
+
try {
|
|
251
|
+
lockStat = await stat(lockPath);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
255
|
+
return true;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
if (this.nowMs() - lockStat.mtimeMs <= options.staleAfterMs)
|
|
259
|
+
return false;
|
|
260
|
+
let ownerAlive = false;
|
|
261
|
+
try {
|
|
262
|
+
const owner = JSON.parse(await readFile(join(lockPath, 'owner.json'), 'utf8'));
|
|
263
|
+
if (typeof owner.pid === 'number' && Number.isInteger(owner.pid)) {
|
|
264
|
+
try {
|
|
265
|
+
process.kill(owner.pid, 0);
|
|
266
|
+
ownerAlive = true;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
ownerAlive = nodeErrorCode(error) === 'EPERM';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// An ownerless stale directory is safe to recover while this exclusive
|
|
275
|
+
// recovery lock prevents a second contender deleting its successor.
|
|
276
|
+
}
|
|
277
|
+
if (ownerAlive)
|
|
278
|
+
return false;
|
|
279
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
try {
|
|
284
|
+
await access(join(recoveryPath, recoveryOwner));
|
|
285
|
+
await rm(recoveryPath, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// A guard may only disappear after a later stale owner claimed it.
|
|
289
|
+
// Never remove a recovery directory we can no longer prove is ours.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
ownerProcessAlive(pid) {
|
|
294
|
+
try {
|
|
295
|
+
process.kill(pid, 0);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
return nodeErrorCode(error) === 'EPERM';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
recoveryOwnerName(kind) {
|
|
303
|
+
return `${kind}.${Math.trunc(this.nowMs())}.${process.pid}.${randomUUID()}.json`;
|
|
304
|
+
}
|
|
305
|
+
recoveryOwnerMetadata(name) {
|
|
306
|
+
const match = /^(?:owner|claim)\.(-?\d+)\.(\d+)\.[0-9a-f-]+\.json$/u.exec(name);
|
|
307
|
+
if (!match)
|
|
308
|
+
return null;
|
|
309
|
+
const createdAtMs = Number(match[1]);
|
|
310
|
+
const pid = Number(match[2]);
|
|
311
|
+
return Number.isFinite(createdAtMs) && Number.isInteger(pid) ? { createdAtMs, pid } : null;
|
|
312
|
+
}
|
|
313
|
+
async acquireRecoveryGuard(recoveryPath, options) {
|
|
314
|
+
const ownerName = this.recoveryOwnerName('owner');
|
|
315
|
+
try {
|
|
316
|
+
await mkdir(recoveryPath, { mode: 0o700 });
|
|
317
|
+
await writeFile(join(recoveryPath, ownerName), '', { flag: 'wx', mode: 0o600 });
|
|
318
|
+
return ownerName;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (nodeErrorCode(error) !== 'EEXIST') {
|
|
322
|
+
// If mkdir succeeded but the owner write did not, leave an ownerless
|
|
323
|
+
// directory for the conservative stale protocol below.
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let names;
|
|
328
|
+
try {
|
|
329
|
+
names = await readdir(recoveryPath);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
333
|
+
return null;
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
const namedOwner = names
|
|
337
|
+
.map((name) => ({ name, metadata: this.recoveryOwnerMetadata(name) }))
|
|
338
|
+
.find((entry) => entry.metadata !== null);
|
|
339
|
+
if (namedOwner?.metadata) {
|
|
340
|
+
if (this.nowMs() - namedOwner.metadata.createdAtMs <= options.staleAfterMs ||
|
|
341
|
+
this.ownerProcessAlive(namedOwner.metadata.pid))
|
|
342
|
+
return null;
|
|
343
|
+
const claimName = this.recoveryOwnerName('claim');
|
|
344
|
+
try {
|
|
345
|
+
await rename(join(recoveryPath, namedOwner.name), join(recoveryPath, claimName));
|
|
346
|
+
return claimName;
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
350
|
+
return null;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const ownerlessClaim = join(recoveryPath, 'ownerless.claim');
|
|
355
|
+
if (names.includes('ownerless.claim')) {
|
|
356
|
+
try {
|
|
357
|
+
const raw = JSON.parse(await readFile(ownerlessClaim, 'utf8'));
|
|
358
|
+
if (typeof raw.createdAtMs === 'number' &&
|
|
359
|
+
typeof raw.pid === 'number' &&
|
|
360
|
+
(this.nowMs() - raw.createdAtMs <= options.staleAfterMs || this.ownerProcessAlive(raw.pid)))
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
const claimStat = await stat(ownerlessClaim).catch(() => null);
|
|
365
|
+
if (claimStat && this.nowMs() - claimStat.mtimeMs <= options.staleAfterMs)
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const claimName = this.recoveryOwnerName('claim');
|
|
369
|
+
try {
|
|
370
|
+
await rename(ownerlessClaim, join(recoveryPath, claimName));
|
|
371
|
+
return claimName;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
375
|
+
return null;
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const recoveryStat = await stat(recoveryPath).catch(() => null);
|
|
380
|
+
if (!recoveryStat || this.nowMs() - recoveryStat.mtimeMs <= options.staleAfterMs)
|
|
381
|
+
return null;
|
|
382
|
+
try {
|
|
383
|
+
await writeFile(ownerlessClaim, JSON.stringify({
|
|
384
|
+
createdAtMs: this.nowMs(),
|
|
385
|
+
pid: process.pid,
|
|
386
|
+
}), { flag: 'wx', mode: 0o600 });
|
|
387
|
+
return 'ownerless.claim';
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
if (nodeErrorCode(error) === 'EEXIST')
|
|
391
|
+
return null;
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async acquireLockAt(lockPath, options) {
|
|
396
|
+
const parent = dirname(this.filePath);
|
|
397
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
398
|
+
await chmod(parent, 0o700);
|
|
399
|
+
let retries = 0;
|
|
400
|
+
while (true) {
|
|
401
|
+
try {
|
|
402
|
+
const token = randomUUID();
|
|
403
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
404
|
+
try {
|
|
405
|
+
await writeFile(join(lockPath, 'owner.json'), JSON.stringify({ token, pid: process.pid }), { encoding: 'utf8', mode: 0o600 });
|
|
406
|
+
}
|
|
407
|
+
catch (ownerError) {
|
|
408
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
409
|
+
throw ownerError;
|
|
410
|
+
}
|
|
411
|
+
return token;
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (nodeErrorCode(error) !== 'EEXIST')
|
|
415
|
+
throw error;
|
|
416
|
+
if (await this.tryRecoverStaleLock(lockPath, options))
|
|
417
|
+
continue;
|
|
418
|
+
if (retries >= options.maxRetries) {
|
|
419
|
+
throw new Error(`contribution store lock acquisition failed after ${options.maxRetries} retries`);
|
|
420
|
+
}
|
|
421
|
+
retries += 1;
|
|
422
|
+
await sleep(options.retryDelayMs);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async withLockAt(lockPath, options, operation) {
|
|
427
|
+
const previous = localLockTails.get(lockPath) ?? Promise.resolve();
|
|
428
|
+
let release;
|
|
429
|
+
const turn = new Promise((resolve) => { release = resolve; });
|
|
430
|
+
const tail = previous.catch(() => undefined).then(() => turn);
|
|
431
|
+
localLockTails.set(lockPath, tail);
|
|
432
|
+
await previous.catch(() => undefined);
|
|
433
|
+
try {
|
|
434
|
+
const token = await this.acquireLockAt(lockPath, options);
|
|
435
|
+
try {
|
|
436
|
+
return await operation();
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
try {
|
|
440
|
+
const owner = JSON.parse(await readFile(join(lockPath, 'owner.json'), 'utf8'));
|
|
441
|
+
if (owner.token === token)
|
|
442
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
// A missing/corrupt owner record is never permission to remove a lock
|
|
446
|
+
// we cannot prove is ours. Stale recovery handles it later.
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
release();
|
|
452
|
+
if (localLockTails.get(lockPath) === tail)
|
|
453
|
+
localLockTails.delete(lockPath);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async withLock(operation) {
|
|
457
|
+
return this.withLockAt(this.lockPath, this.lock, operation);
|
|
458
|
+
}
|
|
459
|
+
async withPublicationLock(operation) {
|
|
460
|
+
return this.withLockAt(this.publicationLockPath, this.publicationLock, operation);
|
|
461
|
+
}
|
|
462
|
+
async writeAtomic(file) {
|
|
463
|
+
const tmp = `${this.filePath}.${process.pid}.${this.nowMs()}.tmp`;
|
|
464
|
+
try {
|
|
465
|
+
await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
466
|
+
await rename(tmp, this.filePath);
|
|
467
|
+
await chmod(this.filePath, 0o600);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
await rm(tmp, { force: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async preserveMigrationBackup(version, sourceText) {
|
|
474
|
+
const backupPath = `${this.filePath}.${version}.bak`;
|
|
475
|
+
const tempPath = `${backupPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
476
|
+
let handle;
|
|
477
|
+
try {
|
|
478
|
+
handle = await open(tempPath, 'wx', 0o600);
|
|
479
|
+
await handle.writeFile(sourceText, 'utf8');
|
|
480
|
+
await handle.sync();
|
|
481
|
+
await handle.close();
|
|
482
|
+
handle = undefined;
|
|
483
|
+
await link(tempPath, backupPath);
|
|
484
|
+
fsyncDirectory(dirname(backupPath));
|
|
485
|
+
await rm(tempPath, { force: true });
|
|
486
|
+
fsyncDirectory(dirname(backupPath));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
if (nodeErrorCode(error) !== 'EEXIST')
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
await handle?.close().catch(() => undefined);
|
|
495
|
+
await rm(tempPath, { force: true });
|
|
496
|
+
}
|
|
497
|
+
const existing = await readFile(backupPath, 'utf8');
|
|
498
|
+
if (existing !== sourceText) {
|
|
499
|
+
throw new Error(`existing contribution store ${version} backup does not match active store; refusing migration`);
|
|
500
|
+
}
|
|
501
|
+
const existingHandle = await open(backupPath, 'r+');
|
|
502
|
+
try {
|
|
503
|
+
await existingHandle.chmod(0o600);
|
|
504
|
+
await existingHandle.sync();
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
await existingHandle.close();
|
|
508
|
+
}
|
|
509
|
+
fsyncDirectory(dirname(backupPath));
|
|
510
|
+
}
|
|
511
|
+
async loadUnlocked() {
|
|
512
|
+
let text;
|
|
513
|
+
try {
|
|
514
|
+
text = await readFile(this.filePath, 'utf8');
|
|
515
|
+
await chmod(this.filePath, 0o600);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
519
|
+
return freshStore();
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
const raw = JSON.parse(text);
|
|
523
|
+
const schemaVersion = raw && typeof raw === 'object'
|
|
524
|
+
? raw['schemaVersion']
|
|
525
|
+
: undefined;
|
|
526
|
+
if (schemaVersion === 'mineable-trace-store.v1') {
|
|
527
|
+
await this.preserveMigrationBackup('v1', text);
|
|
528
|
+
const migrated = migrateV1File(raw);
|
|
529
|
+
await this.writeAtomic(migrated);
|
|
530
|
+
return migrated;
|
|
531
|
+
}
|
|
532
|
+
if (schemaVersion === CONTRIBUTION_STORE_V2_SCHEMA_VERSION) {
|
|
533
|
+
await this.preserveMigrationBackup('v2', text);
|
|
534
|
+
const migrated = migrateV2File(raw);
|
|
535
|
+
await this.writeAtomic(migrated);
|
|
536
|
+
return migrated;
|
|
537
|
+
}
|
|
538
|
+
return parseV3File(raw);
|
|
539
|
+
}
|
|
540
|
+
async mutate(operation) {
|
|
541
|
+
return this.withLock(async () => {
|
|
542
|
+
const file = await this.loadUnlocked();
|
|
543
|
+
const result = operation(file);
|
|
544
|
+
await this.writeAtomic(file);
|
|
545
|
+
return result;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async publicationDisabledUnlocked() {
|
|
549
|
+
try {
|
|
550
|
+
await access(this.publicationDisabledPath);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (nodeErrorCode(error) === 'ENOENT')
|
|
555
|
+
return false;
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async record(candidateInput, _localMetadata, options) {
|
|
560
|
+
const candidate = ContributionCandidateV1Schema.parse(candidateInput);
|
|
561
|
+
return this.recordReference(candidate.sourceId, {
|
|
562
|
+
publishMinedTasksConsent: candidate.publishMinedTasksConsent,
|
|
563
|
+
...options,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
async recordReference(episodeId, options = {}) {
|
|
567
|
+
if (typeof episodeId !== 'string' || episodeId.length === 0) {
|
|
568
|
+
throw new Error('contribution episode reference must not be empty');
|
|
569
|
+
}
|
|
570
|
+
return this.withLock(async () => {
|
|
571
|
+
const file = await this.loadUnlocked();
|
|
572
|
+
const existing = ownRecord(file, episodeId);
|
|
573
|
+
if (existing)
|
|
574
|
+
return existing;
|
|
575
|
+
const globallyDisabled = await this.publicationDisabledUnlocked();
|
|
576
|
+
const record = {
|
|
577
|
+
recordId: episodeId,
|
|
578
|
+
localState: 'recorded',
|
|
579
|
+
publicationState: options?.publicationState ??
|
|
580
|
+
(options.publishMinedTasksConsent && !globallyDisabled
|
|
581
|
+
? (file.firstSharePreviewAcknowledgedAt ? 'queued' : 'preview-required')
|
|
582
|
+
: 'disabled'),
|
|
583
|
+
};
|
|
584
|
+
file.records[record.recordId] = record;
|
|
585
|
+
await this.writeAtomic(file);
|
|
586
|
+
return record;
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
async get(recordId) {
|
|
590
|
+
return this.withLock(async () => (await this.loadUnlocked()).records[recordId]);
|
|
591
|
+
}
|
|
592
|
+
async list() {
|
|
593
|
+
return this.withLock(async () => Object.values((await this.loadUnlocked()).records));
|
|
594
|
+
}
|
|
595
|
+
async listRecorded() {
|
|
596
|
+
return (await this.list()).filter((record) => record.localState === 'recorded');
|
|
597
|
+
}
|
|
598
|
+
async authorize(recordId, acknowledgedAt) {
|
|
599
|
+
return this.withLock(async () => {
|
|
600
|
+
const file = await this.loadUnlocked();
|
|
601
|
+
if (await this.publicationDisabledUnlocked()) {
|
|
602
|
+
throw new Error('contribution publication is globally disabled');
|
|
603
|
+
}
|
|
604
|
+
const record = this.requireRecord(file, recordId);
|
|
605
|
+
if (record.publicationState === 'published')
|
|
606
|
+
this.throwPublishedImmutable(recordId);
|
|
607
|
+
if (record.publicationState === 'vetoed')
|
|
608
|
+
throw new Error(`contribution ${recordId} is vetoed`);
|
|
609
|
+
if (record.localState === 'rejected')
|
|
610
|
+
throw new Error(`contribution ${recordId} is rejected`);
|
|
611
|
+
if (record.publicationState === 'disabled')
|
|
612
|
+
throw new Error(`contribution ${recordId} publication is disabled`);
|
|
613
|
+
if (record.publicationState === 'preview-required') {
|
|
614
|
+
file.firstSharePreviewAcknowledgedAt = acknowledgedAt;
|
|
615
|
+
for (const candidate of Object.values(file.records)) {
|
|
616
|
+
if (candidate.publicationState === 'preview-required' && candidate.localState !== 'rejected') {
|
|
617
|
+
candidate.publicationState = 'queued';
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
await this.writeAtomic(file);
|
|
622
|
+
return record;
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
async markMinted(recordId, mintRef) {
|
|
626
|
+
return this.mutate((file) => {
|
|
627
|
+
const record = this.requireRecord(file, recordId);
|
|
628
|
+
if (record.publicationState === 'published')
|
|
629
|
+
this.throwPublishedImmutable(recordId);
|
|
630
|
+
if (record.localState === 'rejected')
|
|
631
|
+
throw new Error(`contribution ${recordId} is rejected`);
|
|
632
|
+
record.localState = 'minted';
|
|
633
|
+
if (mintRef !== undefined) {
|
|
634
|
+
if (mintRef.length === 0)
|
|
635
|
+
throw new Error('mintRef must not be empty');
|
|
636
|
+
record.mintRef = mintRef;
|
|
637
|
+
}
|
|
638
|
+
return record;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
async markRejected(recordId, reason) {
|
|
642
|
+
return this.withPublicationLock(async () => this.mutate((file) => {
|
|
643
|
+
const record = this.requireRecord(file, recordId);
|
|
644
|
+
if (record.publicationState === 'published')
|
|
645
|
+
this.throwPublishedImmutable(recordId);
|
|
646
|
+
if (record.localState === 'minted')
|
|
647
|
+
throw new Error(`contribution ${recordId} is already minted`);
|
|
648
|
+
record.localState = 'rejected';
|
|
649
|
+
if (record.publicationState !== 'vetoed')
|
|
650
|
+
record.publicationState = 'disabled';
|
|
651
|
+
if (reason !== undefined) {
|
|
652
|
+
if (reason.length === 0)
|
|
653
|
+
throw new Error('rejection reason must not be empty');
|
|
654
|
+
record.rejectionReason = reason;
|
|
655
|
+
}
|
|
656
|
+
return record;
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
async veto(recordId) {
|
|
660
|
+
return this.withPublicationLock(async () => this.mutate((file) => {
|
|
661
|
+
const record = this.requireRecord(file, recordId);
|
|
662
|
+
if (record.publicationState === 'published')
|
|
663
|
+
this.throwPublishedImmutable(recordId);
|
|
664
|
+
record.publicationState = 'vetoed';
|
|
665
|
+
return record;
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
async disableUnpublished() {
|
|
669
|
+
return this.withPublicationLock(async () => this.withLock(async () => {
|
|
670
|
+
const file = await this.loadUnlocked();
|
|
671
|
+
await writeFile(this.publicationDisabledPath, 'disabled\n', { encoding: 'utf8', mode: 0o600 });
|
|
672
|
+
await chmod(this.publicationDisabledPath, 0o600);
|
|
673
|
+
const disabled = [];
|
|
674
|
+
for (const record of Object.values(file.records)) {
|
|
675
|
+
if (record.publicationState === 'preview-required' || record.publicationState === 'queued') {
|
|
676
|
+
record.publicationState = 'disabled';
|
|
677
|
+
disabled.push(record.recordId);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
await this.writeAtomic(file);
|
|
681
|
+
return disabled;
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
async enablePublication() {
|
|
685
|
+
await this.withPublicationLock(async () => this.withLock(async () => {
|
|
686
|
+
await rm(this.publicationDisabledPath, { force: true });
|
|
687
|
+
}));
|
|
688
|
+
}
|
|
689
|
+
async publishAuthorized(recordId, operation) {
|
|
690
|
+
return this.withPublicationLock(async () => {
|
|
691
|
+
await this.withLock(async () => {
|
|
692
|
+
const file = await this.loadUnlocked();
|
|
693
|
+
if (await this.publicationDisabledUnlocked()) {
|
|
694
|
+
throw new Error('contribution publication is globally disabled');
|
|
695
|
+
}
|
|
696
|
+
const record = this.requireRecord(file, recordId);
|
|
697
|
+
if (record.publicationState === 'published')
|
|
698
|
+
this.throwPublishedImmutable(recordId);
|
|
699
|
+
if (record.publicationState !== 'queued') {
|
|
700
|
+
throw new Error(`contribution ${recordId} is not authorized for publication`);
|
|
701
|
+
}
|
|
702
|
+
if (record.localState === 'rejected')
|
|
703
|
+
throw new Error(`contribution ${recordId} is rejected`);
|
|
704
|
+
});
|
|
705
|
+
const result = await operation();
|
|
706
|
+
if (!result.mintRef || !result.publicationRef) {
|
|
707
|
+
throw new Error('authorized publication must return non-empty mint and publication refs');
|
|
708
|
+
}
|
|
709
|
+
await this.withLock(async () => {
|
|
710
|
+
const file = await this.loadUnlocked();
|
|
711
|
+
const record = this.requireRecord(file, recordId);
|
|
712
|
+
if (record.publicationState !== 'queued' || record.localState === 'rejected') {
|
|
713
|
+
throw new Error(`contribution ${recordId} lost publication authorization`);
|
|
714
|
+
}
|
|
715
|
+
record.localState = 'minted';
|
|
716
|
+
record.mintRef = result.mintRef;
|
|
717
|
+
record.publicationState = 'published';
|
|
718
|
+
record.publicationRef = result.publicationRef;
|
|
719
|
+
await this.writeAtomic(file);
|
|
720
|
+
});
|
|
721
|
+
return result.value;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
async markPublished(recordId, publicationRef) {
|
|
725
|
+
return this.withPublicationLock(async () => this.mutate((file) => {
|
|
726
|
+
const record = this.requireRecord(file, recordId);
|
|
727
|
+
if (record.publicationState === 'published') {
|
|
728
|
+
if (record.publicationRef === publicationRef)
|
|
729
|
+
return record;
|
|
730
|
+
this.throwPublishedImmutable(recordId);
|
|
731
|
+
}
|
|
732
|
+
if (record.localState !== 'minted' || record.publicationState !== 'queued') {
|
|
733
|
+
throw new Error(`contribution ${recordId} is not a queued local mint`);
|
|
734
|
+
}
|
|
735
|
+
if (publicationRef.length === 0)
|
|
736
|
+
throw new Error('publicationRef must not be empty');
|
|
737
|
+
record.publicationState = 'published';
|
|
738
|
+
record.publicationRef = publicationRef;
|
|
739
|
+
return record;
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
742
|
+
toOutboundProjection(record, episodeInput) {
|
|
743
|
+
if (record.localState !== 'minted' || record.publicationState !== 'queued' || !record.mintRef) {
|
|
744
|
+
throw new Error(`contribution ${record.recordId} is not ready for outbound publication`);
|
|
745
|
+
}
|
|
746
|
+
const episode = EpisodeV1Schema.parse(episodeInput);
|
|
747
|
+
if (episode.episodeId !== record.recordId) {
|
|
748
|
+
throw new Error(`contribution ${record.recordId} does not match episode ${episode.episodeId}`);
|
|
749
|
+
}
|
|
750
|
+
const candidate = ContributionCandidateV1ProjectionSchema.safeParse(episode.contributionCandidate);
|
|
751
|
+
if (!candidate.success || candidate.data.sourceId !== record.recordId) {
|
|
752
|
+
throw new Error(`canonical episode ${record.recordId} has no matching contribution candidate`);
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
schemaVersion: CONTRIBUTION_PUBLICATION_SCHEMA_VERSION,
|
|
756
|
+
repositorySlug: candidate.data.repositorySlug,
|
|
757
|
+
baseCommit: candidate.data.baseCommit,
|
|
758
|
+
mintRef: record.mintRef,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
requireRecord(file, recordId) {
|
|
762
|
+
const record = ownRecord(file, recordId);
|
|
763
|
+
if (!record)
|
|
764
|
+
throw new Error(`no such contribution record: ${recordId}`);
|
|
765
|
+
return record;
|
|
766
|
+
}
|
|
767
|
+
throwPublishedImmutable(recordId) {
|
|
768
|
+
throw new Error(`contribution ${recordId} is published and immutable`);
|
|
769
|
+
}
|
|
770
|
+
}
|