@symbols-cli/cli 0.0.1
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/LICENSE +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- package/package.json +44 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
/**
|
|
6
|
+
* The whole decision, in one place.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately pure: no I/O, no clock, no filesystem. That is what makes the
|
|
9
|
+
* table exhaustively testable, and every row below has a test.
|
|
10
|
+
*/
|
|
11
|
+
export function decide(s) {
|
|
12
|
+
const { server, local, base } = s;
|
|
13
|
+
// ── 1. The loop breaker. Content equality ends it, in BOTH directions. ─────
|
|
14
|
+
//
|
|
15
|
+
// Ported from `writeback_one` (odin_notebook_writeback.rs:1603-1605), where it
|
|
16
|
+
// exists because a push that echoes back as a pull that pushes again is the
|
|
17
|
+
// runaway-loop class. Note this fires even when B disagrees with both: if the
|
|
18
|
+
// two sides hold identical bytes there is nothing to transfer, and the only
|
|
19
|
+
// repair needed is to advance B.
|
|
20
|
+
if (server === local) {
|
|
21
|
+
// Note both sides may still disagree with B (e.g. the same edit was made
|
|
22
|
+
// twice, or a crash lost the confirm). There is nothing to TRANSFER, so the
|
|
23
|
+
// action is `none` either way — the caller advances B to this hash on `none`,
|
|
24
|
+
// which is the repair. Returning a distinct action for that would put a
|
|
25
|
+
// second decision point in a table whose whole value is being the only one.
|
|
26
|
+
return { kind: "none" };
|
|
27
|
+
}
|
|
28
|
+
// ── 2. Never synced (B absent) and present on both sides, differently. ────
|
|
29
|
+
//
|
|
30
|
+
// This is NOT a conflict to auto-resolve. It is the first-contact case: a
|
|
31
|
+
// project materialized over an existing directory, or a ledger that was
|
|
32
|
+
// deleted. There is no evidence about which side is newer, so freezing is the
|
|
33
|
+
// only non-destructive answer.
|
|
34
|
+
if (base === null && server !== null && local !== null) {
|
|
35
|
+
return {
|
|
36
|
+
kind: "conflict",
|
|
37
|
+
reason: "both sides have content and there is no common ancestor",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// ── 3. One side is unchanged since the base — the simple, safe cases. ─────
|
|
41
|
+
// Local matches the base => the local side did not change => take the server's.
|
|
42
|
+
if (local === base) {
|
|
43
|
+
if (server === null) {
|
|
44
|
+
return { kind: "pull-delete", reason: "deleted on the server; unchanged locally" };
|
|
45
|
+
}
|
|
46
|
+
return { kind: "pull", reason: "changed on the server; unchanged locally" };
|
|
47
|
+
}
|
|
48
|
+
// Server matches the base => the server did not change => send ours.
|
|
49
|
+
if (server === base) {
|
|
50
|
+
if (local === null) {
|
|
51
|
+
return { kind: "push-delete", reason: "deleted locally; unchanged on the server" };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "push", reason: "changed locally; unchanged on the server" };
|
|
54
|
+
}
|
|
55
|
+
// ── 4. All three differ. Both sides moved. ────────────────────────────────
|
|
56
|
+
// A delete on one side against an EDIT on the other is the case where LWW is
|
|
57
|
+
// most destructive, and it is not a conflict we can represent as a file pair —
|
|
58
|
+
// there is nothing to write beside. Keep the content, always: a delete is
|
|
59
|
+
// cheap to redo and an edit is not.
|
|
60
|
+
if (server === null) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "conflict",
|
|
63
|
+
reason: "deleted on the server but edited locally — keeping the local file",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (local === null) {
|
|
67
|
+
return {
|
|
68
|
+
kind: "conflict",
|
|
69
|
+
reason: "deleted locally but edited on the server — restoring the server copy",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { kind: "conflict", reason: "edited on both sides since the last sync" };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* May the ledger base advance to this hash?
|
|
76
|
+
*
|
|
77
|
+
* Both subtleties from the header, in one testable function. The caller must not
|
|
78
|
+
* write B unless this returns a hash.
|
|
79
|
+
*
|
|
80
|
+
* Returns the hash to store, or `null` meaning "leave B alone and re-diff next
|
|
81
|
+
* cycle" — which is always safe, because a stale B produces at worst a redundant
|
|
82
|
+
* transfer, while an over-eager B produces silent loss.
|
|
83
|
+
*/
|
|
84
|
+
export function baseAfter(i) {
|
|
85
|
+
// Subtlety 1: only the far side's acknowledgement counts.
|
|
86
|
+
if (!i.confirmed)
|
|
87
|
+
return "hold";
|
|
88
|
+
// Subtlety 2: the file moved under us while the request was in flight. The
|
|
89
|
+
// diff we acted on described content that is no longer there.
|
|
90
|
+
if (i.hashAfter !== i.hashAtDecision)
|
|
91
|
+
return "hold";
|
|
92
|
+
return i.hashAtDecision;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The same question, for a PUSH — and the answer differs.
|
|
96
|
+
*
|
|
97
|
+
* ⚠ `baseAfter` above is the PULL rule, and applying it to a push produces a
|
|
98
|
+
* SPURIOUS CONFLICT. Walk it: base `v0`, we push `v1`, the file becomes `v2`
|
|
99
|
+
* while the PATCH is in flight. `baseAfter` holds, so `B` stays `v0`. The next
|
|
100
|
+
* sweep sees `S=v1, L=v2, B=v0` — all three differ — and reports a conflict,
|
|
101
|
+
* writing a sidecar and freezing a path where NOTHING was actually in conflict:
|
|
102
|
+
* the server's `v1` is the copy we sent it a second ago.
|
|
103
|
+
*
|
|
104
|
+
* The plan's verification line says the outcome must be *"re-pushed next
|
|
105
|
+
* cycle"*, and it is right. The two functions differ because they answer
|
|
106
|
+
* different questions:
|
|
107
|
+
*
|
|
108
|
+
* pull — "is the LOCAL file now the bytes I wrote?" A partially-written file
|
|
109
|
+
* whose hash we recorded as the base would be pushed over the good
|
|
110
|
+
* server copy on the next sweep. That is why it holds.
|
|
111
|
+
* push — "does the SERVER now hold the bytes I sent?" Its answer does not
|
|
112
|
+
* depend on what the local file did afterwards. Recording it gives
|
|
113
|
+
* `S=v1, L=v2, B=v1` next sweep -> a plain `push` of the newer edit.
|
|
114
|
+
*
|
|
115
|
+
* The mid-flight edit is never lost either way; the difference is whether the
|
|
116
|
+
* user is asked to resolve a merge that does not exist.
|
|
117
|
+
*/
|
|
118
|
+
export function baseAfterPush(i) {
|
|
119
|
+
if (!i.confirmed)
|
|
120
|
+
return "hold";
|
|
121
|
+
return i.hashAtDecision;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Guard 2 of the four the container never needed.
|
|
125
|
+
*
|
|
126
|
+
* `rm -rf`, a bad `git clean`, and a real cleanup are indistinguishable from
|
|
127
|
+
* here. On a container the workspace was ours and a mass delete meant one thing;
|
|
128
|
+
* on a laptop it is far more likely to be an accident, an unplugged drive, or a
|
|
129
|
+
* mistyped command.
|
|
130
|
+
*
|
|
131
|
+
* ⚠ This is a BACKSTOP, not the primary protection, and the plan is explicit
|
|
132
|
+
* about why: the >20 threshold is blind to a server-side notebook 404, which
|
|
133
|
+
* reads naively as "all files missing". That case is handled upstream by
|
|
134
|
+
* freezing the project offline (F4), and must never reach here.
|
|
135
|
+
*/
|
|
136
|
+
export function deleteGuard(i) {
|
|
137
|
+
if (i.deletions === 0)
|
|
138
|
+
return { allowed: true };
|
|
139
|
+
if (i.confirmed)
|
|
140
|
+
return { allowed: true };
|
|
141
|
+
const fraction = i.totalTracked > 0 ? i.deletions / i.totalTracked : 1;
|
|
142
|
+
if (i.deletions > 20) {
|
|
143
|
+
return {
|
|
144
|
+
allowed: false,
|
|
145
|
+
reason: `${i.deletions} deletions exceeds the 20-file limit — re-run with \`symbols sync --confirm-deletes\` if this is intended`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (fraction > 0.25) {
|
|
149
|
+
return {
|
|
150
|
+
allowed: false,
|
|
151
|
+
reason: `${i.deletions} of ${i.totalTracked} tracked files (${Math.round(fraction * 100)}%) would be deleted — re-run with \`symbols sync --confirm-deletes\` if this is intended`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { allowed: true };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Where a conflicting server copy is written, beside the local file.
|
|
158
|
+
*
|
|
159
|
+
* Never overwrites the user's file, and the name says which side it came from —
|
|
160
|
+
* `strategy.py` stays theirs, `strategy.server.py` is the other one. The
|
|
161
|
+
* extension is preserved so an editor still highlights it.
|
|
162
|
+
*/
|
|
163
|
+
export function conflictSidecarPath(rel) {
|
|
164
|
+
const slash = rel.lastIndexOf("/");
|
|
165
|
+
const dir = slash === -1 ? "" : rel.slice(0, slash + 1);
|
|
166
|
+
const base = rel.slice(slash + 1);
|
|
167
|
+
const dot = base.lastIndexOf(".");
|
|
168
|
+
// A leading dot is the whole name (`.env`), not an extension.
|
|
169
|
+
if (dot <= 0)
|
|
170
|
+
return `${dir}${base}.server`;
|
|
171
|
+
return `${dir}${base.slice(0, dot)}.server${base.slice(dot)}`;
|
|
172
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// The sync ledger — SQLite at `~/.symbols/state.db`.
|
|
6
|
+
//
|
|
7
|
+
// This file exists to hold ONE value durably: **B**, the last state both sides
|
|
8
|
+
// confirmed for each path. Without a durable B the three-way table degrades to a
|
|
9
|
+
// two-way compare, which cannot distinguish "the server changed" from "I
|
|
10
|
+
// changed", and every implementation that tries collapses to last-writer-wins.
|
|
11
|
+
// On a laptop, where "offline three days" is normal, that silently eats work.
|
|
12
|
+
//
|
|
13
|
+
// Why SQLite and not a JSON file:
|
|
14
|
+
//
|
|
15
|
+
// * **Crash atomicity.** A JSON manifest rewritten on every settle is a
|
|
16
|
+
// truncated-file bug waiting for a kill -9 mid-write, and a truncated B is
|
|
17
|
+
// worse than a missing one — the diff would treat a partial file's paths as
|
|
18
|
+
// never-synced and re-conflict them.
|
|
19
|
+
// * **Read cost.** Reconcile runs every 60s over potentially thousands of
|
|
20
|
+
// paths; it needs point lookups, not a full parse.
|
|
21
|
+
// * `tracked_tickers.json` in R2 is this project's own worked example of the
|
|
22
|
+
// failure: a one-object read-modify-write that wiped 247 entries down to 14.
|
|
23
|
+
//
|
|
24
|
+
// ⚠ THE INODE BINDING IS A SAFETY CONTROL, NOT BOOKKEEPING. `project.json` is
|
|
25
|
+
// agent-writable and Finder-copyable. Duplicating a project directory gives two
|
|
26
|
+
// watchers on one notebook; editing `notebook_id` retargets sync at a different
|
|
27
|
+
// notebook. So the ledger binds `notebook_id <-> (realpath, inode)` and
|
|
28
|
+
// **freezes on ambiguity rather than guessing** — the plan is explicit that
|
|
29
|
+
// guessing here is how one project's content lands in another.
|
|
30
|
+
import Database from "better-sqlite3";
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join, dirname } from "node:path";
|
|
34
|
+
const SCHEMA = `
|
|
35
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
36
|
+
key TEXT PRIMARY KEY,
|
|
37
|
+
value TEXT NOT NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS project (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
notebook_id TEXT NOT NULL UNIQUE,
|
|
43
|
+
name TEXT NOT NULL,
|
|
44
|
+
dirname TEXT NOT NULL,
|
|
45
|
+
root TEXT NOT NULL,
|
|
46
|
+
inode INTEGER,
|
|
47
|
+
device_no INTEGER,
|
|
48
|
+
frozen_reason TEXT
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS file (
|
|
52
|
+
project_id TEXT NOT NULL,
|
|
53
|
+
path TEXT NOT NULL,
|
|
54
|
+
base_hash TEXT,
|
|
55
|
+
file_id TEXT,
|
|
56
|
+
size INTEGER,
|
|
57
|
+
mtime_ms REAL,
|
|
58
|
+
local_hash TEXT,
|
|
59
|
+
frozen_reason TEXT,
|
|
60
|
+
updated_at INTEGER NOT NULL,
|
|
61
|
+
PRIMARY KEY (project_id, path)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
-- The path a DELETE was confirmed for. Kept rather than dropping the row,
|
|
65
|
+
-- because "no row" and "confirmed deleted" are different states: the first
|
|
66
|
+
-- re-pulls the file, the second does not.
|
|
67
|
+
CREATE TABLE IF NOT EXISTS tombstone (
|
|
68
|
+
project_id TEXT NOT NULL,
|
|
69
|
+
path TEXT NOT NULL,
|
|
70
|
+
deleted_at INTEGER NOT NULL,
|
|
71
|
+
PRIMARY KEY (project_id, path)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Work that must survive a restart: a push whose response never arrived is
|
|
75
|
+
-- retried, not lost.
|
|
76
|
+
CREATE TABLE IF NOT EXISTS queue (
|
|
77
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
78
|
+
project_id TEXT NOT NULL,
|
|
79
|
+
path TEXT NOT NULL,
|
|
80
|
+
op TEXT NOT NULL,
|
|
81
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
last_error TEXT,
|
|
83
|
+
created_at INTEGER NOT NULL
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE INDEX IF NOT EXISTS file_by_project ON file (project_id);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS queue_by_project ON queue (project_id);
|
|
88
|
+
`;
|
|
89
|
+
/** Bumped only for a migration; `meta.schema_version` is checked on open. */
|
|
90
|
+
const SCHEMA_VERSION = 1;
|
|
91
|
+
export function statePath() {
|
|
92
|
+
return join(process.env["SYMBOLS_HOME"] ?? join(homedir(), ".symbols"), "state.db");
|
|
93
|
+
}
|
|
94
|
+
export class Ledger {
|
|
95
|
+
db;
|
|
96
|
+
constructor(db) {
|
|
97
|
+
this.db = db;
|
|
98
|
+
}
|
|
99
|
+
static async open(path = statePath()) {
|
|
100
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
101
|
+
const db = new Database(path);
|
|
102
|
+
// WAL so a reader (`symbols status`) never blocks the watcher, and so a
|
|
103
|
+
// crash mid-write rolls back to the last commit rather than corrupting.
|
|
104
|
+
db.pragma("journal_mode = WAL");
|
|
105
|
+
// NORMAL rather than FULL: a lost transaction on power failure costs one
|
|
106
|
+
// re-sync cycle, which the reconcile sweep repairs anyway. FULL would fsync
|
|
107
|
+
// on every settle of a 5,000-file project.
|
|
108
|
+
db.pragma("synchronous = NORMAL");
|
|
109
|
+
db.pragma("foreign_keys = ON");
|
|
110
|
+
db.exec(SCHEMA);
|
|
111
|
+
const ledger = new Ledger(db);
|
|
112
|
+
const existing = ledger.meta("schema_version");
|
|
113
|
+
if (existing === null) {
|
|
114
|
+
ledger.setMeta("schema_version", String(SCHEMA_VERSION));
|
|
115
|
+
}
|
|
116
|
+
else if (Number(existing) > SCHEMA_VERSION) {
|
|
117
|
+
// A newer CLI wrote this file. Refuse rather than silently misread it —
|
|
118
|
+
// reading a future schema with today's queries is how a base gets treated
|
|
119
|
+
// as absent, which re-conflicts every path in the account.
|
|
120
|
+
db.close();
|
|
121
|
+
throw new Error(`~/.symbols/state.db was written by a newer Symbols CLI (schema ${existing} > ${SCHEMA_VERSION}). ` +
|
|
122
|
+
`Upgrade with \`npm i -g @symbols-cli/cli\`.`);
|
|
123
|
+
}
|
|
124
|
+
return ledger;
|
|
125
|
+
}
|
|
126
|
+
close() {
|
|
127
|
+
this.db.close();
|
|
128
|
+
}
|
|
129
|
+
// ── meta ───────────────────────────────────────────────────────────────────
|
|
130
|
+
meta(key) {
|
|
131
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
132
|
+
return row?.value ?? null;
|
|
133
|
+
}
|
|
134
|
+
setMeta(key, value) {
|
|
135
|
+
this.db
|
|
136
|
+
.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
137
|
+
.run(key, value);
|
|
138
|
+
}
|
|
139
|
+
// ── projects ───────────────────────────────────────────────────────────────
|
|
140
|
+
/**
|
|
141
|
+
* Bind a notebook to a directory, or refuse.
|
|
142
|
+
*
|
|
143
|
+
* ⚠ THE FREEZE-ON-AMBIGUITY RULE. Three ways this goes wrong, all of them
|
|
144
|
+
* reachable without malice:
|
|
145
|
+
*
|
|
146
|
+
* * The user duplicates the folder in Finder. Two directories now claim one
|
|
147
|
+
* `notebook_id`; syncing both means each overwrites the other's pushes.
|
|
148
|
+
* * The agent edits `project.json` and retargets it at a different notebook.
|
|
149
|
+
* * The user renames or moves the directory. That one is BENIGN and must be
|
|
150
|
+
* followed, which is why the inode — not the path — is the identity.
|
|
151
|
+
*
|
|
152
|
+
* Returns the bound project, or throws. It never guesses.
|
|
153
|
+
*/
|
|
154
|
+
bindProject(p) {
|
|
155
|
+
const existing = this.db
|
|
156
|
+
.prepare("SELECT * FROM project WHERE notebook_id = ?")
|
|
157
|
+
.get(p.notebookId);
|
|
158
|
+
if (existing) {
|
|
159
|
+
const sameInode = p.inode !== null && existing["inode"] === p.inode && existing["device_no"] === p.deviceNo;
|
|
160
|
+
const samePath = existing["root"] === p.root;
|
|
161
|
+
if (!sameInode && !samePath) {
|
|
162
|
+
// Neither the inode nor the path matches: this is a second directory
|
|
163
|
+
// claiming the same notebook. Freeze BOTH rather than pick.
|
|
164
|
+
const reason = `notebook ${p.notebookId} is already bound to ${String(existing["root"])}; ` +
|
|
165
|
+
`${p.root} also claims it. Sync is frozen for this project — remove or ` +
|
|
166
|
+
`detach one copy (\`symbols project detach\`).`;
|
|
167
|
+
this.freezeProject(String(existing["id"]), reason);
|
|
168
|
+
throw new Error(reason);
|
|
169
|
+
}
|
|
170
|
+
// Same inode, different path = a rename or move. Follow it.
|
|
171
|
+
this.db
|
|
172
|
+
.prepare("UPDATE project SET root = ?, name = ?, dirname = ?, inode = ?, device_no = ? WHERE id = ?")
|
|
173
|
+
.run(p.root, p.name, p.dirname, p.inode, p.deviceNo, existing["id"]);
|
|
174
|
+
return this.project(p.notebookId);
|
|
175
|
+
}
|
|
176
|
+
this.db
|
|
177
|
+
.prepare("INSERT INTO project (id, notebook_id, name, dirname, root, inode, device_no) VALUES (?,?,?,?,?,?,?)")
|
|
178
|
+
.run(p.id, p.notebookId, p.name, p.dirname, p.root, p.inode, p.deviceNo);
|
|
179
|
+
return this.project(p.notebookId);
|
|
180
|
+
}
|
|
181
|
+
project(notebookId) {
|
|
182
|
+
const r = this.db.prepare("SELECT * FROM project WHERE notebook_id = ?").get(notebookId);
|
|
183
|
+
return r ? toProject(r) : null;
|
|
184
|
+
}
|
|
185
|
+
projects() {
|
|
186
|
+
return this.db.prepare("SELECT * FROM project ORDER BY name").all().map(toProject);
|
|
187
|
+
}
|
|
188
|
+
freezeProject(id, reason) {
|
|
189
|
+
this.db.prepare("UPDATE project SET frozen_reason = ? WHERE id = ?").run(reason, id);
|
|
190
|
+
}
|
|
191
|
+
unfreezeProject(id) {
|
|
192
|
+
this.db.prepare("UPDATE project SET frozen_reason = NULL WHERE id = ?").run(id);
|
|
193
|
+
}
|
|
194
|
+
// ── files ──────────────────────────────────────────────────────────────────
|
|
195
|
+
file(projectId, path) {
|
|
196
|
+
const r = this.db
|
|
197
|
+
.prepare("SELECT * FROM file WHERE project_id = ? AND path = ?")
|
|
198
|
+
.get(projectId, path);
|
|
199
|
+
return r ? toFile(r) : null;
|
|
200
|
+
}
|
|
201
|
+
files(projectId) {
|
|
202
|
+
return this.db.prepare("SELECT * FROM file WHERE project_id = ?").all(projectId).map(toFile);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Record a CONFIRMED state.
|
|
206
|
+
*
|
|
207
|
+
* ⚠ Call this ONLY after `baseAfter()` in `diff.ts` has returned a hash. This
|
|
208
|
+
* function does not re-check the two subtleties (far-side confirmation, and L
|
|
209
|
+
* unchanged mid-flight) because putting that decision in two places is how the
|
|
210
|
+
* two copies drift. `diff.ts` owns it.
|
|
211
|
+
*/
|
|
212
|
+
confirm(row) {
|
|
213
|
+
this.db
|
|
214
|
+
.prepare(`INSERT INTO file (project_id, path, base_hash, file_id, size, mtime_ms, local_hash, updated_at)
|
|
215
|
+
VALUES (@projectId, @path, @base, @fileId, @size, @mtimeMs, @localHash, @now)
|
|
216
|
+
ON CONFLICT(project_id, path) DO UPDATE SET
|
|
217
|
+
base_hash = excluded.base_hash,
|
|
218
|
+
file_id = COALESCE(excluded.file_id, file.file_id),
|
|
219
|
+
size = excluded.size,
|
|
220
|
+
mtime_ms = excluded.mtime_ms,
|
|
221
|
+
local_hash = excluded.local_hash,
|
|
222
|
+
updated_at = excluded.updated_at`)
|
|
223
|
+
.run({
|
|
224
|
+
projectId: row.projectId,
|
|
225
|
+
path: row.path,
|
|
226
|
+
base: row.base,
|
|
227
|
+
fileId: row.fileId ?? null,
|
|
228
|
+
size: row.size ?? null,
|
|
229
|
+
mtimeMs: row.mtimeMs ?? null,
|
|
230
|
+
localHash: row.localHash ?? null,
|
|
231
|
+
now: Date.now(),
|
|
232
|
+
});
|
|
233
|
+
// A path that comes back is no longer deleted.
|
|
234
|
+
this.db.prepare("DELETE FROM tombstone WHERE project_id = ? AND path = ?").run(row.projectId, row.path);
|
|
235
|
+
}
|
|
236
|
+
freezeFile(projectId, path, reason) {
|
|
237
|
+
this.db
|
|
238
|
+
.prepare(`INSERT INTO file (project_id, path, frozen_reason, updated_at) VALUES (?,?,?,?)
|
|
239
|
+
ON CONFLICT(project_id, path) DO UPDATE SET frozen_reason = excluded.frozen_reason,
|
|
240
|
+
updated_at = excluded.updated_at`)
|
|
241
|
+
.run(projectId, path, reason, Date.now());
|
|
242
|
+
}
|
|
243
|
+
unfreezeFile(projectId, path) {
|
|
244
|
+
this.db
|
|
245
|
+
.prepare("UPDATE file SET frozen_reason = NULL WHERE project_id = ? AND path = ?")
|
|
246
|
+
.run(projectId, path);
|
|
247
|
+
}
|
|
248
|
+
frozen(projectId) {
|
|
249
|
+
return this.db
|
|
250
|
+
.prepare("SELECT * FROM file WHERE project_id = ? AND frozen_reason IS NOT NULL")
|
|
251
|
+
.all(projectId).map(toFile);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Record a confirmed delete.
|
|
255
|
+
*
|
|
256
|
+
* The row is REPLACED BY A TOMBSTONE rather than simply dropped, because "no
|
|
257
|
+
* row" and "confirmed deleted" produce different diffs: the first has `B =
|
|
258
|
+
* null`, which reads as never-synced and re-pulls the file the user just
|
|
259
|
+
* deleted.
|
|
260
|
+
*/
|
|
261
|
+
tombstone(projectId, path) {
|
|
262
|
+
const tx = this.db.transaction(() => {
|
|
263
|
+
this.db.prepare("DELETE FROM file WHERE project_id = ? AND path = ?").run(projectId, path);
|
|
264
|
+
this.db
|
|
265
|
+
.prepare("INSERT INTO tombstone (project_id, path, deleted_at) VALUES (?,?,?) ON CONFLICT DO NOTHING")
|
|
266
|
+
.run(projectId, path, Date.now());
|
|
267
|
+
});
|
|
268
|
+
tx();
|
|
269
|
+
}
|
|
270
|
+
isTombstoned(projectId, path) {
|
|
271
|
+
return (this.db
|
|
272
|
+
.prepare("SELECT 1 FROM tombstone WHERE project_id = ? AND path = ?")
|
|
273
|
+
.get(projectId, path) !== undefined);
|
|
274
|
+
}
|
|
275
|
+
// ── queue ──────────────────────────────────────────────────────────────────
|
|
276
|
+
enqueue(projectId, path, op) {
|
|
277
|
+
this.db
|
|
278
|
+
.prepare("INSERT INTO queue (project_id, path, op, created_at) VALUES (?,?,?,?)")
|
|
279
|
+
.run(projectId, path, op, Date.now());
|
|
280
|
+
}
|
|
281
|
+
pending(projectId) {
|
|
282
|
+
return this.db
|
|
283
|
+
.prepare("SELECT id, path, op, attempts FROM queue WHERE project_id = ? ORDER BY id")
|
|
284
|
+
.all(projectId);
|
|
285
|
+
}
|
|
286
|
+
dequeue(id) {
|
|
287
|
+
this.db.prepare("DELETE FROM queue WHERE id = ?").run(id);
|
|
288
|
+
}
|
|
289
|
+
failed(id, error) {
|
|
290
|
+
this.db
|
|
291
|
+
.prepare("UPDATE queue SET attempts = attempts + 1, last_error = ? WHERE id = ?")
|
|
292
|
+
.run(error, id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function toProject(r) {
|
|
296
|
+
return {
|
|
297
|
+
id: String(r["id"]),
|
|
298
|
+
notebookId: String(r["notebook_id"]),
|
|
299
|
+
name: String(r["name"]),
|
|
300
|
+
dirname: String(r["dirname"]),
|
|
301
|
+
root: String(r["root"]),
|
|
302
|
+
inode: r["inode"] === null ? null : Number(r["inode"]),
|
|
303
|
+
deviceNo: r["device_no"] === null ? null : Number(r["device_no"]),
|
|
304
|
+
frozenReason: r["frozen_reason"] ?? null,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function toFile(r) {
|
|
308
|
+
return {
|
|
309
|
+
projectId: String(r["project_id"]),
|
|
310
|
+
path: String(r["path"]),
|
|
311
|
+
base: r["base_hash"] ?? null,
|
|
312
|
+
fileId: r["file_id"] ?? null,
|
|
313
|
+
size: r["size"] === null ? null : Number(r["size"]),
|
|
314
|
+
mtimeMs: r["mtime_ms"] === null ? null : Number(r["mtime_ms"]),
|
|
315
|
+
localHash: r["local_hash"] ?? null,
|
|
316
|
+
frozenReason: r["frozen_reason"] ?? null,
|
|
317
|
+
updatedAt: Number(r["updated_at"] ?? 0),
|
|
318
|
+
};
|
|
319
|
+
}
|