feat(sync): tombstone, pull dall'export, ritocchi flush e fallback su 404

Prepara il client al gateway 2.12.0 (export + soft delete), mantenendo la
compatibilità con la 2.11.0 in produzione fino al redeploy.

- tombstone: colonna `deleted_at` (+ migrazione), monotona nel merge, esclusa
  dalle ricerche locali di default; `--deleted` in CLI e /qmem:local find;
  conteggio nel report/status; marker 🗑 nei risultati di qmem_get
- `pull` usa `include_deleted=true` e mappa l'intero payload dell'export
  (incluso deleted_at), così il mirror impara le cancellazioni
- rate limit: pace del flush 600 ms (100 req/min < 120/min del gateway) e
  messaggio dedicato su 429 (prima 300-400 ms → possibile 429 con code grandi)
- `qmem_get`: fallback sull'indice locale anche sul 404 (un id in coda non è
  ancora sul gateway) con etichetta "non presente sul gateway"
- test: 27 controlli (nuovi: pull con tombstone, esclusione/visibilità
  tombstone, ricerca del record esportato)

Verificato con la suite locale completa: 27/27.
This commit is contained in:
Matteo Benedetto
2026-09-13 17:53:59 +02:00
parent d509778448
commit d1985514e1
6 changed files with 92 additions and 24 deletions
+3 -1
View File
@@ -64,6 +64,7 @@ if (cmd === "status") {
if (r.queueLastError) console.log(` ultimo errore : ${r.queueLastError.slice(0, 120)}`);
}
if (r.pendingInIndex) console.log(` in indice : ${r.pendingInIndex} record marcati ⏳ (creati offline, non ancora sul gateway)`);
if (r.deleted) console.log(` tombstone : ${r.deleted} record cancellati sul gateway (soft delete)`);
console.log(` top progetti : ${r.topProjects.map((p) => `${p.project_id ?? "(null)"}=${p.n}`).join(", ") || "-"}`);
if (r.duplicates.length) {
console.log(` possibili duplicati (testo identico): ${r.duplicates.length} gruppi — es. ${r.duplicates[0].ids.map((i) => i.slice(0, 8)).join(", ")}`);
@@ -86,7 +87,7 @@ if (cmd === "status") {
} else if (cmd === "find") {
const query = argv[1] && !argv[1].startsWith("--") ? argv[1] : "";
if (!query) {
console.error('Uso: find "query" [--kind K] [--project P] [--top N] [--all] [--exact] [--json]');
console.error('Uso: find "query" [--kind K] [--project P] [--top N] [--all] [--deleted] [--exact] [--json]');
process.exit(2);
}
const hits = await localSearch(
@@ -99,6 +100,7 @@ if (cmd === "status") {
topic: typeof opt("topic", null) === "string" ? opt("topic", null) : undefined,
include_superseded: has("all"),
include_private: has("private"),
include_deleted: has("deleted"),
exact: has("exact"),
top_k: Number(opt("top", 5)) || 5,
},
+19 -2
View File
@@ -33,6 +33,8 @@ const ID_B = "22222222-2222-4222-8222-222222222222"; // store poi superseduto
const ID_C = "33333333-3333-4333-8333-333333333333"; // correzione (nuovo)
const ID_D = "44444444-4444-4444-8444-444444444444"; // visto in qmem_search (senza project_id)
const ID_E = "55555555-5555-4555-8555-555555555555"; // visto in qmem_get
const EXPORT_ID = "66666666-6666-4666-8666-666666666666"; // dal pull export
const TOMBSTONE_ID = "77777777-7777-4777-8777-777777777777"; // tombstone dal pull export
fs.mkdirSync(SESS_DIR, { recursive: true });
fs.mkdirSync(path.dirname(CONFIG), { recursive: true });
@@ -181,7 +183,16 @@ const server = createServer((req, res) => {
return;
}
if (req.url?.startsWith("/v1/memories:export")) {
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ detail: "non trovato" }));
res.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
results: [
{ memory_id: EXPORT_ID, text: "Record arrivato dal pull export del gateway.", kind: "fact", project_id: "export-proj", scope: "agent", created_at: "2026-09-10T10:00:00Z", updated_at: "2026-09-10T10:00:00Z", updated_ts: 1 },
{ memory_id: TOMBSTONE_ID, text: "Record cancellato sul gateway con soft delete.", kind: "fact", project_id: "export-proj", created_at: "2026-09-09T10:00:00Z", deleted_at: "2026-09-11T10:00:00Z", deleted_ts: 2 },
],
count: 2,
next_cursor: null,
}),
);
return;
}
if (req.url === `/v1/memories/${ID_D}`) {
@@ -210,7 +221,13 @@ check("project_id/private arricchiti nel DB", afterJson.withProject > 0, `con pr
const pullOut = await runCliAsync(["pull", "--json"]);
const pull = { stdout: pullOut };
const pullJson = JSON.parse(pull.stdout || "{}");
check("pull segnala endpoint export assente", pullJson.supported === false, pullJson.message ?? "");
check("pull export: 2 record (1 attivo + 1 tombstone)", pullJson.supported === true && pullJson.fetched === 2, `pagine=${pullJson.pages} record=${pullJson.fetched}`);
const findExported = JSON.parse(runCli(["find", "pull export gateway", "--json"]).stdout || "[]");
check("record esportato ricercabile nel mirror", findExported.some((h) => h.memory_id === EXPORT_ID), `${findExported.length} hit`);
const findTombstone = JSON.parse(runCli(["find", "soft delete", "--json"]).stdout);
check("tombstone escluso dalle ricerche locali di default", !findTombstone.some((h) => h.memory_id === TOMBSTONE_ID), `${findTombstone.length} hit`);
const findTombstoneAll = JSON.parse(runCli(["find", "soft delete", "--deleted", "--json"]).stdout || "{}");
check("tombstone visibile con --deleted", findTombstoneAll.some((h) => h.memory_id === TOMBSTONE_ID && h.deleted_at), `${findTombstoneAll.length} hit`);
// ---------------------------------------------------------------- 5) outbox: flush, 409, 422, supersede
const q1 = JSON.parse((await runCliAsync(["queue", "--json"])) || "{}");