@bitpub/cli 2.0.0
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/README.md +98 -0
- package/bin/bitpub.js +67 -0
- package/package.json +58 -0
- package/skills/bitpub/SKILL.md +325 -0
- package/src/agents-md.js +100 -0
- package/src/aliases.js +116 -0
- package/src/api.js +177 -0
- package/src/commands/alias.js +79 -0
- package/src/commands/auth.js +50 -0
- package/src/commands/browser.js +196 -0
- package/src/commands/catchup.js +109 -0
- package/src/commands/delete.js +189 -0
- package/src/commands/drop.js +22 -0
- package/src/commands/fetch.js +29 -0
- package/src/commands/find.js +175 -0
- package/src/commands/grep.js +26 -0
- package/src/commands/init.js +49 -0
- package/src/commands/list.js +241 -0
- package/src/commands/load.js +122 -0
- package/src/commands/push.js +84 -0
- package/src/commands/read.js +42 -0
- package/src/commands/recent.js +67 -0
- package/src/commands/restore.js +23 -0
- package/src/commands/save.js +255 -0
- package/src/commands/seed.js +152 -0
- package/src/commands/setup.js +312 -0
- package/src/commands/skills.js +304 -0
- package/src/commands/status.js +62 -0
- package/src/commands/sync.js +160 -0
- package/src/commands/trash.js +88 -0
- package/src/commands/update.js +155 -0
- package/src/commands/watch.js +24 -0
- package/src/commands/welcome.js +189 -0
- package/src/config.js +85 -0
- package/src/crypto.js +61 -0
- package/src/db/cache.js +373 -0
- package/src/workspace.js +377 -0
- package/static/console.html +2263 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const BITPUB_DIR = path.join(os.homedir(), '.bitpub');
|
|
8
|
+
const CONFIG_FILE = path.join(BITPUB_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default cloud tenant. `bitpub init` provisions an identity here unless
|
|
12
|
+
* overridden with --url. Override via env for development:
|
|
13
|
+
* BITPUB_CLOUD_URL=http://localhost:8080 bitpub init
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_CLOUD_URL = process.env.BITPUB_CLOUD_URL || 'https://bitpub.io';
|
|
16
|
+
|
|
17
|
+
function ensureDir() {
|
|
18
|
+
if (!fs.existsSync(BITPUB_DIR)) {
|
|
19
|
+
fs.mkdirSync(BITPUB_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readConfig() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeConfig(config) {
|
|
32
|
+
ensureDir();
|
|
33
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Identity is valid if api_url is present and at least one of these is true:
|
|
38
|
+
* - private identity: api_key + owner (provisioned by `bitpub init`)
|
|
39
|
+
* - group identity: group_key + domain (set by `bitpub auth login`)
|
|
40
|
+
* - legacy single-key: api_key + domain (old config shape, still supported)
|
|
41
|
+
*/
|
|
42
|
+
function isConfigured(config) {
|
|
43
|
+
if (!config || !config.api_url) return false;
|
|
44
|
+
const hasPrivate = Boolean(config.api_key && config.owner);
|
|
45
|
+
const hasGroup = Boolean(config.group_key && config.domain);
|
|
46
|
+
const hasLegacy = Boolean(config.api_key && config.domain);
|
|
47
|
+
return hasPrivate || hasGroup || hasLegacy;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Require a valid config or exit with a helpful message that points at the
|
|
52
|
+
* agent-first onboarding path first, then the BYOC path.
|
|
53
|
+
*/
|
|
54
|
+
function requireConfig() {
|
|
55
|
+
const config = readConfig();
|
|
56
|
+
if (!isConfigured(config)) {
|
|
57
|
+
console.error('No BitPub identity configured. Set one up:');
|
|
58
|
+
console.error(' bitpub init # auto-provision against the hosted tenant');
|
|
59
|
+
console.error(' bitpub auth login --key <K> --domain <D> # enterprise / BYOC');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
return config;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stable author identifier used in slice metadata. Falls back gracefully
|
|
67
|
+
* across config shapes so freshly-provisioned (owner-only) and BYOC
|
|
68
|
+
* (domain-only) identities both produce a meaningful author_id.
|
|
69
|
+
*/
|
|
70
|
+
function authorIdFor(config) {
|
|
71
|
+
if (config.owner) return `agent_${config.owner}`;
|
|
72
|
+
if (config.domain) return `agent_${config.domain.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
73
|
+
return 'agent_anonymous';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
readConfig,
|
|
78
|
+
writeConfig,
|
|
79
|
+
requireConfig,
|
|
80
|
+
isConfigured,
|
|
81
|
+
authorIdFor,
|
|
82
|
+
BITPUB_DIR,
|
|
83
|
+
CONFIG_FILE,
|
|
84
|
+
DEFAULT_CLOUD_URL,
|
|
85
|
+
};
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const SALT = Buffer.from('bitpub-private-v1');
|
|
6
|
+
const INFO = Buffer.from('content-encryption');
|
|
7
|
+
const PREFIX = 'bitpub-encrypted:v1:';
|
|
8
|
+
|
|
9
|
+
function deriveKey(apiKey) {
|
|
10
|
+
return crypto.hkdfSync('sha256', apiKey, SALT, INFO, 32);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function encrypt(content, apiKey) {
|
|
14
|
+
const key = deriveKey(apiKey);
|
|
15
|
+
const iv = crypto.randomBytes(12);
|
|
16
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(key), iv);
|
|
17
|
+
const encrypted = Buffer.concat([cipher.update(content, 'utf8'), cipher.final()]);
|
|
18
|
+
const tag = cipher.getAuthTag();
|
|
19
|
+
const combined = Buffer.concat([encrypted, tag]);
|
|
20
|
+
return PREFIX + iv.toString('base64') + ':' + combined.toString('base64');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function decrypt(encryptedStr, apiKey) {
|
|
24
|
+
if (!encryptedStr.startsWith(PREFIX)) return encryptedStr;
|
|
25
|
+
const parts = encryptedStr.slice(PREFIX.length).split(':', 1);
|
|
26
|
+
const rest = encryptedStr.slice(PREFIX.length + parts[0].length + 1);
|
|
27
|
+
const iv = Buffer.from(parts[0], 'base64');
|
|
28
|
+
const data = Buffer.from(rest, 'base64');
|
|
29
|
+
const tag = data.slice(-16);
|
|
30
|
+
const ciphertext = data.slice(0, -16);
|
|
31
|
+
const key = deriveKey(apiKey);
|
|
32
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), iv);
|
|
33
|
+
decipher.setAuthTag(tag);
|
|
34
|
+
return decipher.update(ciphertext, null, 'utf8') + decipher.final('utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isEncrypted(str) {
|
|
38
|
+
return typeof str === 'string' && str.startsWith(PREFIX);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPrivateHcu(hcu) {
|
|
42
|
+
return typeof hcu === 'string' && hcu.startsWith('bitpub://private:');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decrypt payload.content for any private-scope slices in an array.
|
|
47
|
+
* Mutates the slice objects in place. Returns the array for chaining.
|
|
48
|
+
*/
|
|
49
|
+
function decryptSlices(slices, apiKey) {
|
|
50
|
+
for (const slice of slices) {
|
|
51
|
+
if (!isPrivateHcu(slice.hcu)) continue;
|
|
52
|
+
const payload = typeof slice.payload === 'string' ? JSON.parse(slice.payload) : slice.payload;
|
|
53
|
+
if (isEncrypted(payload.content)) {
|
|
54
|
+
payload.content = decrypt(payload.content, apiKey);
|
|
55
|
+
slice.payload = payload;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return slices;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { encrypt, decrypt, isEncrypted, isPrivateHcu, decryptSlices };
|
package/src/db/cache.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Database = require('better-sqlite3');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
const DB_PATH = path.join(os.homedir(), '.bitpub', 'cache.db');
|
|
9
|
+
|
|
10
|
+
// All cache tables live here. CREATE TABLE IF NOT EXISTS is idempotent
|
|
11
|
+
// and fast (~0.1ms), so we run it on every connection rather than relying
|
|
12
|
+
// on `bitpub init`. This means a CLI that's been installed since before
|
|
13
|
+
// a new table was added (e.g. local_trash for soft delete) self-migrates
|
|
14
|
+
// the first time any cache function is called — no `bitpub init` rerun
|
|
15
|
+
// required.
|
|
16
|
+
const SCHEMA_DDL = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS local_slices (
|
|
18
|
+
hcu TEXT PRIMARY KEY,
|
|
19
|
+
payload TEXT NOT NULL,
|
|
20
|
+
metadata TEXT NOT NULL,
|
|
21
|
+
version INTEGER DEFAULT 1,
|
|
22
|
+
last_synced TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS synced_namespaces (
|
|
26
|
+
pattern TEXT PRIMARY KEY,
|
|
27
|
+
last_synced TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
28
|
+
slice_count INTEGER DEFAULT 0
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS local_trash (
|
|
32
|
+
hcu TEXT PRIMARY KEY,
|
|
33
|
+
payload TEXT NOT NULL,
|
|
34
|
+
metadata TEXT NOT NULL,
|
|
35
|
+
version INTEGER NOT NULL,
|
|
36
|
+
deleted_at TEXT NOT NULL,
|
|
37
|
+
trashed_locally_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
38
|
+
);
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function getDb() {
|
|
42
|
+
const dir = path.dirname(DB_PATH);
|
|
43
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
const db = new Database(DB_PATH);
|
|
45
|
+
// Self-heal schema on every connection — handles upgrades from CLI
|
|
46
|
+
// versions that predated newer tables. Idempotent, microseconds.
|
|
47
|
+
db.exec(SCHEMA_DDL);
|
|
48
|
+
return db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Explicit initialization is now redundant — `getDb()` runs the same DDL
|
|
55
|
+
* on every connection. This function is kept for backward compatibility
|
|
56
|
+
* with code that calls it directly during `bitpub init`.
|
|
57
|
+
*
|
|
58
|
+
* Two tables matter for deletion:
|
|
59
|
+
*
|
|
60
|
+
* local_slices — active state ONLY. No deleted_at column. The hot
|
|
61
|
+
* read path (`bitpub read`) is a pure-local query
|
|
62
|
+
* against this table; tombstones never leak in.
|
|
63
|
+
*
|
|
64
|
+
* local_trash — personal undo buffer for private slices THIS machine
|
|
65
|
+
* dropped. Stores the prior payload + version so an
|
|
66
|
+
* offline `bitpub trash restore` can attempt a server
|
|
67
|
+
* write with --expect-version. Group drops are NOT
|
|
68
|
+
* stored here (group is shared state; restoration goes
|
|
69
|
+
* through the server). Cleaned up on sync if remotely
|
|
70
|
+
* restored or superseded; TTL-purged after 30 days.
|
|
71
|
+
*/
|
|
72
|
+
function initCache() {
|
|
73
|
+
const db = getDb();
|
|
74
|
+
db.close();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Writes ────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Upsert a single slice returned from the remote API into the local cache.
|
|
81
|
+
* Accepts either the full DTO (from /push response) or a raw DB row.
|
|
82
|
+
*/
|
|
83
|
+
function upsertSlice(slice) {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
const stmt = db.prepare(`
|
|
86
|
+
INSERT INTO local_slices (hcu, payload, metadata, version, last_synced)
|
|
87
|
+
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
88
|
+
ON CONFLICT (hcu) DO UPDATE SET
|
|
89
|
+
payload = excluded.payload,
|
|
90
|
+
metadata = excluded.metadata,
|
|
91
|
+
version = excluded.version,
|
|
92
|
+
last_synced = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
93
|
+
`);
|
|
94
|
+
stmt.run(
|
|
95
|
+
slice.hcu,
|
|
96
|
+
typeof slice.payload === 'string' ? slice.payload : JSON.stringify(slice.payload),
|
|
97
|
+
typeof slice.metadata === 'string' ? slice.metadata : JSON.stringify(slice.metadata),
|
|
98
|
+
slice.metadata?.version ?? 1
|
|
99
|
+
);
|
|
100
|
+
db.close();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Record that a namespace pattern was explicitly fetched.
|
|
105
|
+
*/
|
|
106
|
+
function recordNamespaceSync(pattern, count) {
|
|
107
|
+
const db = getDb();
|
|
108
|
+
db.prepare(`
|
|
109
|
+
INSERT INTO synced_namespaces (pattern, last_synced, slice_count)
|
|
110
|
+
VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
|
111
|
+
ON CONFLICT (pattern) DO UPDATE SET
|
|
112
|
+
last_synced = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
113
|
+
slice_count = excluded.slice_count
|
|
114
|
+
`).run(pattern, count);
|
|
115
|
+
db.close();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Reads ─────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Query local cache using HCU glob patterns.
|
|
122
|
+
* exact → exact match
|
|
123
|
+
* .../path/* → direct children (one level deep)
|
|
124
|
+
* .../path/** → all descendants (recursive)
|
|
125
|
+
*/
|
|
126
|
+
function querySlices(hcuPattern) {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
const pattern = hcuPattern.replace(/\/$/, '');
|
|
129
|
+
let rows;
|
|
130
|
+
|
|
131
|
+
if (pattern.endsWith('/**')) {
|
|
132
|
+
const base = pattern.slice(0, -3);
|
|
133
|
+
// Match the exact base OR anything underneath it
|
|
134
|
+
rows = db.prepare(`
|
|
135
|
+
SELECT * FROM local_slices
|
|
136
|
+
WHERE hcu = ? OR hcu LIKE ?
|
|
137
|
+
ORDER BY hcu
|
|
138
|
+
`).all(base, base + '/%');
|
|
139
|
+
} else if (pattern.endsWith('/*')) {
|
|
140
|
+
const base = pattern.slice(0, -2);
|
|
141
|
+
// Direct children: one slash deeper, no further slashes
|
|
142
|
+
rows = db.prepare(`
|
|
143
|
+
SELECT * FROM local_slices
|
|
144
|
+
WHERE hcu LIKE ?
|
|
145
|
+
AND SUBSTR(hcu, LENGTH(?) + 2) NOT LIKE '%/%'
|
|
146
|
+
ORDER BY hcu
|
|
147
|
+
`).all(base + '/%', base);
|
|
148
|
+
} else {
|
|
149
|
+
rows = db.prepare('SELECT * FROM local_slices WHERE hcu = ?').all(pattern);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
db.close();
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Find slices whose address ends with the given path-tail. Used for
|
|
158
|
+
* "did you mean" recovery on a `load` miss: the user typed a short name
|
|
159
|
+
* that didn't resolve in their active workspace, but a slice with the
|
|
160
|
+
* same trailing path may still exist elsewhere in their cache (e.g. in
|
|
161
|
+
* Transcripts/, Memory/, a sibling Workspace, etc.).
|
|
162
|
+
*
|
|
163
|
+
* Matching is path-suffix only — `notes` matches `.../foo/notes` but not
|
|
164
|
+
* `.../barnotes`. Multi-segment tails work too: `Transcripts/Raw/2026-04-30`
|
|
165
|
+
* matches any address ending in that exact path.
|
|
166
|
+
*
|
|
167
|
+
* SQL wildcards `%` and `_` in the input are escaped so user-supplied
|
|
168
|
+
* names can't accidentally widen the search.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} tail e.g. "notes" or "Transcripts/Raw/2026-04-30-..."
|
|
171
|
+
* @param {string} [scopePattern] optional bitpub:// pattern to constrain the
|
|
172
|
+
* search (e.g. `bitpub://private:owner/**`)
|
|
173
|
+
* @returns {Array} matching local_slices rows
|
|
174
|
+
*/
|
|
175
|
+
function findSlicesByName(tail, scopePattern) {
|
|
176
|
+
if (typeof tail !== 'string' || tail.length === 0) return [];
|
|
177
|
+
const db = getDb();
|
|
178
|
+
|
|
179
|
+
const normalizedTail = '/' + tail.replace(/^\/+|\/+$/g, '');
|
|
180
|
+
const escape = (s) => s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
181
|
+
const tailLike = '%' + escape(normalizedTail);
|
|
182
|
+
|
|
183
|
+
let rows;
|
|
184
|
+
if (scopePattern) {
|
|
185
|
+
const scopeBase = scopePattern.replace(/\/(\*\*|\*)$/, '').replace(/\/$/, '');
|
|
186
|
+
const scopeLike = escape(scopeBase) + '/%';
|
|
187
|
+
rows = db.prepare(`
|
|
188
|
+
SELECT * FROM local_slices
|
|
189
|
+
WHERE (hcu = ? OR hcu LIKE ? ESCAPE '\\')
|
|
190
|
+
AND hcu LIKE ? ESCAPE '\\'
|
|
191
|
+
ORDER BY hcu
|
|
192
|
+
`).all(scopeBase, scopeLike, tailLike);
|
|
193
|
+
} else {
|
|
194
|
+
rows = db.prepare(
|
|
195
|
+
`SELECT * FROM local_slices WHERE hcu LIKE ? ESCAPE '\\' ORDER BY hcu`
|
|
196
|
+
).all(tailLike);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
db.close();
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Return all synced namespace records for the `status` command.
|
|
205
|
+
*/
|
|
206
|
+
function getSyncedNamespaces() {
|
|
207
|
+
const db = getDb();
|
|
208
|
+
const rows = db.prepare(
|
|
209
|
+
'SELECT pattern, last_synced, slice_count FROM synced_namespaces ORDER BY last_synced DESC'
|
|
210
|
+
).all();
|
|
211
|
+
db.close();
|
|
212
|
+
return rows;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Return aggregate stats for the `status` command.
|
|
217
|
+
*/
|
|
218
|
+
function getCacheStats() {
|
|
219
|
+
const db = getDb();
|
|
220
|
+
const row = db.prepare(`
|
|
221
|
+
SELECT COUNT(*) as total_slices,
|
|
222
|
+
MAX(last_synced) as latest_sync,
|
|
223
|
+
MIN(last_synced) as oldest_sync
|
|
224
|
+
FROM local_slices
|
|
225
|
+
`).get();
|
|
226
|
+
db.close();
|
|
227
|
+
return row;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Deletion helpers ──────────────────────────────────────────────────────────
|
|
231
|
+
//
|
|
232
|
+
// Drops mutate two tables atomically depending on scope:
|
|
233
|
+
// - private + this-machine drop → row leaves local_slices, lands in local_trash
|
|
234
|
+
// - group drop → row leaves local_slices, no trash entry
|
|
235
|
+
// - sync-discovered drop (any) → row leaves local_slices, no trash entry
|
|
236
|
+
//
|
|
237
|
+
// The local_trash entry carries the version at delete-time so a restore push
|
|
238
|
+
// can pass --expect-version and let the server reject any state drift.
|
|
239
|
+
|
|
240
|
+
const TRASH_TTL_DAYS = 30;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Remove a slice from the active local cache. Used for any drop the local
|
|
244
|
+
* cache observes — both ones initiated here and ones learned about via
|
|
245
|
+
* sync (SSE `deleted` hint, fetch returning no row, etc.).
|
|
246
|
+
*/
|
|
247
|
+
function evictSlice(hcu) {
|
|
248
|
+
const db = getDb();
|
|
249
|
+
db.prepare('DELETE FROM local_slices WHERE hcu = ?').run(hcu);
|
|
250
|
+
db.close();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Move a slice from local_slices into local_trash, recording the version
|
|
255
|
+
* and tombstone timestamp from the server response so a future restore
|
|
256
|
+
* can use --expect-version safely.
|
|
257
|
+
*
|
|
258
|
+
* Only call this for private slices the local user actively dropped; group
|
|
259
|
+
* drops should go through `evictSlice` instead.
|
|
260
|
+
*/
|
|
261
|
+
function trashSlice(slice, deletedAt) {
|
|
262
|
+
const db = getDb();
|
|
263
|
+
const tx = db.transaction(() => {
|
|
264
|
+
db.prepare('DELETE FROM local_slices WHERE hcu = ?').run(slice.hcu);
|
|
265
|
+
db.prepare(`
|
|
266
|
+
INSERT INTO local_trash (hcu, payload, metadata, version, deleted_at, trashed_locally_at)
|
|
267
|
+
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
268
|
+
ON CONFLICT (hcu) DO UPDATE SET
|
|
269
|
+
payload = excluded.payload,
|
|
270
|
+
metadata = excluded.metadata,
|
|
271
|
+
version = excluded.version,
|
|
272
|
+
deleted_at = excluded.deleted_at,
|
|
273
|
+
trashed_locally_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
274
|
+
`).run(
|
|
275
|
+
slice.hcu,
|
|
276
|
+
typeof slice.payload === 'string' ? slice.payload : JSON.stringify(slice.payload),
|
|
277
|
+
typeof slice.metadata === 'string' ? slice.metadata : JSON.stringify(slice.metadata),
|
|
278
|
+
slice.metadata?.version ?? 1,
|
|
279
|
+
deletedAt || slice.deleted_at || new Date().toISOString()
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
tx();
|
|
283
|
+
db.close();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Look up a single trash entry. Returns null if not in trash.
|
|
288
|
+
*/
|
|
289
|
+
function getTrashEntry(hcu) {
|
|
290
|
+
const db = getDb();
|
|
291
|
+
const row = db.prepare('SELECT * FROM local_trash WHERE hcu = ?').get(hcu);
|
|
292
|
+
db.close();
|
|
293
|
+
return row || null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* List trash entries, optionally constrained to an HCU prefix (e.g. the
|
|
298
|
+
* active workspace's namespace). Newest-first.
|
|
299
|
+
*/
|
|
300
|
+
function listTrash(prefix) {
|
|
301
|
+
const db = getDb();
|
|
302
|
+
let rows;
|
|
303
|
+
if (prefix) {
|
|
304
|
+
rows = db.prepare(`
|
|
305
|
+
SELECT * FROM local_trash
|
|
306
|
+
WHERE hcu = ? OR hcu LIKE ?
|
|
307
|
+
ORDER BY trashed_locally_at DESC
|
|
308
|
+
`).all(prefix.replace(/\/$/, ''), prefix.replace(/\/$/, '') + '/%');
|
|
309
|
+
} else {
|
|
310
|
+
rows = db.prepare('SELECT * FROM local_trash ORDER BY trashed_locally_at DESC').all();
|
|
311
|
+
}
|
|
312
|
+
db.close();
|
|
313
|
+
return rows;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Remove an entry from local_trash without touching the server. Used after
|
|
318
|
+
* a successful restore (the slice is back in local_slices via upsert), or
|
|
319
|
+
* by `bitpub trash empty`.
|
|
320
|
+
*/
|
|
321
|
+
function removeFromTrash(hcu) {
|
|
322
|
+
const db = getDb();
|
|
323
|
+
db.prepare('DELETE FROM local_trash WHERE hcu = ?').run(hcu);
|
|
324
|
+
db.close();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Empty all trash entries, optionally constrained to a prefix.
|
|
329
|
+
* Returns the number of rows removed.
|
|
330
|
+
*/
|
|
331
|
+
function emptyTrash(prefix) {
|
|
332
|
+
const db = getDb();
|
|
333
|
+
let result;
|
|
334
|
+
if (prefix) {
|
|
335
|
+
const base = prefix.replace(/\/$/, '');
|
|
336
|
+
result = db.prepare('DELETE FROM local_trash WHERE hcu = ? OR hcu LIKE ?').run(base, base + '/%');
|
|
337
|
+
} else {
|
|
338
|
+
result = db.prepare('DELETE FROM local_trash').run();
|
|
339
|
+
}
|
|
340
|
+
db.close();
|
|
341
|
+
return result.changes;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* TTL-purge stale trash entries. Trash entries older than TRASH_TTL_DAYS
|
|
346
|
+
* are removed. Returns the count of removed rows.
|
|
347
|
+
*/
|
|
348
|
+
function purgeExpiredTrash() {
|
|
349
|
+
const db = getDb();
|
|
350
|
+
const cutoff = new Date(Date.now() - TRASH_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
351
|
+
const result = db.prepare('DELETE FROM local_trash WHERE trashed_locally_at < ?').run(cutoff);
|
|
352
|
+
db.close();
|
|
353
|
+
return result.changes;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
initCache,
|
|
358
|
+
upsertSlice,
|
|
359
|
+
recordNamespaceSync,
|
|
360
|
+
querySlices,
|
|
361
|
+
findSlicesByName,
|
|
362
|
+
getSyncedNamespaces,
|
|
363
|
+
getCacheStats,
|
|
364
|
+
// Deletion helpers
|
|
365
|
+
evictSlice,
|
|
366
|
+
trashSlice,
|
|
367
|
+
getTrashEntry,
|
|
368
|
+
listTrash,
|
|
369
|
+
removeFromTrash,
|
|
370
|
+
emptyTrash,
|
|
371
|
+
purgeExpiredTrash,
|
|
372
|
+
TRASH_TTL_DAYS,
|
|
373
|
+
};
|