@gamflaz/opencode-todolist 1.0.12 → 1.1.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.
@@ -0,0 +1,278 @@
1
+ import { readFileSync, readdirSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { asc, eq } from "drizzle-orm";
4
+ import { TodoListSchema } from "../schemas/todo.js";
5
+ import { InvalidTodoDataError } from "./errors.js";
6
+ import { todoItem, todoSession } from "./schema.js";
7
+ const FILE_PREFIX = "todos-";
8
+ const FILE_SUFFIX = ".json";
9
+ const MIGRATED_SUFFIX = ".json.migrated";
10
+ /** `todos-<ts>.json` holds the payload; `version` holds the counter. */
11
+ export function readLegacyVersion(sessionDir) {
12
+ for (const candidate of [
13
+ join(sessionDir, "version"),
14
+ join(sessionDir, "todos", "version"),
15
+ ]) {
16
+ try {
17
+ const value = Number.parseInt(readFileSync(candidate, "utf8").trim(), 10);
18
+ if (Number.isSafeInteger(value) && value >= 0)
19
+ return value;
20
+ }
21
+ catch {
22
+ // try the next candidate
23
+ }
24
+ }
25
+ return 0;
26
+ }
27
+ /** Newest payload file wins, matching the old "latest wins" behaviour. */
28
+ export function findLegacyTodoFile(sessionDir) {
29
+ for (const dir of [join(sessionDir, "todos"), sessionDir]) {
30
+ let names;
31
+ try {
32
+ names = readdirSync(dir);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ const newest = names
38
+ .filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX))
39
+ .map((name) => ({
40
+ path: join(dir, name),
41
+ timestamp: Number(name.slice(FILE_PREFIX.length, -FILE_SUFFIX.length)),
42
+ }))
43
+ .filter((entry) => Number.isSafeInteger(entry.timestamp) && entry.timestamp >= 0)
44
+ .sort((left, right) => right.timestamp - left.timestamp)[0];
45
+ if (newest)
46
+ return newest.path;
47
+ }
48
+ return undefined;
49
+ }
50
+ /** Parses a legacy payload, accepting the wrapped object and the bare array. */
51
+ export function parseLegacyTodos(raw) {
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ }
56
+ catch (error) {
57
+ throw new InvalidTodoDataError(`[todo store read failed] invalid todo data: ${error instanceof Error ? error.message : String(error)}`);
58
+ }
59
+ const list = Array.isArray(parsed)
60
+ ? parsed
61
+ : typeof parsed === "object" && parsed !== null && "todos" in parsed
62
+ ? parsed.todos
63
+ : undefined;
64
+ if (!Array.isArray(list)) {
65
+ throw new InvalidTodoDataError("[todo store read failed] invalid todo data: todo list is missing");
66
+ }
67
+ const result = TodoListSchema.safeParse(list);
68
+ if (!result.success) {
69
+ throw new InvalidTodoDataError(`[todo store read failed] invalid todo data: ${result.error.issues
70
+ .map((issue) => issue.message)
71
+ .join(", ")}`);
72
+ }
73
+ return result.data;
74
+ }
75
+ /**
76
+ * Replaces a session's whole list in one transaction.
77
+ *
78
+ * Used by both the importer and every normal write, so ordering (`position`)
79
+ * and atomicity are identical everywhere.
80
+ *
81
+ * `behavior: "immediate"` takes the write lock before reading, so two
82
+ * processes cannot both observe an old revision and then write rows that
83
+ * conflict. Combined with `busy_timeout` the second writer waits instead of
84
+ * failing.
85
+ */
86
+ export function replaceSessionTodos(handle, sessionKey, todos) {
87
+ return handle.db.transaction((tx) => writeSessionTodos(tx, sessionKey, todos), { behavior: "immediate" });
88
+ }
89
+ /** Reads a session's revision, or 0 when the session has never been written. */
90
+ export function readRevision(handle, sessionKey) {
91
+ const row = handle.db
92
+ .select({ revision: todoSession.revision })
93
+ .from(todoSession)
94
+ .where(eq(todoSession.sessionKey, sessionKey))
95
+ .get();
96
+ return row?.revision ?? 0;
97
+ }
98
+ /**
99
+ * Read-modify-write under one `IMMEDIATE` transaction.
100
+ *
101
+ * Every write goes through here so the read, the caller's transform, and the
102
+ * replacement cannot interleave with another process's write. Reading outside
103
+ * the transaction and writing inside it would allow a lost update: two
104
+ * processes could each read revision N, then both write revision N+1, and one
105
+ * change would silently disappear.
106
+ *
107
+ * The transform receives the current list and returns the next one; validation
108
+ * happens inside the transaction so an invalid result rolls the whole thing
109
+ * back without bumping the revision.
110
+ */
111
+ export function mutateSessionTodos(handle, sessionKey, transform) {
112
+ return handle.db.transaction((tx) => {
113
+ const todos = selectTodos(tx, sessionKey);
114
+ const next = transform(todos);
115
+ const revision = writeSessionTodos(tx, sessionKey, next);
116
+ return { todos: next, revision };
117
+ }, { behavior: "immediate" });
118
+ }
119
+ /** Reads a session's list in `position` order, rebuilt into the nested shape. */
120
+ export function selectTodos(executor, sessionKey) {
121
+ const rows = executor
122
+ .select()
123
+ .from(todoItem)
124
+ .where(eq(todoItem.sessionKey, sessionKey))
125
+ .orderBy(asc(todoItem.position))
126
+ .all();
127
+ const roots = new Map();
128
+ const order = [];
129
+ for (const row of rows) {
130
+ const base = {
131
+ id: Number(row.id),
132
+ content: row.content,
133
+ status: row.status,
134
+ ...(row.priority ? { priority: row.priority } : {}),
135
+ ...(row.note !== null && row.note !== undefined ? { note: row.note } : {}),
136
+ };
137
+ if (row.parentId === null) {
138
+ roots.set(row.id, base);
139
+ order.push(row.id);
140
+ continue;
141
+ }
142
+ const parent = roots.get(row.parentId);
143
+ if (!parent)
144
+ continue; // orphan; the foreign key prevents this
145
+ parent.children ??= [];
146
+ parent.children.push(base);
147
+ }
148
+ return order.map((id) => roots.get(id));
149
+ }
150
+ /**
151
+ * Writes the full list for a session inside a transaction, without opening one.
152
+ *
153
+ * Kept separate so `mutateSessionTodos` can compose it with a read in the same
154
+ * transaction.
155
+ */
156
+ export function writeSessionTodos(tx, sessionKey, todos) {
157
+ const now = new Date().toISOString();
158
+ const existing = tx
159
+ .select({
160
+ revision: todoSession.revision,
161
+ createdAt: todoSession.createdAt,
162
+ })
163
+ .from(todoSession)
164
+ .where(eq(todoSession.sessionKey, sessionKey))
165
+ .get();
166
+ const revision = (existing?.revision ?? 0) + 1;
167
+ const createdAt = existing?.createdAt ?? now;
168
+ // A clear keeps the session row and still bumps the revision: the row holds
169
+ // `revision` and `hint_revision`, so dropping it would reset the change
170
+ // counter a reader polls and could re-emit an already-delivered hint. Only
171
+ // the items are removed, which the delete below does for every write.
172
+ if (existing) {
173
+ tx.update(todoSession)
174
+ .set({ revision, updatedAt: now })
175
+ .where(eq(todoSession.sessionKey, sessionKey))
176
+ .run();
177
+ }
178
+ else {
179
+ tx.insert(todoSession)
180
+ .values({ sessionKey, revision, createdAt, updatedAt: now })
181
+ .run();
182
+ }
183
+ // Replace rather than diff: callers always supply the full list, and
184
+ // delete-then-insert keeps `position` dense with no extra bookkeeping.
185
+ tx.delete(todoItem).where(eq(todoItem.sessionKey, sessionKey)).run();
186
+ let position = 0;
187
+ const rows = todos.flatMap((root) => {
188
+ const rootRow = {
189
+ sessionKey,
190
+ id: String(root.id),
191
+ parentId: null,
192
+ content: root.content,
193
+ status: root.status,
194
+ priority: root.priority ?? null,
195
+ note: root.note ?? null,
196
+ position: position++,
197
+ };
198
+ const childRows = (root.children ?? []).map((child) => ({
199
+ sessionKey,
200
+ id: String(child.id),
201
+ parentId: String(root.id),
202
+ content: child.content,
203
+ status: child.status,
204
+ priority: child.priority ?? null,
205
+ note: child.note ?? null,
206
+ position: position++,
207
+ }));
208
+ return [rootRow, ...childRows];
209
+ });
210
+ // Roots precede their children so the composite self-reference resolves
211
+ // within the same transaction.
212
+ if (rows.length > 0)
213
+ tx.insert(todoItem).values(rows).run();
214
+ return revision;
215
+ }
216
+ /**
217
+ * Imports one session folder. Returns what happened so the caller can report
218
+ * it; throws only on an unexpected filesystem error.
219
+ */
220
+ function importSession(handle, sessionKey, sessionDir) {
221
+ const file = findLegacyTodoFile(sessionDir);
222
+ if (!file)
223
+ return "empty";
224
+ const todos = parseLegacyTodos(readFileSync(file, "utf8"));
225
+ // Refuse to merge into a session that already has rows: a second import
226
+ // would duplicate items, and silently merging is worse than skipping.
227
+ if (readRevision(handle, sessionKey) > 0)
228
+ return "skipped";
229
+ replaceSessionTodos(handle, sessionKey, todos);
230
+ // Only now is it safe to mark the source as consumed.
231
+ renameSync(file, file.replace(/\.json$/, MIGRATED_SUFFIX));
232
+ return "imported";
233
+ }
234
+ /**
235
+ * Scans every session folder under `baseDir` and imports the legacy JSON.
236
+ *
237
+ * Safe to run on every startup: sessions already present in SQLite are
238
+ * skipped, and consumed files no longer match the payload pattern.
239
+ */
240
+ export function importLegacyJson(handle, baseDir) {
241
+ const result = { imported: [], skipped: [], failed: [] };
242
+ let entries;
243
+ try {
244
+ entries = readdirSync(baseDir);
245
+ }
246
+ catch {
247
+ return result;
248
+ }
249
+ for (const name of entries) {
250
+ const sessionDir = join(baseDir, name);
251
+ let file;
252
+ try {
253
+ file = findLegacyTodoFile(sessionDir);
254
+ if (!file)
255
+ continue;
256
+ }
257
+ catch {
258
+ continue;
259
+ }
260
+ // The hashed folder name is the session key; the legacy plaintext layout
261
+ // used the raw session id, which is also a valid key.
262
+ const sessionKey = name;
263
+ try {
264
+ const outcome = importSession(handle, sessionKey, sessionDir);
265
+ if (outcome === "imported")
266
+ result.imported.push(file);
267
+ else if (outcome === "skipped")
268
+ result.skipped.push(file);
269
+ }
270
+ catch (error) {
271
+ result.failed.push({
272
+ file,
273
+ error: error instanceof Error ? error.message : String(error),
274
+ });
275
+ }
276
+ }
277
+ return result;
278
+ }