feat(outbox): store offline con coda locale e sincronizzazione al ritorno della rete

Prima qmem_store falliva se il gateway non era raggiungibile: la conoscenza
andava persa. Ora il record entra in una coda locale persistente e viene
inviato automaticamente quando la connessione torna.

Core (extensions/local-db.ts):
- tabella `pending` (local_id, payload JSON, attempts, last_error, status,
  remote_id) + colonna `records.pending` (migrazione automatica dei DB esistenti)
- queueStore(): accoda e crea subito il placeholder locale ricercabile ()
- flushQueue(): POST /v1/memories con Idempotency-Key = local_id (retry senza
  duplicati), FIFO, pacing sotto il rate limit, timeout 12s per richiesta
- esiti: synced (il record locale adotta l'ID remoto, niente duplicati) ·
  duplicate (409: registra l'ID del match e NON sovrascrive il testo locale
  autorevole) · failed (4xx di validazione, non ritentato) · 0/429/5xx: resta in
  coda e il flush si ferma
- supersede offline: supersedes_id che punta a un local_id viene rimappato al
  remote_id al flush (se il genitore non è sincronizzato → failed esplicito)
- submitOrQueue(): online → gateway + indicizzazione locale; offline → coda
- maybeBackgroundFlush() (single-flight) e flushQueueIfPending() per session_start
- stato/report: queued/synced/duplicate/failed, più vecchio, ultimo errore,
  last_flush, record pendenti in indice

Estensione:
- qmem_store: gateway giù → accoda e risponde con id locale, dimensione coda e
  spiegazione (details.queued/local_id/queue_size)
- fallback offline di session_start: flush in background (non blocca l'avvio)
- /qmem:local queue|flush; status con la coda; marker " in coda" nei risultati
  locali di qmem_search/qmem_get
- regole e skill: un record in coda NON è ancora nella memoria condivisa

CLI: store [--queue-only], queue, flush (+ status con la coda).
Test: scripts/test-local.mjs ora copre anche outbox → 24 controlli (flush con
2 sync + 1 duplicato 409 + 1 fallito 422, Idempotency-Key, rimappatura del
supersede, ricerca del record con l'ID remoto dopo il sync).

Verifiche: 24/24 test superati; demo reale su DB temporaneo: store accodato,
queue con local_id, flush con gateway giù → "fermato: HTTP 0" e voce che resta
in coda con l'errore registrato.
This commit is contained in:
Matteo Benedetto
2026-09-13 17:29:09 +02:00
parent 1832562a7f
commit 322b4cf446
12 changed files with 705 additions and 67 deletions
+440 -12
View File
@@ -24,7 +24,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { gatewayRequest, loadConfig, type MemoryConfig } from "./shared.ts";
// ---------------------------------------------------------------------------
@@ -90,7 +90,9 @@ CREATE TABLE IF NOT EXISTS records(
text TEXT, kind TEXT, project_id TEXT, scope TEXT, agent_id TEXT, confidence TEXT,
importance REAL, created_at TEXT, supersedes_id TEXT, superseded_by TEXT,
parent_id TEXT, topic TEXT, level TEXT, private INTEGER NOT NULL DEFAULT 0,
text_hash TEXT, sources TEXT, synced_at TEXT, updated_at TEXT
text_hash TEXT, sources TEXT, synced_at TEXT, updated_at TEXT,
/** 1 = record creato offline, non ancora presente sul gateway */
pending INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS records_project ON records(project_id);
CREATE INDEX IF NOT EXISTS records_kind ON records(kind);
@@ -110,7 +112,26 @@ CREATE TRIGGER IF NOT EXISTS records_ad AFTER DELETE ON records BEGIN
INSERT INTO records_fts(records_fts, rowid, text, kind, project_id) VALUES('delete', old.rowid_, old.text, old.kind, old.project_id);
END;
CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT);
CREATE TABLE IF NOT EXISTS pending(
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_id TEXT UNIQUE NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt TEXT,
last_error TEXT,
status TEXT NOT NULL DEFAULT 'queued', -- queued | synced | duplicate | failed
remote_id TEXT,
synced_at TEXT
);
CREATE INDEX IF NOT EXISTS pending_status ON pending(status);
`);
// migrazione per DB creati prima dell'introduzione della coda
const cols = db
.prepare("PRAGMA table_info(records)")
.all()
.map((c: any) => c.name);
if (!cols.includes("pending")) db.exec("ALTER TABLE records ADD COLUMN pending INTEGER NOT NULL DEFAULT 0");
return db;
}
@@ -133,12 +154,14 @@ export interface LocalRecordInput {
topic?: string | null;
level?: string | null;
private?: boolean | null;
/** Fonte dell'osservazione: store | correct | get | search | export | enrich */
/** true = creato offline (in coda), false = presente sul gateway */
pending?: boolean | null;
/** Fonte dell'osservazione: store | correct | get | search | export | enrich | outbox | sync */
source: string;
}
/** Sorgenti ordinate per affidabilità del testo (maggiore = più completo). */
const TEXT_PRIORITY: Record<string, number> = { export: 5, enrich: 5, get: 4, store: 3, correct: 3, search: 2 };
const TEXT_PRIORITY: Record<string, number> = { export: 5, enrich: 5, sync: 5, get: 4, store: 3, correct: 3, outbox: 3, search: 2 };
function normalizeText(t: string): string {
return t.replace(/\s+/g, " ").trim().toLowerCase();
@@ -155,11 +178,11 @@ export function upsertRecords(db: Db, records: LocalRecordInput[]): number {
const find = db.prepare("SELECT * FROM records WHERE memory_id = ?");
const insert = db.prepare(`INSERT INTO records
(memory_id, text, kind, project_id, scope, agent_id, confidence, importance, created_at,
supersedes_id, superseded_by, parent_id, topic, level, private, text_hash, sources, synced_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
supersedes_id, superseded_by, parent_id, topic, level, private, text_hash, sources, synced_at, updated_at, pending)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
const update = db.prepare(`UPDATE records SET
text=?, kind=?, project_id=?, scope=?, agent_id=?, confidence=?, importance=?, created_at=?,
supersedes_id=?, superseded_by=?, parent_id=?, topic=?, level=?, private=?, text_hash=?, sources=?, synced_at=?, updated_at=?
supersedes_id=?, superseded_by=?, parent_id=?, topic=?, level=?, private=?, text_hash=?, sources=?, synced_at=?, updated_at=?, pending=?
WHERE rowid_=?`);
const now = new Date().toISOString();
let written = 0;
@@ -193,17 +216,19 @@ export function upsertRecords(db: Db, records: LocalRecordInput[]): number {
[...sources].sort().join(","),
now,
now,
r.pending === true ? 1 : r.pending === false ? 0 : (existing?.pending ?? 0),
];
if (existing) {
// I campi già noti non vengono mai azzerati da osservazioni più povere
// (es. un risultato di ricerca che non riporta project_id).
const keys = ["text", "kind", "project_id", "scope", "agent_id", "confidence", "importance", "created_at",
"supersedes_id", "superseded_by", "parent_id", "topic", "level", "private", "text_hash", "sources", "synced_at", "updated_at"];
"supersedes_id", "superseded_by", "parent_id", "topic", "level", "private", "text_hash", "sources", "synced_at", "updated_at", "pending"];
const merged = vals.map((v, i) => {
const key = keys[i];
const cur = (existing as any)[key];
const curEmpty = cur === null || cur === undefined || cur === "" || cur === 0;
if (key === "private") return cur === 1 ? 1 : v; // monotono: una volta privato resta privato
if (key === "pending") return v; // 0 = sincronizzato: prevale sullo stato locale
if (key === "sources" || key === "synced_at" || key === "updated_at") return v;
if (v === null || v === "" ) return curEmpty ? v : cur; // non azzerare
return v;
@@ -422,6 +447,8 @@ export interface LocalSearchHit {
created_at: string | null;
superseded_by: string | null;
sources: string | null;
/** 1 = creato offline, non ancora sul gateway */
pending?: number;
snippet: string;
rank: number;
/** "and" = tutti i termini presenti; "or" = match parziale (AND senza risultati) */
@@ -466,7 +493,7 @@ async function runSearch(db: Db, params: LocalSearchParams, match: string): Prom
if (!params.include_private) where.push("r.private = 0");
args.push(Math.min(Math.max(params.top_k ?? 5, 1), 50));
const sql = `SELECT r.memory_id, r.text, r.kind, r.project_id, r.scope, r.agent_id, r.created_at,
r.superseded_by, r.sources, bm25(records_fts) AS rank,
r.superseded_by, r.sources, r.pending, bm25(records_fts) AS rank,
snippet(records_fts, 0, '«', '»', '…', 14) AS snippet
FROM records_fts JOIN records r ON r.rowid_ = records_fts.rowid
WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`;
@@ -495,17 +522,39 @@ export async function localSearch(params: LocalSearchParams, opts?: { dbFile?: s
}
/** Recupero locale di un singolo record (fallback di qmem_get). */
export async function localGet(memoryId: string, opts?: { dbFile?: string }): Promise<LocalSearchHit | null> {
export async function localGet(memoryId: string, opts?: { dbFile?: string }): Promise<(LocalSearchHit & { queue_status?: string; remote_id?: string | null }) | null> {
const db = await openLocalDb(opts?.dbFile);
try {
const row = db
.prepare(
`SELECT memory_id, text, kind, project_id, scope, agent_id, created_at, superseded_by, sources, 0 AS rank,
`SELECT memory_id, text, kind, project_id, scope, agent_id, created_at, superseded_by, sources, pending, 0 AS rank,
COALESCE(substr(text,1,200),'') AS snippet
FROM records WHERE memory_id = ?`,
)
.get(memoryId) as LocalSearchHit | undefined;
return row ?? null;
if (row) return row;
// non è nell'indice: potrebbe essere un id locale della coda
const q = db
.prepare("SELECT local_id, payload, status, remote_id FROM pending WHERE local_id = ?")
.get(memoryId) as any;
if (!q) return null;
const payload: QueuePayload = JSON.parse(q.payload);
return {
memory_id: q.local_id,
text: payload.text,
kind: payload.kind ?? "fact",
project_id: payload.project_id,
scope: payload.scope ?? "agent",
agent_id: payload.agent_id ?? null,
created_at: null,
superseded_by: null,
sources: "outbox",
pending: q.status === "queued" ? 1 : 0,
snippet: String(payload.text ?? "").slice(0, 200),
rank: 0,
queue_status: q.status,
remote_id: q.remote_id ?? null,
};
} finally {
db.close();
}
@@ -641,6 +690,364 @@ export async function pullFromGatewayExport(
}
}
// ---------------------------------------------------------------------------
// Coda offline (outbox): store quando il gateway non è raggiungibile
// ---------------------------------------------------------------------------
// Il record viene scritto subito nell'indice locale (marcato `pending`) e in
// `pending` come payload JSON. Al ritorno della connessione il flush lo invia a
// POST /v1/memories con `Idempotency-Key` = local_id (il gateway deduplica i
// retry), poi il record locale **adotta l'ID remoto** (niente duplicati).
// 409 (duplicato noto) → status `duplicate` con l'id del match; 4xx di
// validazione → `failed` (non ritentato); 0/429/5xx → resta `queued` e il flush
// si ferma.
export interface QueuePayload {
text: string;
kind?: string;
project_id: string;
scope?: string;
agent_id?: string;
source?: string;
confidence?: string;
expires_at?: string;
supersedes_id?: string;
supersede_reason?: string;
parent_id?: string;
level?: string;
topic?: string;
links?: unknown;
importance?: number;
private?: boolean;
}
export interface QueueItem {
local_id: string;
payload: QueuePayload;
created_at: string;
attempts: number;
last_attempt: string | null;
last_error: string | null;
status: "queued" | "synced" | "duplicate" | "failed";
remote_id: string | null;
}
export interface QueueStats {
queued: number;
synced: number;
duplicate: number;
failed: number;
oldestQueued: string | null;
lastError: string | null;
}
export interface FlushStats {
processed: number;
synced: number;
duplicates: number;
failed: number;
remaining: number;
stopped?: string;
remoteIds: Record<string, string>;
errors: string[];
}
const nowIso = () => new Date().toISOString();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function stripEmpty(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) if (v !== undefined && v !== null && v !== "") out[k] = v;
return out;
}
/** Accoda un record (crea anche il placeholder locale ricercabile). */
export async function queueStore(
payload: QueuePayload,
opts?: { dbFile?: string },
): Promise<{ local_id: string; queue_size: number }> {
const db = await openLocalDb(opts?.dbFile);
try {
const localId = randomUUID();
const at = nowIso();
db.prepare("INSERT INTO pending(local_id,payload,created_at,status) VALUES(?,?,?,'queued')").run(
localId,
JSON.stringify(stripEmpty(payload as unknown as Record<string, unknown>)),
at,
);
upsertRecords(db, [
{
memory_id: localId,
text: payload.text,
kind: payload.kind ?? "fact",
project_id: payload.project_id,
scope: payload.scope ?? "agent",
agent_id: payload.agent_id ?? null,
confidence: payload.confidence ?? null,
importance: typeof payload.importance === "number" ? payload.importance : null,
created_at: at,
supersedes_id: payload.supersedes_id ?? null,
parent_id: payload.parent_id ?? null,
topic: payload.topic ?? null,
level: payload.level ?? null,
private: payload.private ?? null,
pending: true,
source: "outbox",
},
]);
const n = db.prepare("SELECT COUNT(*) n FROM pending WHERE status='queued'").get()?.n ?? 0;
return { local_id: localId, queue_size: n };
} finally {
db.close();
}
}
export async function queueList(opts?: { dbFile?: string; status?: string; limit?: number }): Promise<QueueItem[]> {
const db = await openLocalDb(opts?.dbFile);
try {
const where = opts?.status ? "WHERE status = ?" : "";
const args: any[] = opts?.status ? [opts.status] : [];
args.push(opts?.limit ?? 50);
const rows = db
.prepare(`SELECT local_id, payload, created_at, attempts, last_attempt, last_error, status, remote_id FROM pending ${where} ORDER BY id DESC LIMIT ?`)
.all(...args) as any[];
return rows.map((r) => ({ ...r, payload: JSON.parse(r.payload) }));
} finally {
db.close();
}
}
export async function queueStats(opts?: { dbFile?: string }): Promise<QueueStats> {
const db = await openLocalDb(opts?.dbFile);
try {
const count = (s: string) => db.prepare("SELECT COUNT(*) n FROM pending WHERE status=?").get(s)?.n ?? 0;
const oldest = db.prepare("SELECT created_at FROM pending WHERE status='queued' ORDER BY id LIMIT 1").get()?.created_at ?? null;
const lastError = db.prepare("SELECT last_error FROM pending WHERE last_error IS NOT NULL ORDER BY id DESC LIMIT 1").get()?.last_error ?? null;
return { queued: count("queued"), synced: count("synced"), duplicate: count("duplicate"), failed: count("failed"), oldestQueued: oldest, lastError };
} finally {
db.close();
}
}
/** Il record locale "diventa" quello remoto: adotta l'ID del gateway. */
function adoptRemoteId(db: Db, localId: string, remoteId: string, payload: QueuePayload, data: any): void {
db.prepare("DELETE FROM records WHERE memory_id = ?").run(localId);
upsertRecords(db, [
{
memory_id: remoteId,
text: data?.text ?? payload.text,
kind: data?.kind ?? payload.kind ?? "fact",
project_id: data?.project_id ?? payload.project_id,
scope: data?.scope ?? payload.scope ?? "agent",
agent_id: data?.agent_id ?? payload.agent_id ?? null,
confidence: data?.confidence ?? payload.confidence ?? null,
importance: typeof data?.importance === "number" ? data.importance : (payload.importance ?? null),
created_at: data?.created_at ?? null,
supersedes_id: data?.supersedes_id ?? payload.supersedes_id ?? null,
superseded_by: data?.superseded_by ?? null,
parent_id: data?.parent_id ?? payload.parent_id ?? null,
topic: data?.topic ?? payload.topic ?? null,
level: data?.level ?? payload.level ?? null,
private: data?.private ?? payload.private ?? null,
pending: false,
source: "sync",
},
]);
}
/** Upload dei record in coda verso il gateway (idempotente, FIFO). */
export async function flushQueue(
cfg: MemoryConfig,
opts?: { dbFile?: string; limit?: number; paceMs?: number },
): Promise<FlushStats> {
const db = await openLocalDb(opts?.dbFile);
const stats: FlushStats = { processed: 0, synced: 0, duplicates: 0, failed: 0, remaining: 0, remoteIds: {}, errors: [] };
try {
const items = db
.prepare("SELECT local_id, payload FROM pending WHERE status='queued' ORDER BY id LIMIT ?")
.all(opts?.limit ?? 100) as Array<{ local_id: string; payload: string }>;
const pace = opts?.paceMs ?? 300; // < rate limit del gateway (120/min)
const perRequestMs = Math.min(cfg.timeoutMs ?? 30_000, 12_000);
for (const item of items) {
stats.processed++;
const payload: QueuePayload = JSON.parse(item.payload);
// supersede verso un record creato offline: rimappa local_id → remote_id
if (payload.supersedes_id) {
const parent = db.prepare("SELECT status, remote_id FROM pending WHERE local_id = ?").get(payload.supersedes_id) as any;
if (parent) {
if ((parent.status === "synced" || parent.status === "duplicate") && parent.remote_id) {
payload.supersedes_id = parent.remote_id;
} else {
const msg = `supersede di un record non ancora sincronizzato (${String(payload.supersedes_id).slice(0, 8)})`;
db.prepare("UPDATE pending SET status='failed', attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
stats.failed++;
stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`);
continue;
}
}
}
const body = stripEmpty(payload as unknown as Record<string, unknown>);
let res: { ok: boolean; status: number; data: any };
try {
res = await gatewayRequest(
cfg,
"POST",
"/v1/memories",
body,
AbortSignal.timeout(perRequestMs),
item.local_id, // Idempotency-Key: retry sicuri, nessun duplicato
);
} catch (e) {
const msg = `${e instanceof Error ? e.message : String(e)} (timeout ${perRequestMs}ms)`;
db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`);
stats.stopped = msg;
break;
}
if (res.ok && res.data?.memory_id) {
db.prepare("UPDATE pending SET status='synced', remote_id=?, synced_at=?, attempts=attempts+1, last_attempt=?, last_error=NULL WHERE local_id=?").run(
res.data.memory_id,
nowIso(),
nowIso(),
item.local_id,
);
adoptRemoteId(db, item.local_id, res.data.memory_id, payload, res.data);
stats.synced++;
stats.remoteIds[item.local_id] = res.data.memory_id;
} else if (res.status === 409) {
const detail = res.data?.detail ?? res.data ?? {};
const remoteId: string | null = detail?.matches?.[0]?.memory_id ?? null;
db.prepare("UPDATE pending SET status='duplicate', remote_id=?, attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(
remoteId,
nowIso(),
`409 ${detail?.reason ?? "duplicate_memory"}`,
item.local_id,
);
if (remoteId) {
// Il record è già sul gateway: NON sovrascrivere il testo locale
// autorevole con il payload appena inviato — si elimina solo il
// placeholder locale e, se il record remoto non è ancora in indice,
// lo si aggiunge con il testo appena scritto.
const known = db.prepare("SELECT memory_id FROM records WHERE memory_id = ?").get(remoteId);
db.prepare("DELETE FROM records WHERE memory_id = ?").run(item.local_id);
if (!known) {
upsertRecords(db, [
{
memory_id: remoteId,
text: payload.text,
kind: payload.kind ?? "fact",
project_id: payload.project_id,
scope: payload.scope ?? "agent",
agent_id: payload.agent_id ?? null,
created_at: null,
topic: payload.topic ?? null,
level: payload.level ?? null,
private: payload.private ?? null,
pending: false,
source: "sync",
},
]);
}
} else {
db.prepare("UPDATE records SET pending=0 WHERE memory_id=?").run(item.local_id);
}
stats.duplicates++;
} else if (res.status >= 400 && res.status < 500) {
// errore permanente (422 validazione, 400, 401): non ritentare in automatico
const msg = `${res.status}: ${JSON.stringify(res.data).slice(0, 300)}`;
db.prepare("UPDATE pending SET status='failed', attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
stats.failed++;
stats.errors.push(`${item.local_id.slice(0, 8)}: HTTP ${res.status}`);
if (res.status === 401) {
stats.stopped = "chiave API non valida (401)";
break;
}
} else {
// 0/429/5xx: gateway non utilizzabile, resta in coda
const msg = `HTTP ${res.status}`;
db.prepare("UPDATE pending SET attempts=attempts+1, last_attempt=?, last_error=? WHERE local_id=?").run(nowIso(), msg, item.local_id);
stats.errors.push(`${item.local_id.slice(0, 8)}: ${msg}`);
stats.stopped = msg;
break;
}
if (pace > 0) await sleep(pace);
}
stats.remaining = db.prepare("SELECT COUNT(*) n FROM pending WHERE status='queued'").get()?.n ?? 0;
db.prepare("INSERT INTO meta(k,v) VALUES('last_flush',?) ON CONFLICT(k) DO UPDATE SET v=excluded.v").run(nowIso());
return stats;
} finally {
db.close();
}
}
/**
* Store con fallback offline: prova il gateway, e se non è raggiungibile accoda
* il record localmente (default `offlineQueue: true`).
*/
export async function submitOrQueue(
cfg: MemoryConfig,
payload: QueuePayload,
opts?: { dbFile?: string; queue?: boolean },
): Promise<{ queued: boolean; remote_id?: string; local_id?: string; queue_size?: number; status: number; data?: any }> {
const queueAllowed = opts?.queue ?? cfg.offlineQueue !== false;
const body = stripEmpty(payload as unknown as Record<string, unknown>);
const perRequestMs = Math.min(cfg.timeoutMs ?? 30_000, 15_000);
const res = await gatewayRequest(cfg, "POST", "/v1/memories", body, AbortSignal.timeout(perRequestMs), randomUUID());
if (res.ok && res.data?.memory_id) {
// indicizza subito in locale (senza attendere enrich/export)
try {
const db = await openLocalDb(opts?.dbFile);
upsertRecords(db, [
{
memory_id: res.data.memory_id,
text: res.data.text ?? payload.text,
kind: res.data.kind ?? payload.kind ?? "fact",
project_id: res.data.project_id ?? payload.project_id,
scope: res.data.scope ?? payload.scope ?? "agent",
agent_id: res.data.agent_id ?? payload.agent_id ?? null,
confidence: res.data.confidence ?? payload.confidence ?? null,
importance: typeof res.data.importance === "number" ? res.data.importance : (payload.importance ?? null),
created_at: res.data.created_at ?? null,
supersedes_id: res.data.supersedes_id ?? payload.supersedes_id ?? null,
parent_id: payload.parent_id ?? null,
topic: payload.topic ?? null,
level: payload.level ?? null,
private: payload.private ?? null,
pending: false,
source: "store",
},
]);
db.close();
} catch {
/* l'indice locale è best-effort sul percorso online */
}
maybeBackgroundFlush(cfg, opts?.dbFile);
return { queued: false, remote_id: res.data.memory_id, status: res.status, data: res.data };
}
const down = res.status === 0 || res.status >= 500 || res.status === 429;
if (down && queueAllowed) {
const q = await queueStore(payload, { dbFile: opts?.dbFile });
return { queued: true, local_id: q.local_id, queue_size: q.queue_size, status: res.status, data: res.data };
}
return { queued: false, status: res.status, data: res.data };
}
let flushInFlight: Promise<unknown> | null = null;
/** Flush in background (single-flight): usato dopo operazioni riuscite e su session_start. */
export function maybeBackgroundFlush(cfg: MemoryConfig, dbFile?: string): void {
if (flushInFlight) return;
flushInFlight = flushQueue(cfg, { dbFile, limit: 20, paceMs: 400 })
.catch(() => undefined)
.finally(() => {
flushInFlight = null;
});
}
export async function flushQueueIfPending(cfg: MemoryConfig, dbFile?: string): Promise<FlushStats | null> {
const stats = await queueStats({ dbFile });
if (!stats.queued) return null;
return flushQueue(cfg, { dbFile, limit: 50, paceMs: 300 });
}
// ---------------------------------------------------------------------------
// Report / stato
// ---------------------------------------------------------------------------
@@ -657,6 +1064,14 @@ export interface LocalDbReport {
lastImport?: string;
lastEnrich?: string;
lastExport?: string;
lastFlush?: string;
queued: number;
syncedQueue: number;
duplicateQueue: number;
failedQueue: number;
pendingInIndex: number;
oldestQueued?: string | null;
queueLastError?: string | null;
topProjects: Array<{ project_id: string | null; n: number }>;
duplicates: Array<{ text_hash: string; n: number; ids: string[] }>;
}
@@ -674,6 +1089,11 @@ export async function localDbReport(opts?: { dbFile?: string }): Promise<LocalDb
superseded: 0,
private: 0,
withProject: 0,
queued: 0,
syncedQueue: 0,
duplicateQueue: 0,
failedQueue: 0,
pendingInIndex: 0,
topProjects: [],
duplicates: [],
};
@@ -697,6 +1117,14 @@ export async function localDbReport(opts?: { dbFile?: string }): Promise<LocalDb
report.lastImport = db.prepare("SELECT v FROM meta WHERE k='last_import'").get()?.v;
report.lastEnrich = db.prepare("SELECT v FROM meta WHERE k='last_enrich'").get()?.v;
report.lastExport = db.prepare("SELECT v FROM meta WHERE k='last_export'").get()?.v;
report.lastFlush = db.prepare("SELECT v FROM meta WHERE k='last_flush'").get()?.v;
report.queued = one("SELECT COUNT(*) n FROM pending WHERE status='queued'");
report.syncedQueue = one("SELECT COUNT(*) n FROM pending WHERE status='synced'");
report.duplicateQueue = one("SELECT COUNT(*) n FROM pending WHERE status='duplicate'");
report.failedQueue = one("SELECT COUNT(*) n FROM pending WHERE status='failed'");
report.pendingInIndex = one("SELECT COUNT(*) n FROM records WHERE pending = 1");
report.oldestQueued = db.prepare("SELECT created_at FROM pending WHERE status='queued' ORDER BY id LIMIT 1").get()?.created_at ?? null;
report.queueLastError = db.prepare("SELECT last_error FROM pending WHERE last_error IS NOT NULL ORDER BY id DESC LIMIT 1").get()?.last_error ?? null;
report.topProjects = db.prepare("SELECT project_id, COUNT(*) n FROM records GROUP BY project_id ORDER BY n DESC LIMIT 8").all() as any;
report.duplicates = db
.prepare("SELECT text_hash, COUNT(*) n, GROUP_CONCAT(memory_id) ids FROM records WHERE text_hash IS NOT NULL GROUP BY text_hash HAVING n > 1 ORDER BY n DESC LIMIT 20")