mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-04-28 01:49:30 +00:00
129 lines
3.5 KiB
JavaScript
129 lines
3.5 KiB
JavaScript
const MAX_SNAPSHOTS = 50;
|
|
const MIN_INTERVAL_MS = 30000; // minimum 30s between snapshots for a given note
|
|
|
|
// simple djb2 hash for body deduplication
|
|
const hashBody = str => {
|
|
let h = 5381;
|
|
for (let i = 0; i < str.length; i++) h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
|
|
return h.toString(36);
|
|
};
|
|
|
|
const nowMs = () => Date.now();
|
|
|
|
const createHistoryService = database => {
|
|
let _tableAvailable = null;
|
|
|
|
const ensureTable = async () => {
|
|
if (_tableAvailable !== null) return;
|
|
try {
|
|
await database.query(`
|
|
CREATE TABLE IF NOT EXISTS joplock_history (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
note_id VARCHAR(32) NOT NULL,
|
|
user_id VARCHAR(32) NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
body TEXT NOT NULL DEFAULT '',
|
|
body_hash VARCHAR(16) NOT NULL DEFAULT '',
|
|
saved_time BIGINT NOT NULL
|
|
)
|
|
`);
|
|
await database.query(`
|
|
CREATE INDEX IF NOT EXISTS joplock_history_note_time
|
|
ON joplock_history (note_id, saved_time DESC)
|
|
`);
|
|
_tableAvailable = true;
|
|
} catch {
|
|
_tableAvailable = false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
/**
|
|
* Save a snapshot. Skips if:
|
|
* - table unavailable
|
|
* - body hash identical to most recent snapshot for this note
|
|
* - last snapshot for this note was < MIN_INTERVAL_MS ago
|
|
* After insert, prunes to MAX_SNAPSHOTS per note.
|
|
*/
|
|
async saveSnapshot(userId, noteId, title, body) {
|
|
await ensureTable();
|
|
if (!_tableAvailable) return;
|
|
const hash = hashBody(body);
|
|
const ts = nowMs();
|
|
try {
|
|
// check most recent snapshot
|
|
const last = await database.query(
|
|
'SELECT body_hash, saved_time FROM joplock_history WHERE note_id = $1 ORDER BY saved_time DESC LIMIT 1',
|
|
[noteId],
|
|
);
|
|
const prev = last.rows[0];
|
|
if (prev) {
|
|
if (prev.body_hash === hash) return; // identical content
|
|
if (ts - Number(prev.saved_time) < MIN_INTERVAL_MS) return; // too soon
|
|
}
|
|
await database.query(
|
|
'INSERT INTO joplock_history (note_id, user_id, title, body, body_hash, saved_time) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
[noteId, userId, title || '', body || '', hash, ts],
|
|
);
|
|
// prune old entries beyond MAX_SNAPSHOTS
|
|
await database.query(`
|
|
DELETE FROM joplock_history
|
|
WHERE note_id = $1 AND id NOT IN (
|
|
SELECT id FROM joplock_history WHERE note_id = $1
|
|
ORDER BY saved_time DESC LIMIT $2
|
|
)
|
|
`, [noteId, MAX_SNAPSHOTS]);
|
|
} catch {
|
|
// history is best-effort; never break saves
|
|
}
|
|
},
|
|
|
|
/**
|
|
* List snapshots for a note (newest first), metadata only (no body).
|
|
*/
|
|
async listSnapshots(noteId) {
|
|
await ensureTable();
|
|
if (!_tableAvailable) return [];
|
|
try {
|
|
const result = await database.query(
|
|
'SELECT id, title, saved_time FROM joplock_history WHERE note_id = $1 ORDER BY saved_time DESC LIMIT $2',
|
|
[noteId, MAX_SNAPSHOTS],
|
|
);
|
|
return result.rows.map(r => ({
|
|
id: String(r.id),
|
|
title: r.title,
|
|
savedTime: Number(r.saved_time),
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get a single snapshot by id (includes body).
|
|
*/
|
|
async getSnapshot(snapshotId) {
|
|
await ensureTable();
|
|
if (!_tableAvailable) return null;
|
|
try {
|
|
const result = await database.query(
|
|
'SELECT id, note_id, title, body, saved_time FROM joplock_history WHERE id = $1 LIMIT 1',
|
|
[snapshotId],
|
|
);
|
|
const r = result.rows[0];
|
|
if (!r) return null;
|
|
return {
|
|
id: String(r.id),
|
|
noteId: r.note_id,
|
|
title: r.title,
|
|
body: r.body,
|
|
savedTime: Number(r.saved_time),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
module.exports = { createHistoryService, hashBody };
|