mdkg 0.1.8 → 0.1.10

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.
@@ -0,0 +1,599 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.writeProjectDbReceipt = writeProjectDbReceipt;
7
+ exports.recordProjectDbEvent = recordProjectDbEvent;
8
+ exports.applyProjectDbReducer = applyProjectDbReducer;
9
+ exports.replayProjectDbEvents = replayProjectDbEvents;
10
+ exports.acquireProjectWriterLease = acquireProjectWriterLease;
11
+ exports.commitProjectWriterLease = commitProjectWriterLease;
12
+ exports.releaseProjectWriterLease = releaseProjectWriterLease;
13
+ exports.releaseExpiredProjectWriterLeases = releaseExpiredProjectWriterLeases;
14
+ exports.readProjectWriterLeaseStats = readProjectWriterLeaseStats;
15
+ const crypto_1 = __importDefault(require("crypto"));
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ function loadDatabaseCtor() {
19
+ try {
20
+ const loaded = require("node:sqlite");
21
+ if (!loaded.DatabaseSync) {
22
+ throw new Error("node:sqlite DatabaseSync is unavailable");
23
+ }
24
+ return loaded.DatabaseSync;
25
+ }
26
+ catch (err) {
27
+ const message = err instanceof Error ? err.message : String(err);
28
+ throw new Error(`node:sqlite is required for mdkg project DB events: ${message}`);
29
+ }
30
+ }
31
+ function nowMs(input) {
32
+ if (input !== undefined) {
33
+ assertInteger(input, "now_ms");
34
+ return input;
35
+ }
36
+ return Date.now();
37
+ }
38
+ function assertNonEmpty(value, field) {
39
+ if (typeof value !== "string" || value.trim() === "") {
40
+ throw new Error(`${field} must be a non-empty string`);
41
+ }
42
+ }
43
+ function assertInteger(value, field) {
44
+ if (!Number.isInteger(value)) {
45
+ throw new Error(`${field} must be an integer`);
46
+ }
47
+ }
48
+ function assertPositiveInteger(value, field) {
49
+ assertInteger(value, field);
50
+ if (value <= 0) {
51
+ throw new Error(`${field} must be greater than 0`);
52
+ }
53
+ }
54
+ function stableJson(value) {
55
+ if (value === undefined || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
56
+ throw new Error("value must be JSON-serializable");
57
+ }
58
+ if (value === null || typeof value !== "object") {
59
+ if (typeof value === "number" && !Number.isFinite(value)) {
60
+ throw new Error("value must be JSON-serializable");
61
+ }
62
+ const encoded = JSON.stringify(value);
63
+ if (encoded === undefined) {
64
+ throw new Error("value must be JSON-serializable");
65
+ }
66
+ return encoded;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ return `[${value.map((item) => stableJson(item)).join(",")}]`;
70
+ }
71
+ const object = value;
72
+ return `{${Object.keys(object)
73
+ .sort()
74
+ .map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`)
75
+ .join(",")}}`;
76
+ }
77
+ function hashJson(payloadJson) {
78
+ return `sha256:${crypto_1.default.createHash("sha256").update(payloadJson).digest("hex")}`;
79
+ }
80
+ function safeSegment(value) {
81
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "receipt";
82
+ }
83
+ function toMessage(row) {
84
+ return row ?? null;
85
+ }
86
+ function toEvent(row) {
87
+ return {
88
+ event_id: String(row.event_id),
89
+ project_id: String(row.project_id),
90
+ branch_id: String(row.branch_id),
91
+ event_type: String(row.event_type),
92
+ schema_version: Number(row.schema_version),
93
+ idempotency_key: String(row.idempotency_key),
94
+ payload_json: String(row.payload_json),
95
+ payload_hash: String(row.payload_hash),
96
+ actor: String(row.actor),
97
+ status: String(row.status),
98
+ occurred_at_ms: Number(row.occurred_at_ms),
99
+ created_at_ms: Number(row.created_at_ms),
100
+ updated_at_ms: Number(row.updated_at_ms),
101
+ last_error: row.last_error === null || row.last_error === undefined ? null : String(row.last_error),
102
+ };
103
+ }
104
+ function toReceipt(row) {
105
+ return {
106
+ receipt_id: String(row.receipt_id),
107
+ project_id: String(row.project_id),
108
+ branch_id: String(row.branch_id),
109
+ kind: String(row.kind),
110
+ status: String(row.status),
111
+ event_id: row.event_id === null || row.event_id === undefined ? null : String(row.event_id),
112
+ idempotency_key: row.idempotency_key === null || row.idempotency_key === undefined ? null : String(row.idempotency_key),
113
+ payload_hash: row.payload_hash === null || row.payload_hash === undefined ? null : String(row.payload_hash),
114
+ base_snapshot_hash: row.base_snapshot_hash === null || row.base_snapshot_hash === undefined ? null : String(row.base_snapshot_hash),
115
+ result_snapshot_hash: row.result_snapshot_hash === null || row.result_snapshot_hash === undefined ? null : String(row.result_snapshot_hash),
116
+ reducer_name: row.reducer_name === null || row.reducer_name === undefined ? null : String(row.reducer_name),
117
+ reducer_version: row.reducer_version === null || row.reducer_version === undefined ? null : String(row.reducer_version),
118
+ lease_id: row.lease_id === null || row.lease_id === undefined ? null : String(row.lease_id),
119
+ actor: row.actor === null || row.actor === undefined ? null : String(row.actor),
120
+ artifact_path: String(row.artifact_path),
121
+ artifact_hash: String(row.artifact_hash),
122
+ details_json: String(row.details_json),
123
+ created_at_ms: Number(row.created_at_ms),
124
+ };
125
+ }
126
+ function toLease(row) {
127
+ return {
128
+ project_id: String(row.project_id),
129
+ branch_id: String(row.branch_id),
130
+ lease_id: String(row.lease_id),
131
+ lease_owner: String(row.lease_owner),
132
+ base_snapshot_hash: String(row.base_snapshot_hash),
133
+ status: String(row.status),
134
+ lease_deadline_ms: Number(row.lease_deadline_ms),
135
+ result_snapshot_hash: row.result_snapshot_hash === null || row.result_snapshot_hash === undefined ? null : String(row.result_snapshot_hash),
136
+ receipt_id: row.receipt_id === null || row.receipt_id === undefined ? null : String(row.receipt_id),
137
+ created_at_ms: Number(row.created_at_ms),
138
+ updated_at_ms: Number(row.updated_at_ms),
139
+ last_error: row.last_error === null || row.last_error === undefined ? null : String(row.last_error),
140
+ };
141
+ }
142
+ function withDb(databasePath, fn) {
143
+ const DatabaseSync = loadDatabaseCtor();
144
+ const db = new DatabaseSync(databasePath);
145
+ try {
146
+ db.exec("PRAGMA foreign_keys = ON;");
147
+ return fn(db);
148
+ }
149
+ finally {
150
+ db.close();
151
+ }
152
+ }
153
+ function withImmediateTransaction(db, fn) {
154
+ db.exec("BEGIN IMMEDIATE");
155
+ try {
156
+ const result = fn();
157
+ db.exec("COMMIT");
158
+ return result;
159
+ }
160
+ catch (err) {
161
+ try {
162
+ db.exec("ROLLBACK");
163
+ }
164
+ catch {
165
+ // ignore rollback failures when no transaction is active
166
+ }
167
+ throw err;
168
+ }
169
+ }
170
+ function receiptRoot(databasePath, receiptsPath) {
171
+ if (receiptsPath) {
172
+ return receiptsPath;
173
+ }
174
+ return path_1.default.join(path_1.default.dirname(path_1.default.dirname(databasePath)), "receipts");
175
+ }
176
+ function relativeArtifactPath(databasePath, artifactPath) {
177
+ const dbRoot = path_1.default.dirname(path_1.default.dirname(databasePath));
178
+ return artifactPath.split(path_1.default.sep).join("/").startsWith(".")
179
+ ? artifactPath
180
+ : path_1.default.relative(dbRoot, artifactPath).split(path_1.default.sep).join("/");
181
+ }
182
+ function ensureReceiptId(input, detailsJson) {
183
+ if (input.receipt_id !== undefined) {
184
+ assertNonEmpty(input.receipt_id, "receipt_id");
185
+ return input.receipt_id;
186
+ }
187
+ const hash = crypto_1.default
188
+ .createHash("sha256")
189
+ .update([
190
+ input.project_id,
191
+ input.branch_id,
192
+ input.kind,
193
+ input.status,
194
+ input.event_id ?? "",
195
+ input.idempotency_key ?? "",
196
+ input.payload_hash ?? "",
197
+ input.base_snapshot_hash ?? "",
198
+ input.result_snapshot_hash ?? "",
199
+ input.reducer_name ?? "",
200
+ input.reducer_version ?? "",
201
+ input.lease_id ?? "",
202
+ detailsJson,
203
+ String(input.now_ms ?? ""),
204
+ ].join("\n"))
205
+ .digest("hex")
206
+ .slice(0, 16);
207
+ return `receipt-${safeSegment(input.project_id)}-${safeSegment(input.branch_id)}-${safeSegment(input.kind)}-${hash}`;
208
+ }
209
+ function writeReceiptWithDb(db, databasePath, input) {
210
+ assertNonEmpty(input.project_id, "project_id");
211
+ assertNonEmpty(input.branch_id, "branch_id");
212
+ assertNonEmpty(input.kind, "kind");
213
+ const currentNow = nowMs(input.now_ms);
214
+ const detailsJson = stableJson(input.details ?? {});
215
+ const receiptId = ensureReceiptId(input, detailsJson);
216
+ const root = receiptRoot(databasePath, input.receipts_path);
217
+ fs_1.default.mkdirSync(root, { recursive: true });
218
+ const artifactPath = path_1.default.join(root, `${safeSegment(receiptId)}.json`);
219
+ const artifact = {
220
+ receipt_id: receiptId,
221
+ project_id: input.project_id,
222
+ branch_id: input.branch_id,
223
+ kind: input.kind,
224
+ status: input.status,
225
+ event_id: input.event_id ?? null,
226
+ idempotency_key: input.idempotency_key ?? null,
227
+ payload_hash: input.payload_hash ?? null,
228
+ base_snapshot_hash: input.base_snapshot_hash ?? null,
229
+ result_snapshot_hash: input.result_snapshot_hash ?? null,
230
+ reducer_name: input.reducer_name ?? null,
231
+ reducer_version: input.reducer_version ?? null,
232
+ lease_id: input.lease_id ?? null,
233
+ actor: input.actor ?? null,
234
+ details: JSON.parse(detailsJson),
235
+ created_at_ms: currentNow,
236
+ };
237
+ const artifactJson = `${stableJson(artifact)}\n`;
238
+ fs_1.default.writeFileSync(artifactPath, artifactJson, "utf8");
239
+ const artifactHash = hashJson(artifactJson);
240
+ const artifactRelative = relativeArtifactPath(databasePath, artifactPath);
241
+ db
242
+ .prepare([
243
+ "INSERT INTO project_receipt",
244
+ "(receipt_id, project_id, branch_id, kind, status, event_id, idempotency_key, payload_hash, base_snapshot_hash, result_snapshot_hash, reducer_name, reducer_version, lease_id, actor, artifact_path, artifact_hash, details_json, created_at_ms)",
245
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
246
+ "ON CONFLICT(receipt_id) DO UPDATE SET",
247
+ "artifact_path = excluded.artifact_path, artifact_hash = excluded.artifact_hash, details_json = excluded.details_json",
248
+ ].join(" "))
249
+ .run(receiptId, input.project_id, input.branch_id, input.kind, input.status, input.event_id ?? null, input.idempotency_key ?? null, input.payload_hash ?? null, input.base_snapshot_hash ?? null, input.result_snapshot_hash ?? null, input.reducer_name ?? null, input.reducer_version ?? null, input.lease_id ?? null, input.actor ?? null, artifactRelative, artifactHash, detailsJson, currentNow);
250
+ const row = db.prepare("SELECT * FROM project_receipt WHERE receipt_id = ?").get(receiptId);
251
+ if (!row) {
252
+ throw new Error("receipt could not be reloaded after insert");
253
+ }
254
+ return toReceipt(row);
255
+ }
256
+ function getEvent(db, eventId) {
257
+ const row = db.prepare("SELECT * FROM project_event WHERE event_id = ?").get(eventId);
258
+ return row ? toEvent(row) : null;
259
+ }
260
+ function getLease(db, projectId, branchId, leaseId) {
261
+ const row = db
262
+ .prepare("SELECT * FROM project_writer_lease WHERE project_id = ? AND branch_id = ? AND lease_id = ?")
263
+ .get(projectId, branchId, leaseId);
264
+ return row ? toLease(row) : null;
265
+ }
266
+ function writeProjectDbReceipt(databasePath, input) {
267
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => writeReceiptWithDb(db, databasePath, input)));
268
+ }
269
+ function recordProjectDbEvent(databasePath, input) {
270
+ assertNonEmpty(input.event_id, "event_id");
271
+ assertNonEmpty(input.project_id, "project_id");
272
+ assertNonEmpty(input.branch_id, "branch_id");
273
+ assertNonEmpty(input.event_type, "event_type");
274
+ assertPositiveInteger(input.schema_version, "schema_version");
275
+ assertNonEmpty(input.idempotency_key, "idempotency_key");
276
+ assertNonEmpty(input.actor, "actor");
277
+ const currentNow = nowMs(input.now_ms);
278
+ const occurredAt = input.occurred_at_ms ?? currentNow;
279
+ assertInteger(occurredAt, "occurred_at_ms");
280
+ const payloadJson = stableJson(input.payload);
281
+ const payloadHash = hashJson(payloadJson);
282
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
283
+ const duplicateRow = db
284
+ .prepare("SELECT * FROM project_event WHERE project_id = ? AND branch_id = ? AND idempotency_key = ?")
285
+ .get(input.project_id, input.branch_id, input.idempotency_key);
286
+ if (duplicateRow) {
287
+ const duplicate = toEvent(duplicateRow);
288
+ if (duplicate.payload_hash === payloadHash) {
289
+ return { created: false, duplicate: true, conflict: false, event: duplicate, receipt: null };
290
+ }
291
+ const receipt = writeReceiptWithDb(db, databasePath, {
292
+ project_id: input.project_id,
293
+ branch_id: input.branch_id,
294
+ kind: "event-conflict",
295
+ status: "conflict",
296
+ event_id: duplicate.event_id,
297
+ idempotency_key: input.idempotency_key,
298
+ payload_hash: payloadHash,
299
+ actor: input.actor,
300
+ details: {
301
+ existing_event_id: duplicate.event_id,
302
+ existing_payload_hash: duplicate.payload_hash,
303
+ incoming_event_id: input.event_id,
304
+ incoming_payload_hash: payloadHash,
305
+ },
306
+ now_ms: currentNow,
307
+ receipts_path: input.receipts_path,
308
+ });
309
+ return { created: false, duplicate: false, conflict: true, event: duplicate, receipt };
310
+ }
311
+ db
312
+ .prepare([
313
+ "INSERT INTO project_event",
314
+ "(event_id, project_id, branch_id, event_type, schema_version, idempotency_key, payload_json, payload_hash, actor, status, occurred_at_ms, created_at_ms, updated_at_ms, last_error)",
315
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'received', ?, ?, ?, NULL)",
316
+ ].join(" "))
317
+ .run(input.event_id, input.project_id, input.branch_id, input.event_type, input.schema_version, input.idempotency_key, payloadJson, payloadHash, input.actor, occurredAt, currentNow, currentNow);
318
+ const event = getEvent(db, input.event_id);
319
+ if (!event) {
320
+ throw new Error("event could not be reloaded after insert");
321
+ }
322
+ return { created: true, duplicate: false, conflict: false, event, receipt: null };
323
+ }));
324
+ }
325
+ function parseProjectMetaSet(event) {
326
+ if (event.event_type !== "project_meta.set") {
327
+ throw new Error(`unsupported event_type for reducer project_meta.set: ${event.event_type}`);
328
+ }
329
+ const payload = JSON.parse(event.payload_json);
330
+ if (typeof payload.key !== "string" || payload.key.trim() === "") {
331
+ throw new Error("project_meta.set payload.key must be a non-empty string");
332
+ }
333
+ if (typeof payload.value !== "string") {
334
+ throw new Error("project_meta.set payload.value must be a string");
335
+ }
336
+ return { key: payload.key, value: payload.value };
337
+ }
338
+ function applyProjectMetaSet(db, event) {
339
+ const payload = parseProjectMetaSet(event);
340
+ db
341
+ .prepare("INSERT INTO project_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value WHERE value <> excluded.value")
342
+ .run(payload.key, payload.value);
343
+ return payload;
344
+ }
345
+ function applyProjectDbReducer(databasePath, input) {
346
+ assertNonEmpty(input.event_id, "event_id");
347
+ assertNonEmpty(input.actor, "actor");
348
+ const currentNow = nowMs(input.now_ms);
349
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
350
+ const event = getEvent(db, input.event_id);
351
+ if (!event) {
352
+ throw new Error(`project DB event not found: ${input.event_id}`);
353
+ }
354
+ if (event.status === "applied") {
355
+ const receipt = writeReceiptWithDb(db, databasePath, {
356
+ project_id: event.project_id,
357
+ branch_id: event.branch_id,
358
+ kind: "event-duplicate",
359
+ status: "duplicate",
360
+ event_id: event.event_id,
361
+ idempotency_key: event.idempotency_key,
362
+ payload_hash: event.payload_hash,
363
+ reducer_name: input.reducer_name,
364
+ reducer_version: input.reducer_version,
365
+ actor: input.actor,
366
+ details: { reason: "event already applied" },
367
+ now_ms: currentNow,
368
+ receipts_path: input.receipts_path,
369
+ });
370
+ return { applied: false, event, receipt };
371
+ }
372
+ try {
373
+ const change = applyProjectMetaSet(db, event);
374
+ db
375
+ .prepare("UPDATE project_event SET status = 'applied', updated_at_ms = ?, last_error = NULL WHERE event_id = ?")
376
+ .run(currentNow, event.event_id);
377
+ const updated = getEvent(db, event.event_id);
378
+ if (!updated) {
379
+ throw new Error("event could not be reloaded after reducer apply");
380
+ }
381
+ const receipt = writeReceiptWithDb(db, databasePath, {
382
+ project_id: event.project_id,
383
+ branch_id: event.branch_id,
384
+ kind: "event-applied",
385
+ status: "applied",
386
+ event_id: event.event_id,
387
+ idempotency_key: event.idempotency_key,
388
+ payload_hash: event.payload_hash,
389
+ reducer_name: input.reducer_name,
390
+ reducer_version: input.reducer_version,
391
+ actor: input.actor,
392
+ details: { change },
393
+ now_ms: currentNow,
394
+ receipts_path: input.receipts_path,
395
+ });
396
+ return { applied: true, event: updated, receipt };
397
+ }
398
+ catch (err) {
399
+ const message = err instanceof Error ? err.message : String(err);
400
+ db
401
+ .prepare("UPDATE project_event SET status = 'rejected', updated_at_ms = ?, last_error = ? WHERE event_id = ?")
402
+ .run(currentNow, message, event.event_id);
403
+ const updated = getEvent(db, event.event_id);
404
+ if (!updated) {
405
+ throw new Error("event could not be reloaded after reducer reject");
406
+ }
407
+ const receipt = writeReceiptWithDb(db, databasePath, {
408
+ project_id: event.project_id,
409
+ branch_id: event.branch_id,
410
+ kind: "event-rejected",
411
+ status: "rejected",
412
+ event_id: event.event_id,
413
+ idempotency_key: event.idempotency_key,
414
+ payload_hash: event.payload_hash,
415
+ reducer_name: input.reducer_name,
416
+ reducer_version: input.reducer_version,
417
+ actor: input.actor,
418
+ details: { error: message },
419
+ now_ms: currentNow,
420
+ receipts_path: input.receipts_path,
421
+ });
422
+ return { applied: false, event: updated, receipt };
423
+ }
424
+ }));
425
+ }
426
+ function replayProjectDbEvents(databasePath, input) {
427
+ assertNonEmpty(input.project_id, "project_id");
428
+ assertNonEmpty(input.branch_id, "branch_id");
429
+ assertNonEmpty(input.actor, "actor");
430
+ const currentNow = nowMs(input.now_ms);
431
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
432
+ const rows = db
433
+ .prepare("SELECT * FROM project_event WHERE project_id = ? AND branch_id = ? AND event_type = ? AND status IN ('received', 'validated', 'applied') ORDER BY occurred_at_ms ASC, event_id ASC")
434
+ .all(input.project_id, input.branch_id, input.reducer_name);
435
+ for (const row of rows) {
436
+ applyProjectMetaSet(db, toEvent(row));
437
+ }
438
+ const receipt = writeReceiptWithDb(db, databasePath, {
439
+ project_id: input.project_id,
440
+ branch_id: input.branch_id,
441
+ kind: "event-replay",
442
+ status: "replay",
443
+ reducer_name: input.reducer_name,
444
+ reducer_version: input.reducer_version,
445
+ actor: input.actor,
446
+ details: { replayed_event_ids: rows.map((row) => String(row.event_id)) },
447
+ now_ms: currentNow,
448
+ receipts_path: input.receipts_path,
449
+ });
450
+ return { replayed_count: rows.length, receipt };
451
+ }));
452
+ }
453
+ function requireActiveLease(db, input) {
454
+ assertNonEmpty(input.project_id, "project_id");
455
+ assertNonEmpty(input.branch_id, "branch_id");
456
+ assertNonEmpty(input.lease_id, "lease_id");
457
+ assertNonEmpty(input.lease_owner, "lease_owner");
458
+ const lease = getLease(db, input.project_id, input.branch_id, input.lease_id);
459
+ if (!lease) {
460
+ throw new Error(`writer lease not found: ${input.project_id}/${input.branch_id}/${input.lease_id}`);
461
+ }
462
+ if (lease.status !== "active") {
463
+ throw new Error(`writer lease ${input.lease_id} is not active`);
464
+ }
465
+ if (lease.lease_owner !== input.lease_owner) {
466
+ throw new Error(`writer lease ${input.lease_id} is not owned by ${input.lease_owner}`);
467
+ }
468
+ return lease;
469
+ }
470
+ function acquireProjectWriterLease(databasePath, input) {
471
+ assertNonEmpty(input.project_id, "project_id");
472
+ assertNonEmpty(input.branch_id, "branch_id");
473
+ assertNonEmpty(input.lease_id, "lease_id");
474
+ assertNonEmpty(input.lease_owner, "lease_owner");
475
+ assertNonEmpty(input.base_snapshot_hash, "base_snapshot_hash");
476
+ assertPositiveInteger(input.lease_ms, "lease_ms");
477
+ const currentNow = nowMs(input.now_ms);
478
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
479
+ db
480
+ .prepare("UPDATE project_writer_lease SET status = 'expired', updated_at_ms = ?, last_error = 'lease expired' WHERE status = 'active' AND lease_deadline_ms <= ?")
481
+ .run(currentNow, currentNow);
482
+ db
483
+ .prepare("INSERT INTO project_branch_state (project_id, branch_id, current_snapshot_hash, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(project_id, branch_id) DO NOTHING")
484
+ .run(input.project_id, input.branch_id, input.base_snapshot_hash, currentNow);
485
+ db
486
+ .prepare([
487
+ "INSERT INTO project_writer_lease",
488
+ "(project_id, branch_id, lease_id, lease_owner, base_snapshot_hash, status, lease_deadline_ms, result_snapshot_hash, receipt_id, created_at_ms, updated_at_ms, last_error)",
489
+ "VALUES (?, ?, ?, ?, ?, 'active', ?, NULL, NULL, ?, ?, NULL)",
490
+ ].join(" "))
491
+ .run(input.project_id, input.branch_id, input.lease_id, input.lease_owner, input.base_snapshot_hash, currentNow + input.lease_ms, currentNow, currentNow);
492
+ const lease = getLease(db, input.project_id, input.branch_id, input.lease_id);
493
+ if (!lease) {
494
+ throw new Error("writer lease could not be reloaded after insert");
495
+ }
496
+ return lease;
497
+ }));
498
+ }
499
+ function commitProjectWriterLease(databasePath, input) {
500
+ assertNonEmpty(input.result_snapshot_hash, "result_snapshot_hash");
501
+ const currentNow = nowMs(input.now_ms);
502
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
503
+ const lease = requireActiveLease(db, input);
504
+ const state = toMessage(db
505
+ .prepare("SELECT * FROM project_branch_state WHERE project_id = ? AND branch_id = ?")
506
+ .get(input.project_id, input.branch_id));
507
+ const currentSnapshotHash = state ? String(state.current_snapshot_hash) : lease.base_snapshot_hash;
508
+ if (currentSnapshotHash !== lease.base_snapshot_hash) {
509
+ const receipt = writeReceiptWithDb(db, databasePath, {
510
+ project_id: input.project_id,
511
+ branch_id: input.branch_id,
512
+ kind: "writer-conflict",
513
+ status: "conflict",
514
+ base_snapshot_hash: lease.base_snapshot_hash,
515
+ result_snapshot_hash: input.result_snapshot_hash,
516
+ lease_id: input.lease_id,
517
+ actor: input.lease_owner,
518
+ details: { current_snapshot_hash: currentSnapshotHash },
519
+ now_ms: currentNow,
520
+ receipts_path: input.receipts_path,
521
+ });
522
+ db
523
+ .prepare("UPDATE project_writer_lease SET status = 'conflict', result_snapshot_hash = ?, receipt_id = ?, updated_at_ms = ?, last_error = ? WHERE project_id = ? AND branch_id = ? AND lease_id = ?")
524
+ .run(input.result_snapshot_hash, receipt.receipt_id, currentNow, `snapshot hash mismatch: current ${currentSnapshotHash}, base ${lease.base_snapshot_hash}`, input.project_id, input.branch_id, input.lease_id);
525
+ const updated = getLease(db, input.project_id, input.branch_id, input.lease_id);
526
+ if (!updated) {
527
+ throw new Error("writer lease could not be reloaded after conflict");
528
+ }
529
+ return { committed: false, lease: updated, receipt };
530
+ }
531
+ const receipt = writeReceiptWithDb(db, databasePath, {
532
+ project_id: input.project_id,
533
+ branch_id: input.branch_id,
534
+ kind: "writer-commit",
535
+ status: "applied",
536
+ base_snapshot_hash: lease.base_snapshot_hash,
537
+ result_snapshot_hash: input.result_snapshot_hash,
538
+ lease_id: input.lease_id,
539
+ actor: input.lease_owner,
540
+ details: { committed: true },
541
+ now_ms: currentNow,
542
+ receipts_path: input.receipts_path,
543
+ });
544
+ db
545
+ .prepare("UPDATE project_branch_state SET current_snapshot_hash = ?, updated_at_ms = ? WHERE project_id = ? AND branch_id = ?")
546
+ .run(input.result_snapshot_hash, currentNow, input.project_id, input.branch_id);
547
+ db
548
+ .prepare("UPDATE project_writer_lease SET status = 'committed', result_snapshot_hash = ?, receipt_id = ?, updated_at_ms = ?, last_error = NULL WHERE project_id = ? AND branch_id = ? AND lease_id = ?")
549
+ .run(input.result_snapshot_hash, receipt.receipt_id, currentNow, input.project_id, input.branch_id, input.lease_id);
550
+ const updated = getLease(db, input.project_id, input.branch_id, input.lease_id);
551
+ if (!updated) {
552
+ throw new Error("writer lease could not be reloaded after commit");
553
+ }
554
+ return { committed: true, lease: updated, receipt };
555
+ }));
556
+ }
557
+ function releaseProjectWriterLease(databasePath, input) {
558
+ const currentNow = nowMs(input.now_ms);
559
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
560
+ requireActiveLease(db, input);
561
+ db
562
+ .prepare("UPDATE project_writer_lease SET status = 'released', updated_at_ms = ?, last_error = NULL WHERE project_id = ? AND branch_id = ? AND lease_id = ?")
563
+ .run(currentNow, input.project_id, input.branch_id, input.lease_id);
564
+ const updated = getLease(db, input.project_id, input.branch_id, input.lease_id);
565
+ if (!updated) {
566
+ throw new Error("writer lease could not be reloaded after release");
567
+ }
568
+ return updated;
569
+ }));
570
+ }
571
+ function releaseExpiredProjectWriterLeases(databasePath, input = {}) {
572
+ const currentNow = nowMs(input.now_ms);
573
+ return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
574
+ const result = db
575
+ .prepare("UPDATE project_writer_lease SET status = 'expired', updated_at_ms = ?, last_error = 'lease expired' WHERE status = 'active' AND lease_deadline_ms <= ?")
576
+ .run(currentNow, currentNow);
577
+ return { released_count: Number(result.changes ?? 0) };
578
+ }));
579
+ }
580
+ function readProjectWriterLeaseStats(databasePath, input = {}) {
581
+ const currentNow = nowMs(input.now_ms);
582
+ return withDb(databasePath, (db) => {
583
+ const byStatus = {
584
+ active: 0,
585
+ committed: 0,
586
+ released: 0,
587
+ expired: 0,
588
+ conflict: 0,
589
+ };
590
+ for (const row of db.prepare("SELECT status, COUNT(*) AS count FROM project_writer_lease GROUP BY status").all()) {
591
+ byStatus[String(row.status)] = Number(row.count ?? 0);
592
+ }
593
+ const active = Number(db.prepare("SELECT COUNT(*) AS count FROM project_writer_lease WHERE status = 'active'").get()?.count ?? 0);
594
+ const expired = Number(db
595
+ .prepare("SELECT COUNT(*) AS count FROM project_writer_lease WHERE status = 'active' AND lease_deadline_ms <= ?")
596
+ .get(currentNow)?.count ?? 0);
597
+ return { active, expired, by_status: byStatus };
598
+ });
599
+ }