mdkg 0.1.7 → 0.1.9
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 +27 -0
- package/README.md +12 -4
- package/dist/cli.js +6 -3
- package/dist/core/project_db_events.js +599 -0
- package/dist/core/project_db_migrations.js +137 -1
- package/dist/core/project_db_queue.js +317 -0
- package/dist/init/AGENT_START.md +9 -3
- package/dist/init/CLI_COMMAND_MATRIX.md +8 -1
- package/dist/init/README.md +7 -2
- package/dist/init/init-manifest.json +4 -4
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ This project follows a pragmatic changelog style inspired by Keep a Changelog. V
|
|
|
6
6
|
|
|
7
7
|
mdkg is pre-v1 public alpha software. Command, graph, cache, bundle, and DAL contracts may change quickly while the project converges on a stable v1 surface.
|
|
8
8
|
|
|
9
|
+
## 0.1.9 - Unreleased
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added internal local project DB event, receipt, typed reducer, and writer
|
|
14
|
+
lease/CAS foundations after the queue migration.
|
|
15
|
+
- Added packed `smoke:db-events` coverage for event idempotency, conflict
|
|
16
|
+
receipts, reducer application/replay, writer lease CAS conflicts, snapshots,
|
|
17
|
+
stats, index, and validate.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Project DB migration order now includes events/receipts and writer leases
|
|
22
|
+
after the `0.1.8` queue foundation.
|
|
23
|
+
- Publish readiness now requires the compiled event helper and seeded docs that
|
|
24
|
+
keep event/reducer/lease support internal-only.
|
|
25
|
+
|
|
26
|
+
## 0.1.8 - 2026-06-04
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Added internal local node:sqlite queue foundations for project DB delivery state.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Source release line now targets the next non-published package version after `0.1.7`.
|
|
35
|
+
|
|
9
36
|
## 0.1.7 - 2026-06-04
|
|
10
37
|
|
|
11
38
|
### Added
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ mdkg stays deliberately boring:
|
|
|
14
14
|
- first-class rebuildable SQLite cache through built-in `node:sqlite`
|
|
15
15
|
- no daemon, hosted index, or vector DB
|
|
16
16
|
|
|
17
|
-
Current package version in source: `0.1.
|
|
17
|
+
Current package version in source: `0.1.9`
|
|
18
18
|
|
|
19
19
|
mdkg is still pre-v1 public alpha software. The public package is usable, but graph, cache, bundle, and DAL contracts may continue to change quickly while the project converges on a stable v1 surface.
|
|
20
20
|
|
|
@@ -348,9 +348,17 @@ layout is `.mdkg/db/schema`, `.mdkg/db/runtime`, `.mdkg/db/state`, and
|
|
|
348
348
|
Runtime DB files, WAL, SHM, journal, lock, and temp files are ignored by
|
|
349
349
|
default. `mdkg db init` does not create an active runtime SQLite database.
|
|
350
350
|
Run `mdkg db migrate` after init to create or update the active runtime
|
|
351
|
-
SQLite database at the configured `db.runtime_path`;
|
|
352
|
-
|
|
353
|
-
|
|
351
|
+
SQLite database at the configured `db.runtime_path`; built-in migrations write
|
|
352
|
+
mdkg-owned generic foundation tables, then the internal local node:sqlite queue
|
|
353
|
+
foundation, then internal local event/receipt/reducer and writer lease/CAS
|
|
354
|
+
foundations, and record migration order, checksums, and applied timestamps.
|
|
355
|
+
Queue state is durable local delivery infrastructure, not canonical event
|
|
356
|
+
history. Event rows are the durable local history for project DB state
|
|
357
|
+
transitions, receipts provide audit/review artifacts, reducers gate writes, and
|
|
358
|
+
writer leases coordinate snapshot-hash compare-and-swap commits. These
|
|
359
|
+
capabilities are available only through internal helper modules in this release;
|
|
360
|
+
there is no public `mdkg db queue`, `mdkg db event`, `mdkg db reducer`, or
|
|
361
|
+
`mdkg db lease` CLI yet.
|
|
354
362
|
Use `mdkg db verify` for non-mutating health checks over config, layout,
|
|
355
363
|
runtime SQLite integrity, migration metadata, and transient runtime files. Use
|
|
356
364
|
`mdkg db stats` for deterministic table counts, DB size, migration state,
|
package/dist/cli.js
CHANGED
|
@@ -222,16 +222,19 @@ function printDbHelp(log, subcommand) {
|
|
|
222
222
|
log(" mdkg db snapshot diff <left-snapshot> <right-snapshot> [--json]");
|
|
223
223
|
log("\nBoundaries:");
|
|
224
224
|
log(" - `.mdkg/index` is the rebuildable graph cache");
|
|
225
|
-
log(" - `.mdkg/db` is
|
|
225
|
+
log(" - `.mdkg/db` is project application state");
|
|
226
226
|
log(" - `mdkg db init` creates the generic layout and enables db config");
|
|
227
227
|
log(" - `mdkg db init` does not create an active runtime SQLite database");
|
|
228
228
|
log(" - `mdkg db migrate` creates/updates the active runtime SQLite database");
|
|
229
|
-
log(" - `mdkg db migrate` applies mdkg-owned
|
|
229
|
+
log(" - `mdkg db migrate` applies mdkg-owned foundation plus internal queue, event, receipt, reducer, and lease migrations");
|
|
230
|
+
log(" - queue tables are internal local delivery state; no public `mdkg db queue` CLI is exposed");
|
|
231
|
+
log(" - event rows are durable local history; receipts, reducers, and writer leases remain internal helper surfaces");
|
|
232
|
+
log(" - no public `mdkg db event`, `mdkg db reducer`, or `mdkg db lease` CLI is exposed");
|
|
230
233
|
log(" - `mdkg db verify` checks config, layout, SQLite integrity, migrations, and transient files");
|
|
231
234
|
log(" - `mdkg db stats` reports table counts, migration state, DB size, and receipt counts");
|
|
232
235
|
log(" - `mdkg db snapshot ...` manages opt-in sealed checkpoints and review dumps");
|
|
233
236
|
log(" - active `.mdkg/db/runtime` and transient DB files are ignored by default");
|
|
234
|
-
log(" - no raw SQL, hosted queue, profile, or publish behavior is exposed here");
|
|
237
|
+
log(" - no raw SQL, hosted queue/event store, profile, public queue/event/reducer/lease command, or publish behavior is exposed here");
|
|
235
238
|
printGlobalOptions(log);
|
|
236
239
|
}
|
|
237
240
|
}
|
|
@@ -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
|
+
}
|
|
@@ -19,6 +19,124 @@ CREATE TABLE IF NOT EXISTS project_meta (
|
|
|
19
19
|
value TEXT NOT NULL
|
|
20
20
|
) STRICT;
|
|
21
21
|
`;
|
|
22
|
+
const QUEUE_MIGRATION_SQL = `
|
|
23
|
+
CREATE TABLE IF NOT EXISTS project_queue_message (
|
|
24
|
+
queue_name TEXT NOT NULL,
|
|
25
|
+
message_id TEXT NOT NULL,
|
|
26
|
+
dedupe_key TEXT,
|
|
27
|
+
payload_json TEXT NOT NULL,
|
|
28
|
+
payload_hash TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL CHECK(status IN ('ready', 'leased', 'acked', 'dead_letter')),
|
|
30
|
+
available_at_ms INTEGER NOT NULL,
|
|
31
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
|
|
32
|
+
max_attempts INTEGER NOT NULL CHECK(max_attempts > 0),
|
|
33
|
+
lease_owner TEXT,
|
|
34
|
+
lease_deadline_ms INTEGER,
|
|
35
|
+
created_at_ms INTEGER NOT NULL,
|
|
36
|
+
updated_at_ms INTEGER NOT NULL,
|
|
37
|
+
last_error TEXT,
|
|
38
|
+
CHECK (
|
|
39
|
+
(status = 'leased' AND lease_owner IS NOT NULL AND lease_deadline_ms IS NOT NULL)
|
|
40
|
+
OR
|
|
41
|
+
(status <> 'leased' AND lease_owner IS NULL AND lease_deadline_ms IS NULL)
|
|
42
|
+
),
|
|
43
|
+
PRIMARY KEY (queue_name, message_id)
|
|
44
|
+
) STRICT;
|
|
45
|
+
|
|
46
|
+
CREATE UNIQUE INDEX IF NOT EXISTS project_queue_message_dedupe_unique
|
|
47
|
+
ON project_queue_message(queue_name, dedupe_key)
|
|
48
|
+
WHERE dedupe_key IS NOT NULL;
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS project_queue_message_ready_idx
|
|
51
|
+
ON project_queue_message(queue_name, status, available_at_ms, created_at_ms, message_id);
|
|
52
|
+
|
|
53
|
+
CREATE INDEX IF NOT EXISTS project_queue_message_lease_idx
|
|
54
|
+
ON project_queue_message(queue_name, status, lease_deadline_ms, created_at_ms, message_id);
|
|
55
|
+
`;
|
|
56
|
+
const EVENTS_RECEIPTS_MIGRATION_SQL = `
|
|
57
|
+
CREATE TABLE IF NOT EXISTS project_event (
|
|
58
|
+
event_id TEXT PRIMARY KEY,
|
|
59
|
+
project_id TEXT NOT NULL,
|
|
60
|
+
branch_id TEXT NOT NULL,
|
|
61
|
+
event_type TEXT NOT NULL,
|
|
62
|
+
schema_version INTEGER NOT NULL CHECK(schema_version > 0),
|
|
63
|
+
idempotency_key TEXT NOT NULL,
|
|
64
|
+
payload_json TEXT NOT NULL,
|
|
65
|
+
payload_hash TEXT NOT NULL,
|
|
66
|
+
actor TEXT NOT NULL,
|
|
67
|
+
status TEXT NOT NULL CHECK(status IN ('received', 'validated', 'applied', 'rejected', 'dead_letter')),
|
|
68
|
+
occurred_at_ms INTEGER NOT NULL,
|
|
69
|
+
created_at_ms INTEGER NOT NULL,
|
|
70
|
+
updated_at_ms INTEGER NOT NULL,
|
|
71
|
+
last_error TEXT
|
|
72
|
+
) STRICT;
|
|
73
|
+
|
|
74
|
+
CREATE UNIQUE INDEX IF NOT EXISTS project_event_idempotency_unique
|
|
75
|
+
ON project_event(project_id, branch_id, idempotency_key);
|
|
76
|
+
|
|
77
|
+
CREATE INDEX IF NOT EXISTS project_event_branch_status_idx
|
|
78
|
+
ON project_event(project_id, branch_id, status, occurred_at_ms, event_id);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS project_receipt (
|
|
81
|
+
receipt_id TEXT PRIMARY KEY,
|
|
82
|
+
project_id TEXT NOT NULL,
|
|
83
|
+
branch_id TEXT NOT NULL,
|
|
84
|
+
kind TEXT NOT NULL,
|
|
85
|
+
status TEXT NOT NULL CHECK(status IN ('applied', 'rejected', 'duplicate', 'conflict', 'replay', 'dead_letter')),
|
|
86
|
+
event_id TEXT,
|
|
87
|
+
idempotency_key TEXT,
|
|
88
|
+
payload_hash TEXT,
|
|
89
|
+
base_snapshot_hash TEXT,
|
|
90
|
+
result_snapshot_hash TEXT,
|
|
91
|
+
reducer_name TEXT,
|
|
92
|
+
reducer_version TEXT,
|
|
93
|
+
lease_id TEXT,
|
|
94
|
+
actor TEXT,
|
|
95
|
+
artifact_path TEXT NOT NULL,
|
|
96
|
+
artifact_hash TEXT NOT NULL,
|
|
97
|
+
details_json TEXT NOT NULL,
|
|
98
|
+
created_at_ms INTEGER NOT NULL
|
|
99
|
+
) STRICT;
|
|
100
|
+
|
|
101
|
+
CREATE INDEX IF NOT EXISTS project_receipt_branch_status_idx
|
|
102
|
+
ON project_receipt(project_id, branch_id, status, created_at_ms, receipt_id);
|
|
103
|
+
|
|
104
|
+
CREATE INDEX IF NOT EXISTS project_receipt_event_idx
|
|
105
|
+
ON project_receipt(event_id, created_at_ms, receipt_id)
|
|
106
|
+
WHERE event_id IS NOT NULL;
|
|
107
|
+
`;
|
|
108
|
+
const WRITER_LEASES_MIGRATION_SQL = `
|
|
109
|
+
CREATE TABLE IF NOT EXISTS project_branch_state (
|
|
110
|
+
project_id TEXT NOT NULL,
|
|
111
|
+
branch_id TEXT NOT NULL,
|
|
112
|
+
current_snapshot_hash TEXT NOT NULL,
|
|
113
|
+
updated_at_ms INTEGER NOT NULL,
|
|
114
|
+
PRIMARY KEY (project_id, branch_id)
|
|
115
|
+
) STRICT;
|
|
116
|
+
|
|
117
|
+
CREATE TABLE IF NOT EXISTS project_writer_lease (
|
|
118
|
+
project_id TEXT NOT NULL,
|
|
119
|
+
branch_id TEXT NOT NULL,
|
|
120
|
+
lease_id TEXT NOT NULL,
|
|
121
|
+
lease_owner TEXT NOT NULL,
|
|
122
|
+
base_snapshot_hash TEXT NOT NULL,
|
|
123
|
+
status TEXT NOT NULL CHECK(status IN ('active', 'committed', 'released', 'expired', 'conflict')),
|
|
124
|
+
lease_deadline_ms INTEGER NOT NULL,
|
|
125
|
+
result_snapshot_hash TEXT,
|
|
126
|
+
receipt_id TEXT,
|
|
127
|
+
created_at_ms INTEGER NOT NULL,
|
|
128
|
+
updated_at_ms INTEGER NOT NULL,
|
|
129
|
+
last_error TEXT,
|
|
130
|
+
PRIMARY KEY (project_id, branch_id, lease_id)
|
|
131
|
+
) STRICT;
|
|
132
|
+
|
|
133
|
+
CREATE UNIQUE INDEX IF NOT EXISTS project_writer_lease_active_unique
|
|
134
|
+
ON project_writer_lease(project_id, branch_id)
|
|
135
|
+
WHERE status = 'active';
|
|
136
|
+
|
|
137
|
+
CREATE INDEX IF NOT EXISTS project_writer_lease_status_idx
|
|
138
|
+
ON project_writer_lease(project_id, branch_id, status, lease_deadline_ms, lease_id);
|
|
139
|
+
`;
|
|
22
140
|
const BUILTIN_MIGRATIONS = [
|
|
23
141
|
{
|
|
24
142
|
ordinal: 1,
|
|
@@ -26,6 +144,24 @@ const BUILTIN_MIGRATIONS = [
|
|
|
26
144
|
filename: "001_mdkg_project_db_foundation.sql",
|
|
27
145
|
sql: FOUNDATION_MIGRATION_SQL.trim(),
|
|
28
146
|
},
|
|
147
|
+
{
|
|
148
|
+
ordinal: 2,
|
|
149
|
+
key: "mdkg.project_db.queue.v1",
|
|
150
|
+
filename: "002_mdkg_project_db_queue.sql",
|
|
151
|
+
sql: QUEUE_MIGRATION_SQL.trim(),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
ordinal: 3,
|
|
155
|
+
key: "mdkg.project_db.events_receipts.v1",
|
|
156
|
+
filename: "003_mdkg_project_db_events_receipts.sql",
|
|
157
|
+
sql: EVENTS_RECEIPTS_MIGRATION_SQL.trim(),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
ordinal: 4,
|
|
161
|
+
key: "mdkg.project_db.writer_leases.v1",
|
|
162
|
+
filename: "004_mdkg_project_db_writer_leases.sql",
|
|
163
|
+
sql: WRITER_LEASES_MIGRATION_SQL.trim(),
|
|
164
|
+
},
|
|
29
165
|
];
|
|
30
166
|
function loadDatabaseCtor() {
|
|
31
167
|
try {
|
|
@@ -167,7 +303,7 @@ function checkMigrationFiles(root, config) {
|
|
|
167
303
|
ok: errors.length === 0,
|
|
168
304
|
level: errors.length === 0 ? "ok" : "fail",
|
|
169
305
|
path: rel(root, layout.migrations),
|
|
170
|
-
detail: errors.length === 0 ? "migration files match mdkg-owned
|
|
306
|
+
detail: errors.length === 0 ? "migration files match mdkg-owned built-in migrations" : "migration file issues found",
|
|
171
307
|
errors,
|
|
172
308
|
warnings: [],
|
|
173
309
|
};
|
|
@@ -0,0 +1,317 @@
|
|
|
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.enqueueProjectQueueMessage = enqueueProjectQueueMessage;
|
|
7
|
+
exports.claimProjectQueueMessage = claimProjectQueueMessage;
|
|
8
|
+
exports.ackProjectQueueMessage = ackProjectQueueMessage;
|
|
9
|
+
exports.failProjectQueueMessage = failProjectQueueMessage;
|
|
10
|
+
exports.deadLetterProjectQueueMessage = deadLetterProjectQueueMessage;
|
|
11
|
+
exports.releaseExpiredProjectQueueLeases = releaseExpiredProjectQueueLeases;
|
|
12
|
+
exports.readProjectQueueStats = readProjectQueueStats;
|
|
13
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
14
|
+
function loadDatabaseCtor() {
|
|
15
|
+
try {
|
|
16
|
+
const loaded = require("node:sqlite");
|
|
17
|
+
if (!loaded.DatabaseSync) {
|
|
18
|
+
throw new Error("node:sqlite DatabaseSync is unavailable");
|
|
19
|
+
}
|
|
20
|
+
return loaded.DatabaseSync;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24
|
+
throw new Error(`node:sqlite is required for mdkg project DB queues: ${message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function nowMs(input) {
|
|
28
|
+
if (input !== undefined) {
|
|
29
|
+
assertInteger(input, "now_ms");
|
|
30
|
+
return input;
|
|
31
|
+
}
|
|
32
|
+
return Date.now();
|
|
33
|
+
}
|
|
34
|
+
function assertNonEmpty(value, field) {
|
|
35
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
36
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function assertInteger(value, field) {
|
|
40
|
+
if (!Number.isInteger(value)) {
|
|
41
|
+
throw new Error(`${field} must be an integer`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function assertPositiveInteger(value, field) {
|
|
45
|
+
assertInteger(value, field);
|
|
46
|
+
if (value <= 0) {
|
|
47
|
+
throw new Error(`${field} must be greater than 0`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function stableJson(value) {
|
|
51
|
+
if (value === undefined || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
|
|
52
|
+
throw new Error("payload must be JSON-serializable");
|
|
53
|
+
}
|
|
54
|
+
if (value === null || typeof value !== "object") {
|
|
55
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
56
|
+
throw new Error("payload must be JSON-serializable");
|
|
57
|
+
}
|
|
58
|
+
const encoded = JSON.stringify(value);
|
|
59
|
+
if (encoded === undefined) {
|
|
60
|
+
throw new Error("payload must be JSON-serializable");
|
|
61
|
+
}
|
|
62
|
+
return encoded;
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
66
|
+
}
|
|
67
|
+
const object = value;
|
|
68
|
+
return `{${Object.keys(object)
|
|
69
|
+
.sort()
|
|
70
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`)
|
|
71
|
+
.join(",")}}`;
|
|
72
|
+
}
|
|
73
|
+
function payloadHash(payloadJson) {
|
|
74
|
+
return `sha256:${crypto_1.default.createHash("sha256").update(payloadJson).digest("hex")}`;
|
|
75
|
+
}
|
|
76
|
+
function toMessage(row) {
|
|
77
|
+
return {
|
|
78
|
+
queue_name: String(row.queue_name),
|
|
79
|
+
message_id: String(row.message_id),
|
|
80
|
+
dedupe_key: row.dedupe_key === null || row.dedupe_key === undefined ? null : String(row.dedupe_key),
|
|
81
|
+
payload_json: String(row.payload_json),
|
|
82
|
+
payload_hash: String(row.payload_hash),
|
|
83
|
+
status: String(row.status),
|
|
84
|
+
available_at_ms: Number(row.available_at_ms),
|
|
85
|
+
attempt_count: Number(row.attempt_count),
|
|
86
|
+
max_attempts: Number(row.max_attempts),
|
|
87
|
+
lease_owner: row.lease_owner === null || row.lease_owner === undefined ? null : String(row.lease_owner),
|
|
88
|
+
lease_deadline_ms: row.lease_deadline_ms === null || row.lease_deadline_ms === undefined ? null : Number(row.lease_deadline_ms),
|
|
89
|
+
created_at_ms: Number(row.created_at_ms),
|
|
90
|
+
updated_at_ms: Number(row.updated_at_ms),
|
|
91
|
+
last_error: row.last_error === null || row.last_error === undefined ? null : String(row.last_error),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function getMessage(db, queueName, messageId) {
|
|
95
|
+
const row = db
|
|
96
|
+
.prepare("SELECT * FROM project_queue_message WHERE queue_name = ? AND message_id = ?")
|
|
97
|
+
.get(queueName, messageId);
|
|
98
|
+
return row ? toMessage(row) : null;
|
|
99
|
+
}
|
|
100
|
+
function withDb(databasePath, fn) {
|
|
101
|
+
const DatabaseSync = loadDatabaseCtor();
|
|
102
|
+
const db = new DatabaseSync(databasePath);
|
|
103
|
+
try {
|
|
104
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
105
|
+
return fn(db);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
db.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function withImmediateTransaction(db, fn) {
|
|
112
|
+
db.exec("BEGIN IMMEDIATE");
|
|
113
|
+
try {
|
|
114
|
+
const result = fn();
|
|
115
|
+
db.exec("COMMIT");
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
try {
|
|
120
|
+
db.exec("ROLLBACK");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore rollback failures when no transaction is active
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function enqueueProjectQueueMessage(databasePath, input) {
|
|
129
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
130
|
+
assertNonEmpty(input.message_id, "message_id");
|
|
131
|
+
if (input.dedupe_key !== undefined && input.dedupe_key !== null) {
|
|
132
|
+
assertNonEmpty(input.dedupe_key, "dedupe_key");
|
|
133
|
+
}
|
|
134
|
+
const currentNow = nowMs(input.now_ms);
|
|
135
|
+
const availableAt = input.available_at_ms ?? currentNow;
|
|
136
|
+
assertInteger(availableAt, "available_at_ms");
|
|
137
|
+
const maxAttempts = input.max_attempts ?? 3;
|
|
138
|
+
assertPositiveInteger(maxAttempts, "max_attempts");
|
|
139
|
+
const payloadJson = stableJson(input.payload);
|
|
140
|
+
const hash = payloadHash(payloadJson);
|
|
141
|
+
return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
|
|
142
|
+
if (input.dedupe_key) {
|
|
143
|
+
const duplicate = db
|
|
144
|
+
.prepare("SELECT * FROM project_queue_message WHERE queue_name = ? AND dedupe_key = ?")
|
|
145
|
+
.get(input.queue_name, input.dedupe_key);
|
|
146
|
+
if (duplicate) {
|
|
147
|
+
return { created: false, duplicate: true, message: toMessage(duplicate) };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
db
|
|
151
|
+
.prepare([
|
|
152
|
+
"INSERT INTO project_queue_message",
|
|
153
|
+
"(queue_name, message_id, dedupe_key, payload_json, payload_hash, status, available_at_ms, attempt_count, max_attempts, lease_owner, lease_deadline_ms, created_at_ms, updated_at_ms, last_error)",
|
|
154
|
+
"VALUES (?, ?, ?, ?, ?, 'ready', ?, 0, ?, NULL, NULL, ?, ?, NULL)",
|
|
155
|
+
].join(" "))
|
|
156
|
+
.run(input.queue_name, input.message_id, input.dedupe_key ?? null, payloadJson, hash, availableAt, maxAttempts, currentNow, currentNow);
|
|
157
|
+
const message = getMessage(db, input.queue_name, input.message_id);
|
|
158
|
+
if (!message) {
|
|
159
|
+
throw new Error("queued message could not be reloaded after insert");
|
|
160
|
+
}
|
|
161
|
+
return { created: true, duplicate: false, message };
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
function claimProjectQueueMessage(databasePath, input) {
|
|
165
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
166
|
+
assertNonEmpty(input.lease_owner, "lease_owner");
|
|
167
|
+
assertPositiveInteger(input.lease_ms, "lease_ms");
|
|
168
|
+
const currentNow = nowMs(input.now_ms);
|
|
169
|
+
return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
|
|
170
|
+
const row = db
|
|
171
|
+
.prepare([
|
|
172
|
+
"SELECT * FROM project_queue_message",
|
|
173
|
+
"WHERE queue_name = ?",
|
|
174
|
+
"AND (",
|
|
175
|
+
" (status = 'ready' AND available_at_ms <= ?)",
|
|
176
|
+
" OR (status = 'leased' AND lease_deadline_ms <= ?)",
|
|
177
|
+
")",
|
|
178
|
+
"ORDER BY available_at_ms ASC, created_at_ms ASC, message_id ASC",
|
|
179
|
+
"LIMIT 1",
|
|
180
|
+
].join(" "))
|
|
181
|
+
.get(input.queue_name, currentNow, currentNow);
|
|
182
|
+
if (!row) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const message = toMessage(row);
|
|
186
|
+
db
|
|
187
|
+
.prepare("UPDATE project_queue_message SET status = 'leased', lease_owner = ?, lease_deadline_ms = ?, updated_at_ms = ? WHERE queue_name = ? AND message_id = ?")
|
|
188
|
+
.run(input.lease_owner, currentNow + input.lease_ms, currentNow, input.queue_name, message.message_id);
|
|
189
|
+
return getMessage(db, input.queue_name, message.message_id);
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
function requireLeasedMessage(db, queueName, messageId, leaseOwner) {
|
|
193
|
+
const message = getMessage(db, queueName, messageId);
|
|
194
|
+
if (!message) {
|
|
195
|
+
throw new Error(`queue message not found: ${queueName}/${messageId}`);
|
|
196
|
+
}
|
|
197
|
+
if (message.status !== "leased" || message.lease_owner !== leaseOwner) {
|
|
198
|
+
throw new Error(`queue message ${messageId} is not leased by ${leaseOwner}`);
|
|
199
|
+
}
|
|
200
|
+
return message;
|
|
201
|
+
}
|
|
202
|
+
function ackProjectQueueMessage(databasePath, input) {
|
|
203
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
204
|
+
assertNonEmpty(input.message_id, "message_id");
|
|
205
|
+
assertNonEmpty(input.lease_owner, "lease_owner");
|
|
206
|
+
const currentNow = nowMs(input.now_ms);
|
|
207
|
+
return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
|
|
208
|
+
requireLeasedMessage(db, input.queue_name, input.message_id, input.lease_owner);
|
|
209
|
+
db
|
|
210
|
+
.prepare("UPDATE project_queue_message SET status = 'acked', lease_owner = NULL, lease_deadline_ms = NULL, updated_at_ms = ? WHERE queue_name = ? AND message_id = ?")
|
|
211
|
+
.run(currentNow, input.queue_name, input.message_id);
|
|
212
|
+
const message = getMessage(db, input.queue_name, input.message_id);
|
|
213
|
+
if (!message) {
|
|
214
|
+
throw new Error("acked message could not be reloaded");
|
|
215
|
+
}
|
|
216
|
+
return message;
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
function failProjectQueueMessage(databasePath, input) {
|
|
220
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
221
|
+
assertNonEmpty(input.message_id, "message_id");
|
|
222
|
+
assertNonEmpty(input.lease_owner, "lease_owner");
|
|
223
|
+
assertNonEmpty(input.error, "error");
|
|
224
|
+
const currentNow = nowMs(input.now_ms);
|
|
225
|
+
const retryAfter = input.retry_after_ms ?? 0;
|
|
226
|
+
assertInteger(retryAfter, "retry_after_ms");
|
|
227
|
+
if (retryAfter < 0) {
|
|
228
|
+
throw new Error("retry_after_ms must be greater than or equal to 0");
|
|
229
|
+
}
|
|
230
|
+
return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
|
|
231
|
+
const message = requireLeasedMessage(db, input.queue_name, input.message_id, input.lease_owner);
|
|
232
|
+
const attemptCount = message.attempt_count + 1;
|
|
233
|
+
const status = attemptCount >= message.max_attempts ? "dead_letter" : "ready";
|
|
234
|
+
const availableAt = status === "ready" ? currentNow + retryAfter : currentNow;
|
|
235
|
+
db
|
|
236
|
+
.prepare([
|
|
237
|
+
"UPDATE project_queue_message",
|
|
238
|
+
"SET status = ?, attempt_count = ?, available_at_ms = ?, lease_owner = NULL, lease_deadline_ms = NULL, updated_at_ms = ?, last_error = ?",
|
|
239
|
+
"WHERE queue_name = ? AND message_id = ?",
|
|
240
|
+
].join(" "))
|
|
241
|
+
.run(status, attemptCount, availableAt, currentNow, input.error, input.queue_name, input.message_id);
|
|
242
|
+
const updated = getMessage(db, input.queue_name, input.message_id);
|
|
243
|
+
if (!updated) {
|
|
244
|
+
throw new Error("failed message could not be reloaded");
|
|
245
|
+
}
|
|
246
|
+
return updated;
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
function deadLetterProjectQueueMessage(databasePath, input) {
|
|
250
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
251
|
+
assertNonEmpty(input.message_id, "message_id");
|
|
252
|
+
assertNonEmpty(input.lease_owner, "lease_owner");
|
|
253
|
+
assertNonEmpty(input.error, "error");
|
|
254
|
+
const currentNow = nowMs(input.now_ms);
|
|
255
|
+
return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
|
|
256
|
+
requireLeasedMessage(db, input.queue_name, input.message_id, input.lease_owner);
|
|
257
|
+
db
|
|
258
|
+
.prepare("UPDATE project_queue_message SET status = 'dead_letter', lease_owner = NULL, lease_deadline_ms = NULL, updated_at_ms = ?, last_error = ? WHERE queue_name = ? AND message_id = ?")
|
|
259
|
+
.run(currentNow, input.error, input.queue_name, input.message_id);
|
|
260
|
+
const message = getMessage(db, input.queue_name, input.message_id);
|
|
261
|
+
if (!message) {
|
|
262
|
+
throw new Error("dead-lettered message could not be reloaded");
|
|
263
|
+
}
|
|
264
|
+
return message;
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
function releaseExpiredProjectQueueLeases(databasePath, input = {}) {
|
|
268
|
+
const currentNow = nowMs(input.now_ms);
|
|
269
|
+
if (input.queue_name !== undefined) {
|
|
270
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
271
|
+
}
|
|
272
|
+
return withDb(databasePath, (db) => withImmediateTransaction(db, () => {
|
|
273
|
+
const result = input.queue_name
|
|
274
|
+
? db
|
|
275
|
+
.prepare("UPDATE project_queue_message SET status = 'ready', lease_owner = NULL, lease_deadline_ms = NULL, updated_at_ms = ? WHERE queue_name = ? AND status = 'leased' AND lease_deadline_ms <= ?")
|
|
276
|
+
.run(currentNow, input.queue_name, currentNow)
|
|
277
|
+
: db
|
|
278
|
+
.prepare("UPDATE project_queue_message SET status = 'ready', lease_owner = NULL, lease_deadline_ms = NULL, updated_at_ms = ? WHERE status = 'leased' AND lease_deadline_ms <= ?")
|
|
279
|
+
.run(currentNow, currentNow);
|
|
280
|
+
return { released_count: Number(result.changes ?? 0) };
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
function readProjectQueueStats(databasePath, input = {}) {
|
|
284
|
+
if (input.queue_name !== undefined) {
|
|
285
|
+
assertNonEmpty(input.queue_name, "queue_name");
|
|
286
|
+
}
|
|
287
|
+
const currentNow = nowMs(input.now_ms);
|
|
288
|
+
return withDb(databasePath, (db) => {
|
|
289
|
+
const params = input.queue_name ? [input.queue_name] : [];
|
|
290
|
+
const where = input.queue_name ? "WHERE queue_name = ?" : "";
|
|
291
|
+
const rows = db
|
|
292
|
+
.prepare(`SELECT status, COUNT(*) AS count FROM project_queue_message ${where} GROUP BY status`)
|
|
293
|
+
.all(...params);
|
|
294
|
+
const byStatus = {
|
|
295
|
+
ready: 0,
|
|
296
|
+
leased: 0,
|
|
297
|
+
acked: 0,
|
|
298
|
+
dead_letter: 0,
|
|
299
|
+
};
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
byStatus[String(row.status)] = Number(row.count);
|
|
302
|
+
}
|
|
303
|
+
const total = Object.values(byStatus).reduce((sum, count) => sum + count, 0);
|
|
304
|
+
const readyAvailable = db
|
|
305
|
+
.prepare(`SELECT COUNT(*) AS count FROM project_queue_message ${where} ${where ? "AND" : "WHERE"} status = 'ready' AND available_at_ms <= ?`)
|
|
306
|
+
.get(...params, currentNow);
|
|
307
|
+
const leasedExpired = db
|
|
308
|
+
.prepare(`SELECT COUNT(*) AS count FROM project_queue_message ${where} ${where ? "AND" : "WHERE"} status = 'leased' AND lease_deadline_ms <= ?`)
|
|
309
|
+
.get(...params, currentNow);
|
|
310
|
+
return {
|
|
311
|
+
total,
|
|
312
|
+
by_status: byStatus,
|
|
313
|
+
ready_available: Number(readyAvailable?.count ?? 0),
|
|
314
|
+
leased_expired: Number(leasedExpired?.count ?? 0),
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
}
|
package/dist/init/AGENT_START.md
CHANGED
|
@@ -34,9 +34,15 @@ Agent operating prompt:
|
|
|
34
34
|
- Treat `.mdkg/db` as project application state; use `mdkg db init` to create
|
|
35
35
|
the generic scaffold and enable `db.enabled` without creating an active
|
|
36
36
|
runtime SQLite database. Use `mdkg db migrate` after init to create or update
|
|
37
|
-
the runtime SQLite database with mdkg-owned
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
the runtime SQLite database with mdkg-owned foundation plus internal local
|
|
38
|
+
node:sqlite queue, event/receipt/reducer, and writer lease/CAS foundation
|
|
39
|
+
migrations. Queue state is delivery infrastructure, not canonical event
|
|
40
|
+
history. Event rows are durable local project DB history; receipts, reducers,
|
|
41
|
+
and writer leases are internal local helper surfaces, and there is no public
|
|
42
|
+
`mdkg db queue`, `mdkg db event`, `mdkg db reducer`, or `mdkg db lease` CLI
|
|
43
|
+
yet. Use `mdkg db verify` and `mdkg db stats` for
|
|
44
|
+
non-mutating health and summary receipts. Use `mdkg db snapshot seal` for
|
|
45
|
+
explicit sealed checkpoints,
|
|
40
46
|
`mdkg db snapshot verify/status` for checkpoint health, and
|
|
41
47
|
`mdkg db snapshot dump/diff` for deterministic review aids. Keep
|
|
42
48
|
`.mdkg/db/runtime/` and WAL/SHM/journal/lock/temp files ignored unless a
|
|
@@ -49,9 +49,16 @@ Project database commands:
|
|
|
49
49
|
`.mdkg/db/project-db.json`, enables `db.enabled`, and does not create an
|
|
50
50
|
active runtime SQLite database
|
|
51
51
|
- `mdkg db migrate` creates or updates the configured active runtime SQLite
|
|
52
|
-
database and applies mdkg-owned
|
|
52
|
+
database and applies mdkg-owned foundation plus internal local node:sqlite
|
|
53
|
+
queue, event/receipt/reducer, and writer lease/CAS foundation migrations
|
|
53
54
|
- `mdkg db migrate` records migration order, checksums, and applied timestamps
|
|
54
55
|
in the configured migration table
|
|
56
|
+
- queue tables are durable local delivery state, not canonical event history;
|
|
57
|
+
there is no public `mdkg db queue` CLI yet
|
|
58
|
+
- event tables are durable local history for project DB state transitions;
|
|
59
|
+
receipts, typed reducers, and writer leases remain internal helper surfaces in
|
|
60
|
+
this release, with no public `mdkg db event`, `mdkg db reducer`, or
|
|
61
|
+
`mdkg db lease` CLI yet
|
|
55
62
|
- `mdkg db verify` checks config, layout, runtime SQLite integrity, migration
|
|
56
63
|
metadata, receipt directory policy, and transient runtime files
|
|
57
64
|
- `mdkg db stats` reports table counts, database size, migration state,
|
package/dist/init/README.md
CHANGED
|
@@ -75,8 +75,13 @@ Fresh mdkg workspaces default to `index.backend: sqlite`; `.mdkg/index/mdkg.sqli
|
|
|
75
75
|
`.mdkg/index`. Run `mdkg db init` to create the generic scaffold, write
|
|
76
76
|
`.mdkg/db/project-db.json`, and enable `db.enabled`; it does not create an
|
|
77
77
|
active runtime SQLite database. Run `mdkg db migrate` after init to create or
|
|
78
|
-
update the active runtime SQLite database with mdkg-owned
|
|
79
|
-
|
|
78
|
+
update the active runtime SQLite database with mdkg-owned foundation plus
|
|
79
|
+
internal local node:sqlite queue, event/receipt/reducer, and writer lease/CAS
|
|
80
|
+
foundation migrations. Queue state is delivery infrastructure, not canonical
|
|
81
|
+
event history. Event rows are durable local project DB history; receipts,
|
|
82
|
+
reducers, and writer leases are internal local helper surfaces, and there is no
|
|
83
|
+
public `mdkg db queue`, `mdkg db event`, `mdkg db reducer`, or `mdkg db lease`
|
|
84
|
+
CLI yet. Use `mdkg db verify` for non-mutating health checks and
|
|
80
85
|
`mdkg db stats` for table counts, DB size, migration state, and receipt-file
|
|
81
86
|
counts. Use `mdkg db snapshot seal` to create an opt-in sealed checkpoint under
|
|
82
87
|
`.mdkg/db/state`, then use `mdkg db snapshot verify/status` for integrity and
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"tool": "mdkg",
|
|
4
|
-
"mdkg_version": "0.1.
|
|
4
|
+
"mdkg_version": "0.1.9",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": ".mdkg/config.json",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
{
|
|
62
62
|
"path": ".mdkg/README.md",
|
|
63
63
|
"category": "mdkg_doc",
|
|
64
|
-
"sha256": "
|
|
64
|
+
"sha256": "b692c05fcfa1eaaa90edcd3f7a39042615f90e8915c7d447801c93284ed7463e"
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": ".mdkg/skills/build-pack-and-execute-task/SKILL.md",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
{
|
|
187
187
|
"path": "AGENT_START.md",
|
|
188
188
|
"category": "startup_doc",
|
|
189
|
-
"sha256": "
|
|
189
|
+
"sha256": "1e1d9d94e2aa761b1a7978ab4b44e8bb7c3f0d33e94ed2d98cb06d641f4c42c6"
|
|
190
190
|
},
|
|
191
191
|
{
|
|
192
192
|
"path": "AGENTS.md",
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
{
|
|
202
202
|
"path": "CLI_COMMAND_MATRIX.md",
|
|
203
203
|
"category": "startup_doc",
|
|
204
|
-
"sha256": "
|
|
204
|
+
"sha256": "9f90882d698d0ad1b16bad0fbe37065e4ff9f2b79b27373ef7dce74b78e99265"
|
|
205
205
|
},
|
|
206
206
|
{
|
|
207
207
|
"path": "llms.txt",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdkg",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Markdown Knowledge Graph",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"smoke:init": "npm run build && node scripts/smoke-init.js",
|
|
18
18
|
"smoke:capabilities": "npm run build && node scripts/smoke-capabilities.js",
|
|
19
19
|
"smoke:db": "npm run build && node scripts/smoke-db.js",
|
|
20
|
+
"smoke:db-queue": "npm run build && node scripts/smoke-db-queue.js",
|
|
21
|
+
"smoke:db-events": "npm run build && node scripts/smoke-db-events.js",
|
|
20
22
|
"smoke:db-snapshot": "npm run build && node scripts/smoke-db-snapshot.js",
|
|
21
23
|
"smoke:archive-work": "npm run build && node scripts/smoke-archive-work.js",
|
|
22
24
|
"smoke:bundle": "npm run build && node scripts/smoke-bundle.js",
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
"cli:snapshot": "npm run build && node scripts/cli_help_snapshot.js",
|
|
29
31
|
"cli:check": "npm run build && node scripts/cli_help_snapshot.js --check",
|
|
30
32
|
"prepack": "npm run build && node scripts/assert-publish-ready.js",
|
|
31
|
-
"prepublishOnly": "npm run test && npm run cli:check && node dist/cli.js validate && npm run smoke:consumer && npm run smoke:matrix && npm run smoke:upgrade && npm run smoke:init && npm run smoke:capabilities && npm run smoke:db && npm run smoke:db-snapshot && npm run smoke:archive-work && npm run smoke:bundle && npm run smoke:subgraph && npm run smoke:visibility && npm run smoke:sqlite && npm run smoke:parallel && npm run smoke:goal && node scripts/assert-publish-ready.js",
|
|
33
|
+
"prepublishOnly": "npm run test && npm run cli:check && node dist/cli.js validate && npm run smoke:consumer && npm run smoke:matrix && npm run smoke:upgrade && npm run smoke:init && npm run smoke:capabilities && npm run smoke:db && npm run smoke:db-queue && npm run smoke:db-events && npm run smoke:db-snapshot && npm run smoke:archive-work && npm run smoke:bundle && npm run smoke:subgraph && npm run smoke:visibility && npm run smoke:sqlite && npm run smoke:parallel && npm run smoke:goal && node scripts/assert-publish-ready.js",
|
|
32
34
|
"postinstall": "node scripts/postinstall.js",
|
|
33
35
|
"smoke:subgraph": "npm run build && node scripts/smoke-subgraph.js"
|
|
34
36
|
},
|