things-api 0.5.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/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/things.js +9 -0
- package/dist/audit/log.d.ts +10 -0
- package/dist/audit/log.js +31 -0
- package/dist/audit/log.js.map +1 -0
- package/dist/audit/schema.d.ts +37 -0
- package/dist/audit/schema.js +9 -0
- package/dist/audit/schema.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +9 -0
- package/dist/cli/commands/doctor.js +60 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +7 -0
- package/dist/cli/commands/mcp.js +26 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/project.d.ts +5 -0
- package/dist/cli/commands/project.js +50 -0
- package/dist/cli/commands/project.js.map +1 -0
- package/dist/cli/commands/reads.d.ts +21 -0
- package/dist/cli/commands/reads.js +295 -0
- package/dist/cli/commands/reads.js.map +1 -0
- package/dist/cli/commands/snapshot.d.ts +7 -0
- package/dist/cli/commands/snapshot.js +22 -0
- package/dist/cli/commands/snapshot.js.map +1 -0
- package/dist/cli/commands/todo.d.ts +6 -0
- package/dist/cli/commands/todo.js +43 -0
- package/dist/cli/commands/todo.js.map +1 -0
- package/dist/cli/commands/writes.d.ts +9 -0
- package/dist/cli/commands/writes.js +994 -0
- package/dist/cli/commands/writes.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +26 -0
- package/dist/cli/exit-codes.js +26 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/main.d.ts +4 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/output.d.ts +42 -0
- package/dist/cli/output.js +16 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/style.d.ts +6 -0
- package/dist/cli/style.js +25 -0
- package/dist/cli/style.js.map +1 -0
- package/dist/client.d.ts +184 -0
- package/dist/client.js +187 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +76 -0
- package/dist/config.js.map +1 -0
- package/dist/contracts.d.ts +79 -0
- package/dist/contracts.js +46 -0
- package/dist/contracts.js.map +1 -0
- package/dist/db/baselines/db-v26.d.ts +8 -0
- package/dist/db/baselines/db-v26.js +16 -0
- package/dist/db/baselines/db-v26.js.map +1 -0
- package/dist/db/baselines/index.d.ts +3 -0
- package/dist/db/baselines/index.js +4 -0
- package/dist/db/baselines/index.js.map +1 -0
- package/dist/db/connection.d.ts +22 -0
- package/dist/db/connection.js +40 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/fingerprint.d.ts +42 -0
- package/dist/db/fingerprint.js +82 -0
- package/dist/db/fingerprint.js.map +1 -0
- package/dist/db/locate.d.ts +13 -0
- package/dist/db/locate.js +44 -0
- package/dist/db/locate.js.map +1 -0
- package/dist/db/schema.d.ts +37 -0
- package/dist/db/schema.js +81 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/diagnose.d.ts +67 -0
- package/dist/diagnose.js +143 -0
- package/dist/diagnose.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +1002 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/model/dates.d.ts +38 -0
- package/dist/model/dates.js +104 -0
- package/dist/model/dates.js.map +1 -0
- package/dist/model/entities.d.ts +117 -0
- package/dist/model/entities.js +21 -0
- package/dist/model/entities.js.map +1 -0
- package/dist/model/mappers.d.ts +55 -0
- package/dist/model/mappers.js +105 -0
- package/dist/model/mappers.js.map +1 -0
- package/dist/model/occurrences.d.ts +46 -0
- package/dist/model/occurrences.js +164 -0
- package/dist/model/occurrences.js.map +1 -0
- package/dist/model/recurrence.d.ts +52 -0
- package/dist/model/recurrence.js +164 -0
- package/dist/model/recurrence.js.map +1 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/read/detail.d.ts +8 -0
- package/dist/read/detail.js +34 -0
- package/dist/read/detail.js.map +1 -0
- package/dist/read/project-view.d.ts +34 -0
- package/dist/read/project-view.js +87 -0
- package/dist/read/project-view.js.map +1 -0
- package/dist/read/queries.d.ts +39 -0
- package/dist/read/queries.js +134 -0
- package/dist/read/queries.js.map +1 -0
- package/dist/read/snapshot.d.ts +25 -0
- package/dist/read/snapshot.js +39 -0
- package/dist/read/snapshot.js.map +1 -0
- package/dist/read/tags.d.ts +17 -0
- package/dist/read/tags.js +64 -0
- package/dist/read/tags.js.map +1 -0
- package/dist/read/views.d.ts +152 -0
- package/dist/read/views.js +322 -0
- package/dist/read/views.js.map +1 -0
- package/dist/surface-copy.d.ts +15 -0
- package/dist/surface-copy.js +16 -0
- package/dist/surface-copy.js.map +1 -0
- package/dist/write/automation-probe.d.ts +12 -0
- package/dist/write/automation-probe.js +59 -0
- package/dist/write/automation-probe.js.map +1 -0
- package/dist/write/batch.d.ts +51 -0
- package/dist/write/batch.js +97 -0
- package/dist/write/batch.js.map +1 -0
- package/dist/write/capabilities.d.ts +16 -0
- package/dist/write/capabilities.js +16 -0
- package/dist/write/capabilities.js.map +1 -0
- package/dist/write/commands.d.ts +32 -0
- package/dist/write/commands.js +1274 -0
- package/dist/write/commands.js.map +1 -0
- package/dist/write/environment.d.ts +29 -0
- package/dist/write/environment.js +105 -0
- package/dist/write/environment.js.map +1 -0
- package/dist/write/experimental.d.ts +7 -0
- package/dist/write/experimental.js +39 -0
- package/dist/write/experimental.js.map +1 -0
- package/dist/write/failure-hints.d.ts +32 -0
- package/dist/write/failure-hints.js +71 -0
- package/dist/write/failure-hints.js.map +1 -0
- package/dist/write/guards.d.ts +21 -0
- package/dist/write/guards.js +312 -0
- package/dist/write/guards.js.map +1 -0
- package/dist/write/lock.d.ts +10 -0
- package/dist/write/lock.js +70 -0
- package/dist/write/lock.js.map +1 -0
- package/dist/write/operations.d.ts +262 -0
- package/dist/write/operations.js +34 -0
- package/dist/write/operations.js.map +1 -0
- package/dist/write/pipeline.d.ts +90 -0
- package/dist/write/pipeline.js +375 -0
- package/dist/write/pipeline.js.map +1 -0
- package/dist/write/planner.d.ts +40 -0
- package/dist/write/planner.js +57 -0
- package/dist/write/planner.js.map +1 -0
- package/dist/write/pre-state.d.ts +125 -0
- package/dist/write/pre-state.js +299 -0
- package/dist/write/pre-state.js.map +1 -0
- package/dist/write/reopen.d.ts +29 -0
- package/dist/write/reopen.js +63 -0
- package/dist/write/reopen.js.map +1 -0
- package/dist/write/reorder.d.ts +19 -0
- package/dist/write/reorder.js +285 -0
- package/dist/write/reorder.js.map +1 -0
- package/dist/write/undo.d.ts +53 -0
- package/dist/write/undo.js +703 -0
- package/dist/write/undo.js.map +1 -0
- package/dist/write/vectors/applescript.d.ts +5 -0
- package/dist/write/vectors/applescript.js +216 -0
- package/dist/write/vectors/applescript.js.map +1 -0
- package/dist/write/vectors/registry.d.ts +2 -0
- package/dist/write/vectors/registry.js +11 -0
- package/dist/write/vectors/registry.js.map +1 -0
- package/dist/write/vectors/types.d.ts +48 -0
- package/dist/write/vectors/types.js +2 -0
- package/dist/write/vectors/types.js.map +1 -0
- package/dist/write/vectors/url-scheme.d.ts +3 -0
- package/dist/write/vectors/url-scheme.js +176 -0
- package/dist/write/vectors/url-scheme.js.map +1 -0
- package/dist/write/verify/delta.d.ts +126 -0
- package/dist/write/verify/delta.js +281 -0
- package/dist/write/verify/delta.js.map +1 -0
- package/dist/write/verify/poller.d.ts +24 -0
- package/dist/write/verify/poller.js +28 -0
- package/dist/write/verify/poller.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `things undo` — inverse mutations replayed from the audit trail.
|
|
3
|
+
*
|
|
4
|
+
* Every successful mutation's audit record carries the pre-values of the
|
|
5
|
+
* fields it asserted (title, notes, status, tags, ranks, …) plus the target
|
|
6
|
+
* uuid — enough to compile an INVERSE plan for most operations. The inverse
|
|
7
|
+
* runs through the SAME pipeline as any mutation (guards, verified
|
|
8
|
+
* read-after-write, audit), so a concurrently-edited target surfaces as a
|
|
9
|
+
* blocked/verify-failed result, never a blind overwrite.
|
|
10
|
+
*
|
|
11
|
+
* Honesty rules:
|
|
12
|
+
* - Ops with no validated inverse surface are reported IRREVERSIBLE with
|
|
13
|
+
* the reason (permanent deletes, empty-trash, project→no-area — unprobed).
|
|
14
|
+
* - Partial inversions carry notes (todo.delete undo restores to the Inbox
|
|
15
|
+
* de-scheduled; checklist per-item state is unrecoverable).
|
|
16
|
+
* - Inverse mutations are audited under an `undo:`-prefixed actor and are
|
|
17
|
+
* themselves EXCLUDED from later undo target selection (no undo-the-undo).
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { localToday } from "../model/dates.js";
|
|
22
|
+
import { runMutation } from "./pipeline.js";
|
|
23
|
+
import { runReorder } from "./reorder.js";
|
|
24
|
+
// -------------------------------------------------------------- audit reads
|
|
25
|
+
/** Parse every audit record from the monthly JSONL files, oldest first. */
|
|
26
|
+
export function readAuditRecords(dir) {
|
|
27
|
+
let files;
|
|
28
|
+
try {
|
|
29
|
+
files = readdirSync(dir)
|
|
30
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
31
|
+
.toSorted();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const records = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = readFileSync(join(dir, file), "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
for (const line of raw.split("\n")) {
|
|
46
|
+
if (line.trim() === "")
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(line);
|
|
50
|
+
if (parsed.v === 1 && typeof parsed.op === "string")
|
|
51
|
+
records.push(parsed);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// tolerate a torn/corrupt line — audit files are append-only
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return records.toSorted((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The last N undoable targets, NEWEST FIRST (undo unwinds a stack). Only
|
|
62
|
+
* successful mutations qualify; inverse mutations (actor `undo:…`) never do.
|
|
63
|
+
*/
|
|
64
|
+
export function selectUndoTargets(records, last) {
|
|
65
|
+
return records
|
|
66
|
+
.filter((r) => r.result === "ok" && !r.actor.startsWith("undo:"))
|
|
67
|
+
.slice(-Math.max(1, last))
|
|
68
|
+
.toReversed();
|
|
69
|
+
}
|
|
70
|
+
// -------------------------------------------------------------- plan builder
|
|
71
|
+
const IRREVERSIBLE = {
|
|
72
|
+
"area.delete": "areas are deleted permanently — there is nothing to restore (A25)",
|
|
73
|
+
"tag.delete": "tags are deleted permanently — assignments already cascaded (A26)",
|
|
74
|
+
"trash.empty": "emptying the Trash hard-deletes every row — nothing to restore (A27)",
|
|
75
|
+
};
|
|
76
|
+
function preField(record, field) {
|
|
77
|
+
return record.pre === null ? undefined : record.pre[field];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Reconstruct the scheduling step that restores a to-do's pre-op placement
|
|
81
|
+
* from the captured pre-values (start / startDate / todaySection / reminder).
|
|
82
|
+
*/
|
|
83
|
+
function scheduleSteps(uuid, record, todayIso, op = "todo.update") {
|
|
84
|
+
const notes = [];
|
|
85
|
+
const start = preField(record, "start");
|
|
86
|
+
const startDate = preField(record, "startDate");
|
|
87
|
+
const section = preField(record, "todaySection");
|
|
88
|
+
const reminder = preField(record, "reminder");
|
|
89
|
+
// start === "inbox" is a to-do-only pre-state (projects never live in the
|
|
90
|
+
// Inbox), so the move step below stays a todo.move.
|
|
91
|
+
if (start === "inbox") {
|
|
92
|
+
if (reminder !== undefined && reminder !== null) {
|
|
93
|
+
notes.push("the pre-state carried a reminder in the Inbox — not reproducible, skipped");
|
|
94
|
+
}
|
|
95
|
+
return { steps: [{ op: "todo.move", params: { uuid, inbox: true } }], notes };
|
|
96
|
+
}
|
|
97
|
+
let when = null;
|
|
98
|
+
if (typeof startDate === "string") {
|
|
99
|
+
// startDate == today maps back to the today/evening keywords (which also
|
|
100
|
+
// keep the reminder-clear path open); other dates restore literally.
|
|
101
|
+
if (startDate === todayIso)
|
|
102
|
+
when = section === "evening" ? "evening" : "today";
|
|
103
|
+
else {
|
|
104
|
+
when = startDate;
|
|
105
|
+
if (section === "evening") {
|
|
106
|
+
notes.push("the item was a STALE evening entry (past startDate) — the date is restored but " +
|
|
107
|
+
"the evening bucket cannot be (bucket placement is today-only)");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (start === "someday") {
|
|
112
|
+
when = "someday";
|
|
113
|
+
}
|
|
114
|
+
else if (start === "active" && startDate === null) {
|
|
115
|
+
when = "anytime";
|
|
116
|
+
}
|
|
117
|
+
if (when === null) {
|
|
118
|
+
if (start !== undefined || startDate !== undefined) {
|
|
119
|
+
notes.push("pre-op scheduling state was not captured fully — when/schedule not restored");
|
|
120
|
+
}
|
|
121
|
+
return { steps: [], notes };
|
|
122
|
+
}
|
|
123
|
+
const params = { uuid, when };
|
|
124
|
+
if (reminder !== undefined) {
|
|
125
|
+
if (reminder === null) {
|
|
126
|
+
// The undone op SET a reminder where none was: clear it. Only
|
|
127
|
+
// today/evening have a clear path (R20/R21 — dated are sticky).
|
|
128
|
+
if (when === "today" || when === "evening")
|
|
129
|
+
params["reminder"] = null;
|
|
130
|
+
else {
|
|
131
|
+
notes.push("cannot clear the reminder the undone op set: dated reminders are sticky " +
|
|
132
|
+
"(R20/R21) — clear via `--when today --clear-reminder`, then re-date");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
params["reminder"] = reminder;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { steps: [{ op, params }], notes };
|
|
140
|
+
}
|
|
141
|
+
/** Build the inverse plan for one audit record. Pure — unit-testable. */
|
|
142
|
+
export function planUndo(record, now) {
|
|
143
|
+
const target = { ts: record.ts, op: record.op, uuid: record.uuid, actor: record.actor };
|
|
144
|
+
const todayIso = localToday(now);
|
|
145
|
+
const notes = [];
|
|
146
|
+
const irreversible = (reason) => ({
|
|
147
|
+
target,
|
|
148
|
+
kind: "irreversible",
|
|
149
|
+
steps: [],
|
|
150
|
+
notes,
|
|
151
|
+
reason,
|
|
152
|
+
});
|
|
153
|
+
const fixed = IRREVERSIBLE[record.op];
|
|
154
|
+
if (fixed !== undefined)
|
|
155
|
+
return irreversible(fixed);
|
|
156
|
+
const uuid = record.uuid;
|
|
157
|
+
switch (record.op) {
|
|
158
|
+
// Creations: the inverse is deleting what appeared. To-dos/projects go
|
|
159
|
+
// to the Trash (restorable); areas/tags delete PERMANENTLY (ack needed).
|
|
160
|
+
case "todo.add":
|
|
161
|
+
case "todo.add-logged":
|
|
162
|
+
case "todo.duplicate": {
|
|
163
|
+
if (uuid === null)
|
|
164
|
+
return irreversible("the created uuid was never discovered");
|
|
165
|
+
return {
|
|
166
|
+
target,
|
|
167
|
+
kind: "invertible",
|
|
168
|
+
steps: [{ op: "todo.delete", params: { uuid } }],
|
|
169
|
+
notes,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case "project.add":
|
|
173
|
+
case "project.duplicate": {
|
|
174
|
+
if (uuid === null)
|
|
175
|
+
return irreversible("the created uuid was never discovered");
|
|
176
|
+
notes.push("the project (and any children it carried) moves to the Trash");
|
|
177
|
+
return {
|
|
178
|
+
target,
|
|
179
|
+
kind: "invertible",
|
|
180
|
+
steps: [{ op: "project.delete", params: { uuid } }],
|
|
181
|
+
notes,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
case "area.add": {
|
|
185
|
+
if (uuid === null)
|
|
186
|
+
return irreversible("the created uuid was never discovered");
|
|
187
|
+
notes.push("area deletion is PERMANENT — requires --dangerously-permanent");
|
|
188
|
+
return {
|
|
189
|
+
target,
|
|
190
|
+
kind: "invertible",
|
|
191
|
+
steps: [
|
|
192
|
+
{ op: "area.delete", params: { target: uuid }, options: { dangerouslyPermanent: true } },
|
|
193
|
+
],
|
|
194
|
+
notes,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
case "tag.add": {
|
|
198
|
+
if (uuid === null)
|
|
199
|
+
return irreversible("the created uuid was never discovered");
|
|
200
|
+
notes.push("tag deletion is PERMANENT — requires --dangerously-permanent");
|
|
201
|
+
return {
|
|
202
|
+
target,
|
|
203
|
+
kind: "invertible",
|
|
204
|
+
steps: [
|
|
205
|
+
{ op: "tag.delete", params: { target: uuid }, options: { dangerouslyPermanent: true } },
|
|
206
|
+
],
|
|
207
|
+
notes,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Status flips: the pre status says exactly where to go back to.
|
|
211
|
+
case "todo.complete":
|
|
212
|
+
case "todo.cancel": {
|
|
213
|
+
if (uuid === null)
|
|
214
|
+
return irreversible("no target uuid recorded");
|
|
215
|
+
if (preField(record, "status") !== "open") {
|
|
216
|
+
return irreversible("the to-do was not open before the op — nothing to restore");
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
target,
|
|
220
|
+
kind: "invertible",
|
|
221
|
+
steps: [{ op: "todo.reopen", params: { uuid } }],
|
|
222
|
+
notes,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
case "todo.reopen": {
|
|
226
|
+
if (uuid === null)
|
|
227
|
+
return irreversible("no target uuid recorded");
|
|
228
|
+
const was = preField(record, "status");
|
|
229
|
+
if (was !== "completed" && was !== "canceled") {
|
|
230
|
+
return irreversible("the pre-op status was not captured — cannot restore");
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
target,
|
|
234
|
+
kind: "invertible",
|
|
235
|
+
steps: [{ op: was === "completed" ? "todo.complete" : "todo.cancel", params: { uuid } }],
|
|
236
|
+
notes,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
case "todo.delete": {
|
|
240
|
+
if (uuid === null)
|
|
241
|
+
return irreversible("no target uuid recorded");
|
|
242
|
+
notes.push("restore lands in the Inbox DE-SCHEDULED (E15) — the prior list/schedule was not " +
|
|
243
|
+
"captured by the delete");
|
|
244
|
+
return {
|
|
245
|
+
target,
|
|
246
|
+
kind: "invertible",
|
|
247
|
+
steps: [{ op: "todo.restore", params: { uuid } }],
|
|
248
|
+
notes,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
case "todo.restore": {
|
|
252
|
+
if (uuid === null)
|
|
253
|
+
return irreversible("no target uuid recorded");
|
|
254
|
+
return {
|
|
255
|
+
target,
|
|
256
|
+
kind: "invertible",
|
|
257
|
+
steps: [{ op: "todo.delete", params: { uuid } }],
|
|
258
|
+
notes,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
case "project.delete": {
|
|
262
|
+
if (uuid === null)
|
|
263
|
+
return irreversible("no target uuid recorded");
|
|
264
|
+
notes.push("restored IN PLACE (P06) — schedule/area/children keep their state");
|
|
265
|
+
return {
|
|
266
|
+
target,
|
|
267
|
+
kind: "invertible",
|
|
268
|
+
steps: [{ op: "project.restore", params: { uuid } }],
|
|
269
|
+
notes,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// Project completion/cancellation: reopen the project (P02/P05), then
|
|
273
|
+
// reopen exactly the children the cascade resolved — the audit captured
|
|
274
|
+
// their pre-op statuses (nested pre map when a cascade was asserted).
|
|
275
|
+
case "project.complete":
|
|
276
|
+
case "project.cancel": {
|
|
277
|
+
if (uuid === null)
|
|
278
|
+
return irreversible("no target uuid recorded");
|
|
279
|
+
const steps = [{ op: "project.reopen", params: { uuid } }];
|
|
280
|
+
const pre = record.pre ?? {};
|
|
281
|
+
if (!("status" in pre)) {
|
|
282
|
+
for (const [childUuid, fields] of Object.entries(pre)) {
|
|
283
|
+
if (childUuid === uuid)
|
|
284
|
+
continue;
|
|
285
|
+
if (typeof fields === "object" &&
|
|
286
|
+
fields !== null &&
|
|
287
|
+
fields["status"] === "open") {
|
|
288
|
+
steps.push({ op: "todo.reopen", params: { uuid: childUuid } });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (steps.length > 1) {
|
|
293
|
+
notes.push(`${steps.length - 1} cascade-resolved child(ren) will be reopened too`);
|
|
294
|
+
}
|
|
295
|
+
return { target, kind: "invertible", steps, notes };
|
|
296
|
+
}
|
|
297
|
+
case "project.reopen": {
|
|
298
|
+
if (uuid === null)
|
|
299
|
+
return irreversible("no target uuid recorded");
|
|
300
|
+
const was = preField(record, "status");
|
|
301
|
+
if (was !== "completed" && was !== "canceled") {
|
|
302
|
+
return irreversible("the pre-op project status was not captured — cannot restore");
|
|
303
|
+
}
|
|
304
|
+
notes.push("children were untouched by the reopen; require-resolved will block if any were " +
|
|
305
|
+
"reopened since — resolve them or redo manually");
|
|
306
|
+
return {
|
|
307
|
+
target,
|
|
308
|
+
kind: "invertible",
|
|
309
|
+
steps: [
|
|
310
|
+
was === "completed"
|
|
311
|
+
? { op: "project.complete", params: { uuid, children: "require-resolved" } }
|
|
312
|
+
: { op: "project.cancel", params: { uuid, children: "require-resolved" } },
|
|
313
|
+
],
|
|
314
|
+
notes,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
case "project.restore": {
|
|
318
|
+
if (uuid === null)
|
|
319
|
+
return irreversible("no target uuid recorded");
|
|
320
|
+
return {
|
|
321
|
+
target,
|
|
322
|
+
kind: "invertible",
|
|
323
|
+
steps: [{ op: "project.delete", params: { uuid } }],
|
|
324
|
+
notes,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
case "todo.update": {
|
|
328
|
+
if (uuid === null)
|
|
329
|
+
return irreversible("no target uuid recorded");
|
|
330
|
+
const steps = [];
|
|
331
|
+
const params = { uuid };
|
|
332
|
+
const title = preField(record, "title");
|
|
333
|
+
const notesPre = preField(record, "notes");
|
|
334
|
+
const deadline = preField(record, "deadline");
|
|
335
|
+
if (title !== undefined)
|
|
336
|
+
params["title"] = title;
|
|
337
|
+
if (notesPre !== undefined)
|
|
338
|
+
params["notes"] = notesPre; // covers append/prepend too
|
|
339
|
+
if (deadline !== undefined)
|
|
340
|
+
params["deadline"] = deadline;
|
|
341
|
+
const requestedWhen = (record.requested["when"] ?? record.requested["reminder"]) !== undefined;
|
|
342
|
+
if (requestedWhen) {
|
|
343
|
+
const schedule = scheduleSteps(uuid, record, todayIso);
|
|
344
|
+
notes.push(...schedule.notes);
|
|
345
|
+
const scheduleStep = schedule.steps[0];
|
|
346
|
+
if (scheduleStep !== undefined && scheduleStep.op === "todo.update") {
|
|
347
|
+
Object.assign(params, scheduleStep.params);
|
|
348
|
+
}
|
|
349
|
+
else if (scheduleStep !== undefined) {
|
|
350
|
+
steps.push(scheduleStep); // inbox restore is a separate move op
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (Object.keys(params).length > 1)
|
|
354
|
+
steps.unshift({ op: "todo.update", params });
|
|
355
|
+
if (steps.length === 0) {
|
|
356
|
+
return irreversible("no pre-values were captured for the changed fields");
|
|
357
|
+
}
|
|
358
|
+
return { target, kind: "invertible", steps, notes };
|
|
359
|
+
}
|
|
360
|
+
case "project.update": {
|
|
361
|
+
if (uuid === null)
|
|
362
|
+
return irreversible("no target uuid recorded");
|
|
363
|
+
const params = { uuid };
|
|
364
|
+
const title = preField(record, "title");
|
|
365
|
+
const notesPre = preField(record, "notes");
|
|
366
|
+
const deadline = preField(record, "deadline");
|
|
367
|
+
if (title !== undefined)
|
|
368
|
+
params["title"] = title;
|
|
369
|
+
if (notesPre !== undefined)
|
|
370
|
+
params["notes"] = notesPre;
|
|
371
|
+
if (deadline !== undefined)
|
|
372
|
+
params["deadline"] = deadline;
|
|
373
|
+
// when/reminder restore reuses the schedule reconstructor (emitting a
|
|
374
|
+
// project.update); projects never live in the Inbox so that branch is
|
|
375
|
+
// unreachable here.
|
|
376
|
+
if ((record.requested["when"] ?? record.requested["reminder"]) !== undefined) {
|
|
377
|
+
const schedule = scheduleSteps(uuid, record, todayIso, "project.update");
|
|
378
|
+
notes.push(...schedule.notes);
|
|
379
|
+
const scheduleStep = schedule.steps[0];
|
|
380
|
+
if (scheduleStep !== undefined)
|
|
381
|
+
Object.assign(params, scheduleStep.params);
|
|
382
|
+
}
|
|
383
|
+
if (Object.keys(params).length === 1) {
|
|
384
|
+
return irreversible("no pre-values were captured for the changed fields");
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
target,
|
|
388
|
+
kind: "invertible",
|
|
389
|
+
steps: [{ op: "project.update", params }],
|
|
390
|
+
notes,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
case "project.set-tags": {
|
|
394
|
+
if (uuid === null)
|
|
395
|
+
return irreversible("no target uuid recorded");
|
|
396
|
+
const tags = preField(record, "tags");
|
|
397
|
+
if (!Array.isArray(tags))
|
|
398
|
+
return irreversible("the pre-op tag set was not captured");
|
|
399
|
+
return {
|
|
400
|
+
target,
|
|
401
|
+
kind: "invertible",
|
|
402
|
+
steps: [{ op: "project.set-tags", params: { uuid, tags } }],
|
|
403
|
+
notes,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
case "todo.move": {
|
|
407
|
+
if (uuid === null)
|
|
408
|
+
return irreversible("no target uuid recorded");
|
|
409
|
+
if (record.requested["detach"] === true) {
|
|
410
|
+
// The detach delta captured the old container Refs directly.
|
|
411
|
+
const projRef = preField(record, "project");
|
|
412
|
+
const areaRef = preField(record, "area");
|
|
413
|
+
const oldProj = typeof projRef === "object" && projRef !== null
|
|
414
|
+
? projRef.uuid
|
|
415
|
+
: undefined;
|
|
416
|
+
const oldArea = typeof areaRef === "object" && areaRef !== null
|
|
417
|
+
? areaRef.uuid
|
|
418
|
+
: undefined;
|
|
419
|
+
if (preField(record, "heading") !== null && preField(record, "heading") !== undefined) {
|
|
420
|
+
notes.push("heading placement cannot be restored — the to-do returns to the project root");
|
|
421
|
+
}
|
|
422
|
+
if (typeof oldProj === "string") {
|
|
423
|
+
return {
|
|
424
|
+
target,
|
|
425
|
+
kind: "invertible",
|
|
426
|
+
steps: [{ op: "todo.move", params: { uuid, project: { uuid: oldProj } } }],
|
|
427
|
+
notes,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (typeof oldArea === "string") {
|
|
431
|
+
return {
|
|
432
|
+
target,
|
|
433
|
+
kind: "invertible",
|
|
434
|
+
steps: [{ op: "todo.move", params: { uuid, area: { uuid: oldArea } } }],
|
|
435
|
+
notes,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return irreversible("the to-do had no container before the detach — nothing to restore");
|
|
439
|
+
}
|
|
440
|
+
// A move-to-inbox op asserted start/startDate instead of containers.
|
|
441
|
+
if (record.requested["inbox"] === true) {
|
|
442
|
+
const schedule = scheduleSteps(uuid, record, todayIso);
|
|
443
|
+
notes.push(...schedule.notes);
|
|
444
|
+
notes.push("the pre-op project/area link was not captured by the inbox move — not restored");
|
|
445
|
+
if (schedule.steps.length === 0) {
|
|
446
|
+
return irreversible("pre-op scheduling state was not captured — cannot leave the Inbox");
|
|
447
|
+
}
|
|
448
|
+
return { target, kind: "invertible", steps: schedule.steps, notes };
|
|
449
|
+
}
|
|
450
|
+
// The audit captured the OLD value of whatever destination-kind fields
|
|
451
|
+
// the move asserted: "project.uuid"/"heading.uuid" (project moves),
|
|
452
|
+
// "area.uuid" + "project" as a Ref (area moves — A22B clears the link).
|
|
453
|
+
const projectRef = preField(record, "project");
|
|
454
|
+
const oldProject = preField(record, "project.uuid") ??
|
|
455
|
+
(typeof projectRef === "object" && projectRef !== null
|
|
456
|
+
? projectRef.uuid
|
|
457
|
+
: undefined);
|
|
458
|
+
const oldArea = preField(record, "area.uuid");
|
|
459
|
+
if (typeof oldProject === "string") {
|
|
460
|
+
if (preField(record, "heading.uuid") !== undefined) {
|
|
461
|
+
notes.push("heading placement cannot be restored (no heading-move surface) — " +
|
|
462
|
+
"the to-do returns to the project root");
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
target,
|
|
466
|
+
kind: "invertible",
|
|
467
|
+
steps: [{ op: "todo.move", params: { uuid, project: { uuid: oldProject } } }],
|
|
468
|
+
notes,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (typeof oldArea === "string") {
|
|
472
|
+
return {
|
|
473
|
+
target,
|
|
474
|
+
kind: "invertible",
|
|
475
|
+
steps: [{ op: "todo.move", params: { uuid, area: { uuid: oldArea } } }],
|
|
476
|
+
notes,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
// The captured fields say only "no <destination-kind> before" — the
|
|
480
|
+
// real prior container (a different kind, or none) was never recorded.
|
|
481
|
+
return irreversible("the pre-op container was not fully captured (only the destination-kind field is " +
|
|
482
|
+
"audited) — move it back manually");
|
|
483
|
+
}
|
|
484
|
+
case "project.move": {
|
|
485
|
+
if (uuid === null)
|
|
486
|
+
return irreversible("no target uuid recorded");
|
|
487
|
+
if (record.requested["detach"] === true) {
|
|
488
|
+
const areaRef = preField(record, "area");
|
|
489
|
+
const oldArea = typeof areaRef === "object" && areaRef !== null
|
|
490
|
+
? areaRef.uuid
|
|
491
|
+
: undefined;
|
|
492
|
+
if (typeof oldArea === "string") {
|
|
493
|
+
return {
|
|
494
|
+
target,
|
|
495
|
+
kind: "invertible",
|
|
496
|
+
steps: [{ op: "project.move", params: { uuid, area: { uuid: oldArea } } }],
|
|
497
|
+
notes,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
return irreversible("the project had no area before the detach — nothing to restore");
|
|
501
|
+
}
|
|
502
|
+
const areaPre = preField(record, "area.uuid");
|
|
503
|
+
if (typeof areaPre === "string") {
|
|
504
|
+
return {
|
|
505
|
+
target,
|
|
506
|
+
kind: "invertible",
|
|
507
|
+
steps: [{ op: "project.move", params: { uuid, area: { uuid: areaPre } } }],
|
|
508
|
+
notes,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (areaPre === null) {
|
|
512
|
+
// The project had no area before: detach restores that state (P24).
|
|
513
|
+
return {
|
|
514
|
+
target,
|
|
515
|
+
kind: "invertible",
|
|
516
|
+
steps: [{ op: "project.move", params: { uuid, detach: true } }],
|
|
517
|
+
notes,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return irreversible("the pre-op area was not captured");
|
|
521
|
+
}
|
|
522
|
+
case "todo.backdate": {
|
|
523
|
+
if (uuid === null)
|
|
524
|
+
return irreversible("no target uuid recorded");
|
|
525
|
+
const patch = { uuid };
|
|
526
|
+
const stoppedPre = preField(record, "stoppedDate");
|
|
527
|
+
const createdPre = preField(record, "createdDate");
|
|
528
|
+
if (typeof stoppedPre === "string")
|
|
529
|
+
patch["completionDate"] = stoppedPre;
|
|
530
|
+
if (typeof createdPre === "string")
|
|
531
|
+
patch["creationDate"] = createdPre;
|
|
532
|
+
if (patch["completionDate"] === undefined && patch["creationDate"] === undefined) {
|
|
533
|
+
return irreversible("the pre-op timestamps were not captured");
|
|
534
|
+
}
|
|
535
|
+
notes.push("timestamps restore at DAY precision (noon local) — the original sub-day time is " +
|
|
536
|
+
"not recoverable");
|
|
537
|
+
return { target, kind: "invertible", steps: [{ op: "todo.backdate", params: patch }], notes };
|
|
538
|
+
}
|
|
539
|
+
case "todo.set-tags": {
|
|
540
|
+
if (uuid === null)
|
|
541
|
+
return irreversible("no target uuid recorded");
|
|
542
|
+
const tags = preField(record, "tags");
|
|
543
|
+
if (!Array.isArray(tags))
|
|
544
|
+
return irreversible("the pre-op tag set was not captured");
|
|
545
|
+
return {
|
|
546
|
+
target,
|
|
547
|
+
kind: "invertible",
|
|
548
|
+
steps: [{ op: "todo.set-tags", params: { uuid, tags } }],
|
|
549
|
+
notes,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
case "todo.replace-checklist": {
|
|
553
|
+
if (uuid === null)
|
|
554
|
+
return irreversible("no target uuid recorded");
|
|
555
|
+
const items = preField(record, "checklistTitles");
|
|
556
|
+
if (!Array.isArray(items))
|
|
557
|
+
return irreversible("the pre-op checklist was not captured");
|
|
558
|
+
notes.push("checklist TITLES are restored; per-item completion state is unrecoverable (T07)");
|
|
559
|
+
return {
|
|
560
|
+
target,
|
|
561
|
+
kind: "invertible",
|
|
562
|
+
steps: [
|
|
563
|
+
{
|
|
564
|
+
op: "todo.replace-checklist",
|
|
565
|
+
params: { uuid, items },
|
|
566
|
+
options: { acknowledgeChecklistReset: true },
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
notes,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
case "area.update": {
|
|
573
|
+
if (uuid === null)
|
|
574
|
+
return irreversible("no target uuid recorded");
|
|
575
|
+
const patch = { target: uuid };
|
|
576
|
+
const title = preField(record, "title");
|
|
577
|
+
const tags = preField(record, "tags");
|
|
578
|
+
if (title !== undefined)
|
|
579
|
+
patch["title"] = title;
|
|
580
|
+
if (Array.isArray(tags))
|
|
581
|
+
patch["tags"] = tags;
|
|
582
|
+
if (Object.keys(patch).length === 1)
|
|
583
|
+
return irreversible("no pre-values were captured");
|
|
584
|
+
return { target, kind: "invertible", steps: [{ op: "area.update", params: patch }], notes };
|
|
585
|
+
}
|
|
586
|
+
case "tag.update": {
|
|
587
|
+
if (uuid === null)
|
|
588
|
+
return irreversible("no target uuid recorded");
|
|
589
|
+
const patch = { target: uuid };
|
|
590
|
+
const title = preField(record, "title");
|
|
591
|
+
const parent = preField(record, "parent");
|
|
592
|
+
const shortcut = preField(record, "shortcut");
|
|
593
|
+
if (title !== undefined)
|
|
594
|
+
patch["title"] = title;
|
|
595
|
+
if (typeof parent === "string" && parent !== "")
|
|
596
|
+
patch["parent"] = parent;
|
|
597
|
+
else if (parent === null && record.requested["parent"] !== undefined) {
|
|
598
|
+
// The op nested a previously-root tag: un-nest it back (P29).
|
|
599
|
+
patch["unnest"] = true;
|
|
600
|
+
}
|
|
601
|
+
// A captured pre-shortcut (string) re-binds whether the op changed or
|
|
602
|
+
// cleared it; a null pre-shortcut with the op having SET one inverts to
|
|
603
|
+
// a clear (A4 gave us the clear path).
|
|
604
|
+
if (typeof shortcut === "string")
|
|
605
|
+
patch["shortcut"] = shortcut;
|
|
606
|
+
else if (shortcut === null && record.requested["shortcut"] !== undefined) {
|
|
607
|
+
patch["clearShortcut"] = true;
|
|
608
|
+
}
|
|
609
|
+
if (Object.keys(patch).length === 1) {
|
|
610
|
+
return irreversible("none of the changed tag fields can be restored (see notes)");
|
|
611
|
+
}
|
|
612
|
+
return { target, kind: "invertible", steps: [{ op: "tag.update", params: patch }], notes };
|
|
613
|
+
}
|
|
614
|
+
case "reorder": {
|
|
615
|
+
const pre = record.pre;
|
|
616
|
+
if (pre === null) {
|
|
617
|
+
return irreversible("bounce reorders record no pre-ranks in their summary — undo their individual " +
|
|
618
|
+
"when= legs instead (separate audit records)");
|
|
619
|
+
}
|
|
620
|
+
const requested = record.requested;
|
|
621
|
+
const ranked = Object.entries(pre).filter(([, rank]) => typeof rank === "number");
|
|
622
|
+
if (ranked.length === 0)
|
|
623
|
+
return irreversible("no pre-ranks were captured");
|
|
624
|
+
const uuids = ranked.toSorted((a, b) => a[1] - b[1]).map(([id]) => id);
|
|
625
|
+
const params = { scope: requested.scope, uuids };
|
|
626
|
+
if (requested.container !== undefined)
|
|
627
|
+
params["container"] = requested.container;
|
|
628
|
+
notes.push("the requested uuids return to their pre-op relative order; other members keep " +
|
|
629
|
+
"their current positions");
|
|
630
|
+
return { target, kind: "invertible", steps: [{ op: "reorder", params }], notes };
|
|
631
|
+
}
|
|
632
|
+
default:
|
|
633
|
+
return irreversible(`no inverse is defined for operation "${record.op}"`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// ----------------------------------------------------------------- executor
|
|
637
|
+
export async function runUndo(deps, auditDirPath, options = {}, onItem) {
|
|
638
|
+
const records = readAuditRecords(auditDirPath);
|
|
639
|
+
const targets = selectUndoTargets(records, options.last ?? 1);
|
|
640
|
+
const now = deps.now?.() ?? new Date();
|
|
641
|
+
const items = [];
|
|
642
|
+
for (const record of targets) {
|
|
643
|
+
const plan = planUndo(record, now);
|
|
644
|
+
let item;
|
|
645
|
+
if (plan.kind === "irreversible") {
|
|
646
|
+
item = { plan, results: [], outcome: "irreversible" };
|
|
647
|
+
}
|
|
648
|
+
else if (options.dryRun === true) {
|
|
649
|
+
item = { plan, results: [], outcome: "dry-run" };
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
const results = [];
|
|
653
|
+
let failed = false;
|
|
654
|
+
for (const step of plan.steps) {
|
|
655
|
+
const needsPermanent = step.options?.dangerouslyPermanent === true;
|
|
656
|
+
if (needsPermanent && options.dangerouslyPermanent !== true) {
|
|
657
|
+
results.push({
|
|
658
|
+
kind: "blocked",
|
|
659
|
+
op: step.op,
|
|
660
|
+
reason: "hazard",
|
|
661
|
+
hazard: "H-PERMANENT-DELETE",
|
|
662
|
+
detail: `undoing this ${record.op} deletes the created entity PERMANENTLY`,
|
|
663
|
+
remediation: "re-run with --dangerously-permanent",
|
|
664
|
+
});
|
|
665
|
+
failed = true;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
const writeOptions = {
|
|
669
|
+
actor: `undo:${options.actor ?? deps.config.actor}`,
|
|
670
|
+
...(options.verifyTimeoutMs !== undefined && {
|
|
671
|
+
verifyTimeoutMs: options.verifyTimeoutMs,
|
|
672
|
+
}),
|
|
673
|
+
...(step.options?.acknowledgeChecklistReset === true && {
|
|
674
|
+
acknowledgeChecklistReset: true,
|
|
675
|
+
}),
|
|
676
|
+
...(needsPermanent && { dangerouslyPermanent: true }),
|
|
677
|
+
};
|
|
678
|
+
const result = step.op === "reorder"
|
|
679
|
+
? await runReorder(deps, step.params, writeOptions)
|
|
680
|
+
: await runMutation(deps, step.op, step.params, writeOptions);
|
|
681
|
+
results.push(result);
|
|
682
|
+
if (result.kind !== "ok") {
|
|
683
|
+
failed = true;
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const okCount = results.filter((r) => r.kind === "ok").length;
|
|
688
|
+
item = {
|
|
689
|
+
plan,
|
|
690
|
+
results,
|
|
691
|
+
outcome: failed ? (okCount > 0 ? "partial" : "failed") : "ok",
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
items.push(item);
|
|
695
|
+
onItem?.(item);
|
|
696
|
+
// A failed inverse means the state no longer matches the audit trail —
|
|
697
|
+
// unwinding further would compound the divergence.
|
|
698
|
+
if (item.outcome === "failed" || item.outcome === "partial")
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
return items;
|
|
702
|
+
}
|
|
703
|
+
//# sourceMappingURL=undo.js.map
|