wendkeep 0.78.0 → 0.79.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/CHANGELOG.md +41 -0
- package/README.en.md +57 -2
- package/README.md +57 -2
- package/docs/en/commands/changes-and-verification.md +66 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/verify.md +45 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +65 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/verify.md +45 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +201 -122
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1780 -79
- package/src/delivery.mjs +724 -67
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import * as nodeFs from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
dirname,
|
|
5
|
+
isAbsolute,
|
|
6
|
+
join,
|
|
7
|
+
parse,
|
|
8
|
+
relative,
|
|
9
|
+
resolve,
|
|
10
|
+
sep,
|
|
11
|
+
} from 'node:path';
|
|
12
|
+
|
|
13
|
+
const HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
14
|
+
const RECORD_KEYS = [
|
|
15
|
+
'schema_version',
|
|
16
|
+
'sequence',
|
|
17
|
+
'receipt_id',
|
|
18
|
+
'previous_hash',
|
|
19
|
+
'receipt_hash',
|
|
20
|
+
'kind',
|
|
21
|
+
'subject',
|
|
22
|
+
'claims',
|
|
23
|
+
'observations',
|
|
24
|
+
'recorded_at',
|
|
25
|
+
];
|
|
26
|
+
const DRAFT_KEYS = ['kind', 'subject', 'claims', 'observations', 'recorded_at'];
|
|
27
|
+
const CHECKPOINT_KEYS = [
|
|
28
|
+
'schema_version', 'last_sequence', 'last_hash', 'ledger_byte_length',
|
|
29
|
+
];
|
|
30
|
+
const DEFAULT_LOCK_LEASE_MS = 30_000;
|
|
31
|
+
const DEFAULT_LOCK_WAIT_MS = 5_000;
|
|
32
|
+
const MAX_LOCK_IDENTITY_CHANGE_RETRIES = 64;
|
|
33
|
+
const MAX_LOCK_RELEASE_RETRIES = 100;
|
|
34
|
+
const WINDOWS_SHARING_VIOLATIONS = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
35
|
+
const DIRECTORY_FSYNC_UNSUPPORTED = new Set([
|
|
36
|
+
'EISDIR', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP',
|
|
37
|
+
]);
|
|
38
|
+
const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
39
|
+
|
|
40
|
+
function ledgerError(code, message) {
|
|
41
|
+
const error = new Error(message);
|
|
42
|
+
error.code = code;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizedIoError(operation, error) {
|
|
47
|
+
const nativeCode = typeof error?.code === 'string' && /^[A-Z0-9_]+$/.test(error.code)
|
|
48
|
+
? error.code
|
|
49
|
+
: 'UNKNOWN';
|
|
50
|
+
const failure = ledgerError(
|
|
51
|
+
'WENDKEEP_RECEIPT_LEDGER_IO',
|
|
52
|
+
`Falha de I/O durante ${operation} (${nativeCode}).`,
|
|
53
|
+
);
|
|
54
|
+
failure.details = { operation, native_code: nativeCode };
|
|
55
|
+
return failure;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sanitizePublicError(error, operation) {
|
|
59
|
+
if (typeof error?.code === 'string' && error.code.startsWith('WENDKEEP_RECEIPT_LEDGER_')) {
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
return sanitizedIoError(operation, error);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sleepSync(milliseconds) {
|
|
66
|
+
if (milliseconds > 0) Atomics.wait(SLEEP_CELL, 0, 0, milliseconds);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function corrupt(message) {
|
|
70
|
+
return ledgerError('WENDKEEP_RECEIPT_LEDGER_CORRUPT', message);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function truncated(message) {
|
|
74
|
+
return ledgerError('WENDKEEP_RECEIPT_LEDGER_TRUNCATED', message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function canonicalValue(value, seen = new Set()) {
|
|
78
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
79
|
+
if (typeof value === 'number') {
|
|
80
|
+
if (!Number.isFinite(value)) throw corrupt('Receipt contém número não finito.');
|
|
81
|
+
return Object.is(value, -0) ? 0 : value;
|
|
82
|
+
}
|
|
83
|
+
if (typeof value !== 'object') throw corrupt('Receipt contém valor não serializável.');
|
|
84
|
+
if (typeof value.toJSON === 'function') return canonicalValue(value.toJSON(), seen);
|
|
85
|
+
if (seen.has(value)) throw corrupt('Receipt contém referência circular.');
|
|
86
|
+
seen.add(value);
|
|
87
|
+
try {
|
|
88
|
+
if (Array.isArray(value)) return value.map((item) => canonicalValue(item, seen));
|
|
89
|
+
const normalized = Object.create(null);
|
|
90
|
+
for (const key of Object.keys(value).sort()) {
|
|
91
|
+
if (value[key] === undefined) throw corrupt('Receipt contém campo indefinido.');
|
|
92
|
+
normalized[key] = canonicalValue(value[key], seen);
|
|
93
|
+
}
|
|
94
|
+
return normalized;
|
|
95
|
+
} finally {
|
|
96
|
+
seen.delete(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canonicalJson(value) {
|
|
101
|
+
return JSON.stringify(canonicalValue(value));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sha256(bytes) {
|
|
105
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function legacyBytes(legacyPrefix) {
|
|
109
|
+
if (Buffer.isBuffer(legacyPrefix)) return legacyPrefix;
|
|
110
|
+
if (typeof legacyPrefix === 'string') return Buffer.from(legacyPrefix, 'utf8');
|
|
111
|
+
if (legacyPrefix === undefined || legacyPrefix === null) return Buffer.alloc(0);
|
|
112
|
+
return Buffer.from(canonicalJson(legacyPrefix), 'utf8');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function receiptGenesisHash(legacyPrefix = '') {
|
|
116
|
+
return sha256(legacyBytes(legacyPrefix));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertExactKeys(value, expected, label) {
|
|
120
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
121
|
+
throw corrupt(`${label} deve ser um objeto.`);
|
|
122
|
+
}
|
|
123
|
+
const actual = Object.keys(value).sort();
|
|
124
|
+
const required = [...expected].sort();
|
|
125
|
+
if (actual.length !== required.length || actual.some((key, index) => key !== required[index])) {
|
|
126
|
+
throw corrupt(`${label} contém campos ausentes ou desconhecidos.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertContainer(value, label) {
|
|
131
|
+
if (!value || typeof value !== 'object') throw corrupt(`${label} deve ser objeto ou array JSON.`);
|
|
132
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) {
|
|
133
|
+
throw corrupt(`${label} deve usar um objeto JSON simples.`);
|
|
134
|
+
}
|
|
135
|
+
canonicalJson(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizedDraft(draft) {
|
|
139
|
+
assertExactKeys(draft, DRAFT_KEYS, 'Draft do receipt');
|
|
140
|
+
if (typeof draft.kind !== 'string' || !draft.kind.trim()) throw corrupt('kind deve ser string não vazia.');
|
|
141
|
+
assertContainer(draft.subject, 'subject');
|
|
142
|
+
assertContainer(draft.claims, 'claims');
|
|
143
|
+
assertContainer(draft.observations, 'observations');
|
|
144
|
+
if (typeof draft.recorded_at !== 'string'
|
|
145
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(draft.recorded_at)
|
|
146
|
+
|| !Number.isFinite(Date.parse(draft.recorded_at))) {
|
|
147
|
+
throw corrupt('recorded_at deve ser um timestamp válido.');
|
|
148
|
+
}
|
|
149
|
+
return JSON.parse(canonicalJson(draft));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function receiptIdentity(draft) {
|
|
153
|
+
return sha256(canonicalJson({ kind: draft.kind, subject: draft.subject }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function logicalContent(value) {
|
|
157
|
+
return canonicalJson({
|
|
158
|
+
kind: value.kind,
|
|
159
|
+
subject: value.subject,
|
|
160
|
+
claims: value.claims,
|
|
161
|
+
observations: value.observations,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hashableRecord(record) {
|
|
166
|
+
return {
|
|
167
|
+
schema_version: record.schema_version,
|
|
168
|
+
sequence: record.sequence,
|
|
169
|
+
receipt_id: record.receipt_id,
|
|
170
|
+
previous_hash: record.previous_hash,
|
|
171
|
+
kind: record.kind,
|
|
172
|
+
subject: record.subject,
|
|
173
|
+
claims: record.claims,
|
|
174
|
+
observations: record.observations,
|
|
175
|
+
recorded_at: record.recorded_at,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function buildReceiptRecord(draft, { sequence, previousHash }) {
|
|
180
|
+
const logical = normalizedDraft(draft);
|
|
181
|
+
if (!Number.isSafeInteger(sequence) || sequence < 1) throw corrupt('sequence deve ser inteiro positivo.');
|
|
182
|
+
if (typeof previousHash !== 'string' || !HASH_PATTERN.test(previousHash)) {
|
|
183
|
+
throw corrupt('previous_hash deve usar sha256:<64hex>.');
|
|
184
|
+
}
|
|
185
|
+
const base = {
|
|
186
|
+
schema_version: 2,
|
|
187
|
+
sequence,
|
|
188
|
+
receipt_id: receiptIdentity(logical),
|
|
189
|
+
previous_hash: previousHash,
|
|
190
|
+
kind: logical.kind,
|
|
191
|
+
subject: logical.subject,
|
|
192
|
+
claims: logical.claims,
|
|
193
|
+
observations: logical.observations,
|
|
194
|
+
recorded_at: logical.recorded_at,
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
schema_version: base.schema_version,
|
|
198
|
+
sequence: base.sequence,
|
|
199
|
+
receipt_id: base.receipt_id,
|
|
200
|
+
previous_hash: base.previous_hash,
|
|
201
|
+
receipt_hash: sha256(canonicalJson(base)),
|
|
202
|
+
kind: base.kind,
|
|
203
|
+
subject: base.subject,
|
|
204
|
+
claims: base.claims,
|
|
205
|
+
observations: base.observations,
|
|
206
|
+
recorded_at: base.recorded_at,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateCheckpoint(checkpoint) {
|
|
211
|
+
assertExactKeys(checkpoint, CHECKPOINT_KEYS, 'Checkpoint do ledger');
|
|
212
|
+
if (checkpoint.schema_version !== 2) throw corrupt('Checkpoint usa schema_version incompatível.');
|
|
213
|
+
if (!Number.isSafeInteger(checkpoint.last_sequence) || checkpoint.last_sequence < 1) {
|
|
214
|
+
throw corrupt('Checkpoint contém last_sequence inválida.');
|
|
215
|
+
}
|
|
216
|
+
if (!HASH_PATTERN.test(checkpoint.last_hash || '')) throw corrupt('Checkpoint contém last_hash inválido.');
|
|
217
|
+
if (!Number.isSafeInteger(checkpoint.ledger_byte_length) || checkpoint.ledger_byte_length < 1) {
|
|
218
|
+
throw corrupt('Checkpoint contém ledger_byte_length inválido.');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function verifyReceiptChain({ records, checkpoint = null, legacyPrefix = '' }) {
|
|
223
|
+
if (!Array.isArray(records)) throw corrupt('Ledger deve ser uma lista de records.');
|
|
224
|
+
let previousHash = receiptGenesisHash(legacyPrefix);
|
|
225
|
+
const identities = new Set();
|
|
226
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
227
|
+
const record = records[index];
|
|
228
|
+
assertExactKeys(record, RECORD_KEYS, `Receipt ${index + 1}`);
|
|
229
|
+
if (record.schema_version !== 2) throw corrupt(`Receipt ${index + 1} usa schema_version incompatível.`);
|
|
230
|
+
if (record.sequence !== index + 1) throw corrupt(`Receipt ${index + 1} quebra a sequência monotônica.`);
|
|
231
|
+
if (record.previous_hash !== previousHash) throw corrupt(`Receipt ${index + 1} quebra previous_hash.`);
|
|
232
|
+
const draft = normalizedDraft({
|
|
233
|
+
kind: record.kind,
|
|
234
|
+
subject: record.subject,
|
|
235
|
+
claims: record.claims,
|
|
236
|
+
observations: record.observations,
|
|
237
|
+
recorded_at: record.recorded_at,
|
|
238
|
+
});
|
|
239
|
+
if (record.receipt_id !== receiptIdentity(draft)) throw corrupt(`Receipt ${index + 1} tem receipt_id inválido.`);
|
|
240
|
+
if (identities.has(record.receipt_id)) throw corrupt(`Receipt ${index + 1} repete receipt_id.`);
|
|
241
|
+
identities.add(record.receipt_id);
|
|
242
|
+
const expectedHash = sha256(canonicalJson(hashableRecord(record)));
|
|
243
|
+
if (record.receipt_hash !== expectedHash) throw corrupt(`Receipt ${index + 1} tem receipt_hash inválido.`);
|
|
244
|
+
previousHash = record.receipt_hash;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let checkpointStatus = 'absent';
|
|
248
|
+
if (checkpoint !== null && checkpoint !== undefined) {
|
|
249
|
+
validateCheckpoint(checkpoint);
|
|
250
|
+
if (checkpoint.last_sequence > records.length) {
|
|
251
|
+
throw truncated('Checkpoint prova que records foram removidos do tail do ledger.');
|
|
252
|
+
}
|
|
253
|
+
const anchored = records[checkpoint.last_sequence - 1];
|
|
254
|
+
if (!anchored || anchored.receipt_hash !== checkpoint.last_hash) {
|
|
255
|
+
throw corrupt('Checkpoint não corresponde ao prefixo do ledger.');
|
|
256
|
+
}
|
|
257
|
+
checkpointStatus = checkpoint.last_sequence === records.length ? 'current' : 'lagging';
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
checkpoint_status: checkpointStatus,
|
|
262
|
+
last_sequence: records.length,
|
|
263
|
+
last_hash: records.length ? records.at(-1).receipt_hash : receiptGenesisHash(legacyPrefix),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function pathInside(rootPath, targetPath) {
|
|
268
|
+
const rel = relative(rootPath, targetPath);
|
|
269
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function assertNoSymlinkAncestors(targetPath, fs) {
|
|
273
|
+
const absolute = resolve(targetPath);
|
|
274
|
+
const root = parse(absolute).root;
|
|
275
|
+
const parts = absolute.slice(root.length).split(/[\\/]+/).filter(Boolean);
|
|
276
|
+
let current = root;
|
|
277
|
+
for (const part of parts) {
|
|
278
|
+
current = join(current, part);
|
|
279
|
+
if (!fs.existsSync(current)) continue;
|
|
280
|
+
const stat = fs.lstatSync(current);
|
|
281
|
+
if (stat.isSymbolicLink()) throw corrupt('Path inseguro usa symlink.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function storePathLabel(store, targetPath) {
|
|
286
|
+
const target = resolve(targetPath);
|
|
287
|
+
if (store?.ledgerPath && target === resolve(store.ledgerPath)) return 'ledger';
|
|
288
|
+
if (store?.checkpointPath && target === resolve(store.checkpointPath)) return 'checkpoint';
|
|
289
|
+
if (store?.legacyPath && target === resolve(store.legacyPath)) return 'legacy ledger';
|
|
290
|
+
if (store?.lockPath && target === resolve(store.lockPath)) return 'lock';
|
|
291
|
+
return 'runtime file';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertSafeFile(store, targetPath, { allowMissing = true } = {}) {
|
|
295
|
+
const target = resolve(targetPath);
|
|
296
|
+
const label = storePathLabel(store, target);
|
|
297
|
+
if (!pathInside(store.rootPath, target)) throw corrupt(`Path do ${label} escapa do runtime do ledger.`);
|
|
298
|
+
assertNoSymlinkAncestors(dirname(target), store.fs);
|
|
299
|
+
if (!store.fs.existsSync(target)) {
|
|
300
|
+
if (!allowMissing) throw corrupt(`Arquivo obrigatório ausente no ${label}.`);
|
|
301
|
+
return { exists: false, target };
|
|
302
|
+
}
|
|
303
|
+
let stat;
|
|
304
|
+
try {
|
|
305
|
+
stat = store.fs.lstatSync(target);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (allowMissing && error?.code === 'ENOENT') return { exists: false, target };
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw corrupt(`Path do ${label} não é arquivo regular.`);
|
|
311
|
+
if (stat.nlink !== 1) throw corrupt(`Path do ${label} possui hardlinks.`);
|
|
312
|
+
let real;
|
|
313
|
+
try {
|
|
314
|
+
real = resolve(store.fs.realpathSync(target));
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (allowMissing && error?.code === 'ENOENT') return { exists: false, target };
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
if (!pathInside(store.rootPath, real)) throw corrupt(`Path real do ${label} escapa do runtime do ledger.`);
|
|
320
|
+
return { exists: true, target };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function secureReadSnapshot(store, targetPath, {
|
|
324
|
+
encoding = 'utf8',
|
|
325
|
+
allowIdentityChange = false,
|
|
326
|
+
} = {}) {
|
|
327
|
+
const checked = assertSafeFile(store, targetPath);
|
|
328
|
+
if (!checked.exists) {
|
|
329
|
+
return {
|
|
330
|
+
exists: false,
|
|
331
|
+
content: encoding === null ? Buffer.alloc(0) : '',
|
|
332
|
+
identity: null,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const flags = store.fs.constants.O_RDONLY | (store.fs.constants.O_NOFOLLOW || 0);
|
|
336
|
+
let descriptor;
|
|
337
|
+
try {
|
|
338
|
+
descriptor = store.fs.openSync(checked.target, flags);
|
|
339
|
+
const opened = store.fs.fstatSync(descriptor);
|
|
340
|
+
const linked = store.fs.lstatSync(checked.target);
|
|
341
|
+
const real = resolve(store.fs.realpathSync(checked.target));
|
|
342
|
+
if (!opened.isFile() || !pathInside(store.rootPath, real)) {
|
|
343
|
+
throw corrupt(`Arquivo do ${storePathLabel(store, checked.target)} foi trocado ou escapou durante a abertura.`);
|
|
344
|
+
}
|
|
345
|
+
if (opened.nlink !== 1) {
|
|
346
|
+
if (allowIdentityChange && opened.nlink === 0) {
|
|
347
|
+
return { exists: true, changed: true, content: '', identity: null };
|
|
348
|
+
}
|
|
349
|
+
throw corrupt(`Arquivo do ${storePathLabel(store, checked.target)} foi trocado ou escapou durante a abertura.`);
|
|
350
|
+
}
|
|
351
|
+
if (!sameFileIdentity(opened, linked)) {
|
|
352
|
+
if (allowIdentityChange) {
|
|
353
|
+
return { exists: true, changed: true, content: '', identity: null };
|
|
354
|
+
}
|
|
355
|
+
throw corrupt(`Arquivo do ${storePathLabel(store, checked.target)} foi trocado ou escapou durante a abertura.`);
|
|
356
|
+
}
|
|
357
|
+
const content = encoding === null
|
|
358
|
+
? store.fs.readFileSync(descriptor)
|
|
359
|
+
: store.fs.readFileSync(descriptor, encoding);
|
|
360
|
+
const after = store.fs.lstatSync(checked.target);
|
|
361
|
+
if (!sameFileIdentity(opened, after) || after.nlink !== 1) {
|
|
362
|
+
if (allowIdentityChange && after.nlink === 1) {
|
|
363
|
+
return { exists: true, changed: true, content: '', identity: null };
|
|
364
|
+
}
|
|
365
|
+
throw corrupt(`Arquivo do ${storePathLabel(store, checked.target)} foi trocado durante a leitura.`);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
exists: true,
|
|
369
|
+
content,
|
|
370
|
+
identity: {
|
|
371
|
+
dev: after.dev,
|
|
372
|
+
ino: after.ino,
|
|
373
|
+
mtime_ms: after.mtimeMs,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
} finally {
|
|
377
|
+
if (descriptor !== undefined) store.fs.closeSync(descriptor);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function secureRead(store, targetPath, options) {
|
|
382
|
+
return secureReadSnapshot(store, targetPath, options).content;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function fsyncDirectory(store) {
|
|
386
|
+
let descriptor;
|
|
387
|
+
try {
|
|
388
|
+
descriptor = store.fs.openSync(store.rootPath, 'r');
|
|
389
|
+
store.fs.fsyncSync(descriptor);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
const unsupported = DIRECTORY_FSYNC_UNSUPPORTED.has(error?.code)
|
|
392
|
+
|| (process.platform === 'win32' && error?.code === 'EPERM');
|
|
393
|
+
if (!unsupported) {
|
|
394
|
+
throw sanitizedIoError('sincronização do diretório do ledger', error);
|
|
395
|
+
}
|
|
396
|
+
// Some platforms explicitly do not support opening/fsyncing directories.
|
|
397
|
+
// Only those documented capability errors may fall back to file fsync.
|
|
398
|
+
} finally {
|
|
399
|
+
if (descriptor !== undefined) {
|
|
400
|
+
try { store.fs.closeSync(descriptor); } catch { /* best effort */ }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function atomicWrite(store, targetPath, content) {
|
|
406
|
+
const target = assertSafeFile(store, targetPath).target;
|
|
407
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
408
|
+
assertSafeFile(store, temporary);
|
|
409
|
+
let descriptor;
|
|
410
|
+
let temporaryExists = false;
|
|
411
|
+
try {
|
|
412
|
+
descriptor = store.fs.openSync(temporary, 'wx');
|
|
413
|
+
temporaryExists = true;
|
|
414
|
+
store.fs.writeFileSync(descriptor, content, 'utf8');
|
|
415
|
+
store.fs.fsyncSync(descriptor);
|
|
416
|
+
store.fs.closeSync(descriptor);
|
|
417
|
+
descriptor = undefined;
|
|
418
|
+
assertSafeFile(store, temporary, { allowMissing: false });
|
|
419
|
+
assertSafeFile(store, target);
|
|
420
|
+
store.fs.renameSync(temporary, target);
|
|
421
|
+
temporaryExists = false;
|
|
422
|
+
fsyncDirectory(store);
|
|
423
|
+
assertSafeFile(store, target, { allowMissing: false });
|
|
424
|
+
} finally {
|
|
425
|
+
if (descriptor !== undefined) {
|
|
426
|
+
try { store.fs.closeSync(descriptor); } catch { /* original error wins */ }
|
|
427
|
+
}
|
|
428
|
+
if (temporaryExists) {
|
|
429
|
+
try {
|
|
430
|
+
assertSafeFile(store, temporary, { allowMissing: false });
|
|
431
|
+
store.fs.unlinkSync(temporary);
|
|
432
|
+
} catch { /* private temporary remains recoverable */ }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function lockState(store, { allowIdentityChange = false } = {}) {
|
|
438
|
+
let snapshot;
|
|
439
|
+
try {
|
|
440
|
+
snapshot = secureReadSnapshot(store, store.lockPath, { allowIdentityChange });
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error?.code === 'ENOENT') {
|
|
443
|
+
return { status: 'missing', exists: false, content: '', identity: null };
|
|
444
|
+
}
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
if (snapshot.changed) return { status: 'changed', ...snapshot };
|
|
448
|
+
if (!snapshot.exists) return { status: 'missing', ...snapshot };
|
|
449
|
+
const raw = snapshot.content;
|
|
450
|
+
if (!raw) return { status: 'publishing', ...snapshot };
|
|
451
|
+
try {
|
|
452
|
+
const parsed = JSON.parse(raw);
|
|
453
|
+
if (parsed?.schema_version !== 1
|
|
454
|
+
|| typeof parsed.owner_token !== 'string'
|
|
455
|
+
|| !parsed.owner_token
|
|
456
|
+
|| !Number.isSafeInteger(parsed.owner_pid)
|
|
457
|
+
|| typeof parsed.lease_expires_at !== 'string'
|
|
458
|
+
|| !Number.isFinite(Date.parse(parsed.lease_expires_at))) {
|
|
459
|
+
return { status: 'invalid', owner: parsed, ...snapshot };
|
|
460
|
+
}
|
|
461
|
+
return { status: 'valid', owner: parsed, ...snapshot };
|
|
462
|
+
} catch {
|
|
463
|
+
return { status: 'publishing', ...snapshot };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function sameFileIdentity(left, right) {
|
|
468
|
+
if (!left || !right || left.ino !== right.ino) return false;
|
|
469
|
+
if (left.dev === right.dev) return true;
|
|
470
|
+
return process.platform === 'win32' && (left.dev === 0 || right.dev === 0);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function sameLockSnapshot(expected, observed) {
|
|
474
|
+
if (!expected?.exists || !observed?.exists) return false;
|
|
475
|
+
if (!sameFileIdentity(expected.identity, observed.identity)) return false;
|
|
476
|
+
if (expected.content !== observed.content) return false;
|
|
477
|
+
if (expected.status === 'valid') {
|
|
478
|
+
return observed.status === 'valid'
|
|
479
|
+
&& observed.owner.owner_token === expected.owner.owner_token;
|
|
480
|
+
}
|
|
481
|
+
return observed.status === expected.status;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function removeLockIfUnchanged(store, expected) {
|
|
485
|
+
for (let attempt = 1; attempt <= MAX_LOCK_RELEASE_RETRIES; attempt += 1) {
|
|
486
|
+
const observed = lockState(store);
|
|
487
|
+
if (!sameLockSnapshot(expected, observed)) return false;
|
|
488
|
+
try {
|
|
489
|
+
store.fs.unlinkSync(store.lockPath);
|
|
490
|
+
fsyncDirectory(store);
|
|
491
|
+
return true;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
const retryable = process.platform === 'win32'
|
|
494
|
+
&& WINDOWS_SHARING_VIOLATIONS.has(error?.code)
|
|
495
|
+
&& attempt < MAX_LOCK_RELEASE_RETRIES;
|
|
496
|
+
if (!retryable) throw error;
|
|
497
|
+
sleepSync(5);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function processIsAlive(pid) {
|
|
504
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false;
|
|
505
|
+
try {
|
|
506
|
+
process.kill(pid, 0);
|
|
507
|
+
return true;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return error?.code === 'EPERM';
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function lockIsStale(state, now) {
|
|
514
|
+
return Date.parse(state.lease_expires_at) <= now && !processIsAlive(state.owner_pid);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function busyLock(details = {}) {
|
|
518
|
+
const error = ledgerError(
|
|
519
|
+
'WENDKEEP_RECEIPT_LEDGER_BUSY',
|
|
520
|
+
'Outro produtor mantém o lock exclusivo do ledger.',
|
|
521
|
+
);
|
|
522
|
+
error.details = { lock: 'active', ...details };
|
|
523
|
+
return error;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function acquireLock(store) {
|
|
527
|
+
const target = resolve(store.lockPath);
|
|
528
|
+
const token = randomUUID();
|
|
529
|
+
const deadline = store.now() + store.lockWaitMs;
|
|
530
|
+
let identityChangeRetries = 0;
|
|
531
|
+
let openSharingRetries = 0;
|
|
532
|
+
for (;;) {
|
|
533
|
+
// Validate the parent chain before the atomic create. O_EXCL/O_NOFOLLOW
|
|
534
|
+
// makes a replacement race fail closed instead of following a symlink.
|
|
535
|
+
// Do not preflight the lock file itself: another owner may create or
|
|
536
|
+
// remove it between lstat and open. The atomic create below is the
|
|
537
|
+
// authority; only the ancestor chain needs a preflight here.
|
|
538
|
+
assertNoSymlinkAncestors(dirname(target), store.fs);
|
|
539
|
+
let descriptor;
|
|
540
|
+
try {
|
|
541
|
+
const flags = store.fs.constants.O_WRONLY
|
|
542
|
+
| store.fs.constants.O_CREAT
|
|
543
|
+
| store.fs.constants.O_EXCL
|
|
544
|
+
| (store.fs.constants.O_NOFOLLOW || 0);
|
|
545
|
+
descriptor = store.fs.openSync(target, flags, 0o600);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
const sharingConflict = process.platform === 'win32'
|
|
548
|
+
&& WINDOWS_SHARING_VIOLATIONS.has(error?.code);
|
|
549
|
+
if (error?.code !== 'EEXIST' && !sharingConflict) throw error;
|
|
550
|
+
const now = store.now();
|
|
551
|
+
let state;
|
|
552
|
+
try {
|
|
553
|
+
state = lockState(store, { allowIdentityChange: true });
|
|
554
|
+
} catch (inspectionError) {
|
|
555
|
+
const transientInspection = process.platform === 'win32'
|
|
556
|
+
&& WINDOWS_SHARING_VIOLATIONS.has(inspectionError?.code);
|
|
557
|
+
if (!transientInspection) throw inspectionError;
|
|
558
|
+
openSharingRetries += 1;
|
|
559
|
+
if (now >= deadline || openSharingRetries > MAX_LOCK_IDENTITY_CHANGE_RETRIES) {
|
|
560
|
+
throw inspectionError;
|
|
561
|
+
}
|
|
562
|
+
sleepSync(5);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (state.status === 'missing') {
|
|
566
|
+
if (sharingConflict) {
|
|
567
|
+
openSharingRetries += 1;
|
|
568
|
+
if (now >= deadline || openSharingRetries > MAX_LOCK_IDENTITY_CHANGE_RETRIES) throw error;
|
|
569
|
+
sleepSync(5);
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (state.status === 'changed') {
|
|
574
|
+
identityChangeRetries += 1;
|
|
575
|
+
if (now >= deadline || identityChangeRetries > MAX_LOCK_IDENTITY_CHANGE_RETRIES) {
|
|
576
|
+
throw busyLock({ lock: 'changed' });
|
|
577
|
+
}
|
|
578
|
+
sleepSync(Math.min(5, Math.max(1, deadline - now)));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (state.status === 'invalid') throw busyLock();
|
|
582
|
+
if (state.status === 'publishing') {
|
|
583
|
+
const safeAfter = state.identity.mtime_ms + store.lockLeaseMs;
|
|
584
|
+
if (now >= safeAfter) {
|
|
585
|
+
try {
|
|
586
|
+
if (removeLockIfUnchanged(store, state)) continue;
|
|
587
|
+
} catch (unlinkError) {
|
|
588
|
+
if (unlinkError?.code !== 'ENOENT') {
|
|
589
|
+
if (unlinkError?.code?.startsWith('WENDKEEP_RECEIPT_LEDGER_')) throw unlinkError;
|
|
590
|
+
throw busyLock({ lock: 'publishing' });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (now >= deadline) throw busyLock({ lock: 'publishing' });
|
|
595
|
+
sleepSync(Math.min(25, Math.max(1, Math.min(deadline, safeAfter) - now)));
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (lockIsStale(state.owner, now)) {
|
|
599
|
+
try {
|
|
600
|
+
if (removeLockIfUnchanged(store, state)) continue;
|
|
601
|
+
} catch (unlinkError) {
|
|
602
|
+
if (unlinkError?.code !== 'ENOENT') {
|
|
603
|
+
if (unlinkError?.code?.startsWith('WENDKEEP_RECEIPT_LEDGER_')) throw unlinkError;
|
|
604
|
+
throw busyLock({ lock: 'stale' });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (now >= deadline) throw busyLock({ lock: 'stale' });
|
|
608
|
+
sleepSync(Math.min(25, Math.max(1, deadline - now)));
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (now >= deadline) {
|
|
612
|
+
throw busyLock({ lease_expires_at: state.owner.lease_expires_at });
|
|
613
|
+
}
|
|
614
|
+
sleepSync(Math.min(25, Math.max(1, deadline - now)));
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const now = store.now();
|
|
618
|
+
const payload = {
|
|
619
|
+
schema_version: 1,
|
|
620
|
+
owner_token: token,
|
|
621
|
+
owner_pid: process.pid,
|
|
622
|
+
acquired_at: new Date(now).toISOString(),
|
|
623
|
+
lease_expires_at: new Date(now + store.lockLeaseMs).toISOString(),
|
|
624
|
+
};
|
|
625
|
+
try {
|
|
626
|
+
store.fs.writeFileSync(descriptor, `${JSON.stringify(payload)}\n`, 'utf8');
|
|
627
|
+
store.fs.fsyncSync(descriptor);
|
|
628
|
+
store.fs.closeSync(descriptor);
|
|
629
|
+
descriptor = undefined;
|
|
630
|
+
fsyncDirectory(store);
|
|
631
|
+
assertSafeFile(store, target, { allowMissing: false });
|
|
632
|
+
return token;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (descriptor !== undefined) {
|
|
635
|
+
try { store.fs.closeSync(descriptor); } catch { /* original error wins */ }
|
|
636
|
+
}
|
|
637
|
+
try { store.fs.unlinkSync(target); } catch { /* original error wins */ }
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function releaseLock(store, token) {
|
|
644
|
+
const state = lockState(store);
|
|
645
|
+
if (state.status !== 'valid' || state.owner.owner_token !== token) {
|
|
646
|
+
throw corrupt('O lock do ledger mudou de proprietário durante o append.');
|
|
647
|
+
}
|
|
648
|
+
if (!removeLockIfUnchanged(store, state)) {
|
|
649
|
+
throw corrupt('O lock do ledger mudou de proprietário durante o append.');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function parseLedger(content) {
|
|
654
|
+
if (!content) return { records: [], byteOffsets: [] };
|
|
655
|
+
if (!content.endsWith('\n')) throw truncated('Ledger termina com JSON parcial ou sem newline de commit.');
|
|
656
|
+
const physicalLines = content.match(/[^\n]*\n/g) || [];
|
|
657
|
+
const records = [];
|
|
658
|
+
const byteOffsets = [];
|
|
659
|
+
let bytes = 0;
|
|
660
|
+
for (let index = 0; index < physicalLines.length; index += 1) {
|
|
661
|
+
const physical = physicalLines[index];
|
|
662
|
+
bytes += Buffer.byteLength(physical, 'utf8');
|
|
663
|
+
byteOffsets.push(bytes);
|
|
664
|
+
const json = physical.slice(0, -1).replace(/\r$/, '');
|
|
665
|
+
if (!json) throw corrupt(`Ledger contém linha vazia na posição ${index + 1}.`);
|
|
666
|
+
try {
|
|
667
|
+
records.push(JSON.parse(json));
|
|
668
|
+
} catch (error) {
|
|
669
|
+
throw corrupt(`Ledger contém JSON inválido na linha ${index + 1}.`, error);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return { records, byteOffsets };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function parseCheckpoint(content) {
|
|
676
|
+
if (!content) return null;
|
|
677
|
+
try {
|
|
678
|
+
return JSON.parse(content);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
throw corrupt('Checkpoint contém JSON inválido.', error);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function createFileReceiptStoreInternal({
|
|
685
|
+
ledgerPath,
|
|
686
|
+
checkpointPath = `${ledgerPath}.checkpoint.json`,
|
|
687
|
+
legacyPath = `${ledgerPath}.legacy.jsonl`,
|
|
688
|
+
lockPath = `${ledgerPath}.lock`,
|
|
689
|
+
lockLeaseMs = DEFAULT_LOCK_LEASE_MS,
|
|
690
|
+
lockWaitMs = DEFAULT_LOCK_WAIT_MS,
|
|
691
|
+
now = () => Date.now(),
|
|
692
|
+
fsAdapter = {},
|
|
693
|
+
}) {
|
|
694
|
+
if (!ledgerPath) throw corrupt('ledgerPath é obrigatório.');
|
|
695
|
+
if (!Number.isSafeInteger(lockLeaseMs) || lockLeaseMs < 1) throw corrupt('lockLeaseMs inválido.');
|
|
696
|
+
if (!Number.isSafeInteger(lockWaitMs) || lockWaitMs < 0) throw corrupt('lockWaitMs inválido.');
|
|
697
|
+
if (typeof now !== 'function') throw corrupt('clock do lock inválido.');
|
|
698
|
+
const fs = { ...nodeFs, ...fsAdapter };
|
|
699
|
+
const resolvedLedger = resolve(ledgerPath);
|
|
700
|
+
const rootPath = dirname(resolvedLedger);
|
|
701
|
+
assertNoSymlinkAncestors(rootPath, fs);
|
|
702
|
+
fs.mkdirSync(rootPath, { recursive: true });
|
|
703
|
+
assertNoSymlinkAncestors(rootPath, fs);
|
|
704
|
+
const rootStat = fs.lstatSync(rootPath);
|
|
705
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw corrupt('Runtime do ledger não é diretório seguro.');
|
|
706
|
+
const realRoot = resolve(fs.realpathSync(rootPath));
|
|
707
|
+
if (realRoot.toLowerCase() !== rootPath.toLowerCase()) throw corrupt('Runtime do ledger resolve por alias ou symlink.');
|
|
708
|
+
const store = {
|
|
709
|
+
ledgerPath: resolvedLedger,
|
|
710
|
+
checkpointPath: resolve(checkpointPath),
|
|
711
|
+
legacyPath: resolve(legacyPath),
|
|
712
|
+
lockPath: resolve(lockPath),
|
|
713
|
+
lockLeaseMs,
|
|
714
|
+
lockWaitMs,
|
|
715
|
+
now,
|
|
716
|
+
rootPath,
|
|
717
|
+
fs,
|
|
718
|
+
};
|
|
719
|
+
for (const path of [store.ledgerPath, store.checkpointPath, store.legacyPath, store.lockPath]) {
|
|
720
|
+
if (!pathInside(rootPath, path)) throw corrupt('Path do store escapa do runtime.');
|
|
721
|
+
assertSafeFile(store, path);
|
|
722
|
+
}
|
|
723
|
+
return Object.freeze(store);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function createFileReceiptStore(options) {
|
|
727
|
+
try {
|
|
728
|
+
return createFileReceiptStoreInternal(options);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
throw sanitizePublicError(error, 'inicialização do ledger');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function readReceiptLedgerUnlocked({ store }) {
|
|
735
|
+
if (!store?.ledgerPath || !store?.fs) throw corrupt('Store de receipts inválido.');
|
|
736
|
+
const legacyBytesRaw = secureRead(store, store.legacyPath, { encoding: null });
|
|
737
|
+
const raw = secureRead(store, store.ledgerPath);
|
|
738
|
+
const checkpointRaw = secureRead(store, store.checkpointPath);
|
|
739
|
+
if (raw && !checkpointRaw) {
|
|
740
|
+
throw truncated('Ledger não vazio está sem checkpoint obrigatório.');
|
|
741
|
+
}
|
|
742
|
+
const checkpoint = parseCheckpoint(checkpointRaw);
|
|
743
|
+
const { records, byteOffsets } = parseLedger(raw);
|
|
744
|
+
const verified = verifyReceiptChain({ records, checkpoint, legacyPrefix: legacyBytesRaw });
|
|
745
|
+
let checkpointStatus = verified.checkpoint_status;
|
|
746
|
+
if (checkpoint) {
|
|
747
|
+
const totalBytes = Buffer.byteLength(raw, 'utf8');
|
|
748
|
+
if (checkpoint.ledger_byte_length > totalBytes) {
|
|
749
|
+
throw truncated('Checkpoint prova truncamento de bytes no tail do ledger.');
|
|
750
|
+
}
|
|
751
|
+
const expectedBoundary = byteOffsets[checkpoint.last_sequence - 1];
|
|
752
|
+
if (checkpoint.ledger_byte_length !== expectedBoundary) {
|
|
753
|
+
throw corrupt('ledger_byte_length do checkpoint não coincide com a fronteira de um record.');
|
|
754
|
+
}
|
|
755
|
+
if (checkpoint.last_sequence === records.length && checkpoint.ledger_byte_length !== totalBytes) {
|
|
756
|
+
throw corrupt('Checkpoint declara o último record, mas não cobre todos os bytes do ledger.');
|
|
757
|
+
}
|
|
758
|
+
if (checkpoint.last_sequence < records.length && checkpoint.ledger_byte_length >= totalBytes) {
|
|
759
|
+
throw corrupt('Checkpoint atrasado não deixa um tail adicional verificável.');
|
|
760
|
+
}
|
|
761
|
+
checkpointStatus = checkpoint.last_sequence === records.length ? 'current' : 'lagging';
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
records,
|
|
765
|
+
checkpoint,
|
|
766
|
+
checkpoint_status: checkpointStatus,
|
|
767
|
+
legacy_prefix: legacyBytesRaw.toString('utf8'),
|
|
768
|
+
raw,
|
|
769
|
+
last_sequence: verified.last_sequence,
|
|
770
|
+
last_hash: verified.last_hash,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function readReceiptLedgerInternal({ store, assumeLocked = false }) {
|
|
775
|
+
if (assumeLocked) return readReceiptLedgerUnlocked({ store });
|
|
776
|
+
const token = acquireLock(store);
|
|
777
|
+
try {
|
|
778
|
+
return readReceiptLedgerUnlocked({ store });
|
|
779
|
+
} finally {
|
|
780
|
+
releaseLock(store, token);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function readReceiptLedger(options) {
|
|
785
|
+
try {
|
|
786
|
+
return readReceiptLedgerInternal(options);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
throw sanitizePublicError(error, 'leitura do ledger');
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function checkpointFor(raw, record) {
|
|
793
|
+
return {
|
|
794
|
+
schema_version: 2,
|
|
795
|
+
last_sequence: record.sequence,
|
|
796
|
+
last_hash: record.receipt_hash,
|
|
797
|
+
ledger_byte_length: Buffer.byteLength(raw, 'utf8'),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function publishCheckpoint(store, raw, record) {
|
|
802
|
+
atomicWrite(store, store.checkpointPath, `${JSON.stringify(checkpointFor(raw, record))}\n`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function appendReceiptInternal({ store, draft }) {
|
|
806
|
+
const logical = normalizedDraft(draft);
|
|
807
|
+
const receiptId = receiptIdentity(logical);
|
|
808
|
+
const token = acquireLock(store);
|
|
809
|
+
try {
|
|
810
|
+
const current = readReceiptLedger({ store, assumeLocked: true });
|
|
811
|
+
const existing = current.records.find((record) => record.receipt_id === receiptId);
|
|
812
|
+
if (existing) {
|
|
813
|
+
if (logicalContent(existing) !== logicalContent(logical)) {
|
|
814
|
+
throw ledgerError(
|
|
815
|
+
'WENDKEEP_RECEIPT_LEDGER_CONFLICT',
|
|
816
|
+
`receipt_id ${receiptId} já existe com claims ou observations diferentes.`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (current.checkpoint_status !== 'current') publishCheckpoint(store, current.raw, current.records.at(-1));
|
|
820
|
+
return { record: existing, idempotent: true, checkpoint_recovered: current.checkpoint_status !== 'current' };
|
|
821
|
+
}
|
|
822
|
+
const record = buildReceiptRecord(logical, {
|
|
823
|
+
sequence: current.records.length + 1,
|
|
824
|
+
previousHash: current.last_hash,
|
|
825
|
+
});
|
|
826
|
+
const raw = `${current.raw}${JSON.stringify(record)}\n`;
|
|
827
|
+
atomicWrite(store, store.ledgerPath, raw);
|
|
828
|
+
publishCheckpoint(store, raw, record);
|
|
829
|
+
return { record, idempotent: false, checkpoint_recovered: current.checkpoint_status === 'lagging' };
|
|
830
|
+
} finally {
|
|
831
|
+
releaseLock(store, token);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function appendReceipt(options) {
|
|
836
|
+
try {
|
|
837
|
+
return appendReceiptInternal(options);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
throw sanitizePublicError(error, 'append do ledger');
|
|
840
|
+
}
|
|
841
|
+
}
|