@gamflaz/opencode-todolist 1.0.11 → 1.1.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.
@@ -1,31 +1,45 @@
1
1
  import z from "zod";
2
2
  import { PrioritySchema, TodoStatusSchema } from "./status.js";
3
+ /** A top-level id only: `1`, `2`, ... — never a dotted subtask id. */
4
+ export const RootIdSchema = z
5
+ .number()
6
+ .int()
7
+ .positive()
8
+ .describe("Unique task identifier (e.g. 1)");
3
9
  // Child id format: <root_id>.<n> as a number -> 1.1, 2.3, etc.
4
10
  // Only a single decimal digit (1-9) is allowed, see the note below.
5
- const ChildIdSchema = z
11
+ export const ChildIdSchema = z
6
12
  .number()
7
13
  .refine((n) => /^[1-9]\d*\.[1-9]$/.test(String(n)), {
8
14
  message: "Child id must follow the format <root_id>.<n> with n from 1 to 9, e.g. 1.1",
9
15
  })
10
16
  .describe("Unique subtask identifier, format <root_id>.<n> (e.g. 1.1)");
17
+ // Root or child id, composed from the two schemas above so the id rules
18
+ // live in exactly one place.
19
+ export const TodoTargetIdSchema = z
20
+ .union([RootIdSchema, ChildIdSchema])
21
+ .describe("Root or child todo id, e.g. 1 or 1.1");
11
22
  // TodoItem Children SCHEMA
12
- export const TodoChildernItemSchema = z.object({
23
+ export const TodoChildrenItemSchema = z
24
+ .object({
13
25
  id: ChildIdSchema,
14
26
  content: z.string().describe("Subtask description"),
15
27
  status: TodoStatusSchema,
16
28
  priority: PrioritySchema.optional(),
17
29
  note: z.string().optional(),
18
- });
30
+ })
31
+ .strict();
19
32
  // TodoItem Root SCHEMA
20
33
  export const TodoRootItemSchema = z
21
34
  .object({
22
- id: z.number().int().positive().describe("Unique task identifier (e.g. 1)"),
35
+ id: RootIdSchema,
23
36
  content: z.string().describe("Main task summary"),
24
37
  status: TodoStatusSchema,
25
38
  priority: PrioritySchema.optional(),
26
39
  note: z.string().optional(),
27
- children: z.array(TodoChildernItemSchema).max(9).optional(),
40
+ children: z.array(TodoChildrenItemSchema).max(9).optional(),
28
41
  })
42
+ .strict()
29
43
  .superRefine((root, ctx) => {
30
44
  const seen = new Set();
31
45
  if (root.children) {
@@ -70,32 +84,73 @@ export const TodoListSchema = z
70
84
  });
71
85
  })
72
86
  .describe("Hierarchical task list");
73
- // TodoHook Write Input
74
- export const TodoWriteInputSchema = z.object({
75
- todos: TodoListSchema,
76
- });
77
- export const TodoTargetIdSchema = z
78
- .number()
79
- .positive()
80
- .refine((id) => Number.isInteger(id) || /^[1-9]\d*\.[1-9]$/.test(String(id)), "ID must be a root number or child number such as 1 or 1.1");
81
87
  // TodoHook Tool Schema
82
- export const TodoReadOperationSchema = z
88
+ //
89
+ // `read` is a discriminated union on `scope`, so an impossible combination
90
+ // cannot be written at all: `target` exists only alongside `scope: "item"`,
91
+ // `"children"` and `"root"`, none of which is valid without one. `.strict()`
92
+ // on every member is what enforces that — without it
93
+ // `{ scope: "all", target: 1 }` would parse and silently ignore `target`.
94
+ //
95
+ // An omitted `scope` defaults to "all", so a bare `{ operation: "read" }` keeps
96
+ // behaving exactly as it did before this option existed. That default has to
97
+ // live on the discriminator itself; a member without `scope` is rejected at
98
+ // construction time.
99
+ const readOperation = z
100
+ .literal("read")
101
+ .describe("'read' to inspect current todos.");
102
+ export const TodoReadAllSchema = z
83
103
  .object({
84
- operation: z.literal("read").describe("'read' to inspect current todos."),
104
+ operation: readOperation,
105
+ scope: z
106
+ .literal("all")
107
+ .default("all")
108
+ .describe("Default. Return the whole list."),
85
109
  })
86
110
  .strict();
111
+ export const TodoReadItemSchema = z
112
+ .object({
113
+ operation: readOperation,
114
+ scope: z.literal("item").describe("Return only the targeted item."),
115
+ target: TodoTargetIdSchema.describe("Root or child todo id, e.g. 1 or 1.1."),
116
+ })
117
+ .strict();
118
+ export const TodoReadChildrenSchema = z
119
+ .object({
120
+ operation: readOperation,
121
+ scope: z
122
+ .literal("children")
123
+ .describe("Return one root and its subtasks. The root is included as context."),
124
+ target: ChildIdSchema.describe("A subtask id such as 1.1; its root is resolved from the id."),
125
+ })
126
+ .strict();
127
+ export const TodoReadRootSchema = z
128
+ .object({
129
+ operation: readOperation,
130
+ scope: z
131
+ .literal("root")
132
+ .describe("Return exactly one root together with all of its subtasks."),
133
+ target: RootIdSchema.describe("Root todo id, e.g. 1."),
134
+ })
135
+ .strict();
136
+ export const TodoReadOperationSchema = z.discriminatedUnion("scope", [
137
+ TodoReadAllSchema,
138
+ TodoReadItemSchema,
139
+ TodoReadChildrenSchema,
140
+ TodoReadRootSchema,
141
+ ]);
87
142
  export const TodoWriteOperationSchema = z
88
143
  .object({
89
144
  operation: z
90
145
  .literal("write")
91
- .describe("'write' to initialize an empty list."),
146
+ .describe("'write' to replace the whole list. Pass [] to clear it."),
92
147
  todos: TodoListSchema.describe("Required for 'write'. Pass [] to clear the list."),
93
148
  })
94
149
  .strict();
95
150
  export const TodoUpdateOperationSchema = z
96
151
  .object({
97
152
  operation: z.literal("update").describe("'update' one root or child todo."),
98
- id: TodoTargetIdSchema.describe("Root or child todo ID, e.g. 1 or 1.1"),
153
+ id: TodoTargetIdSchema,
99
154
  status: TodoStatusSchema.describe("New status for the target todo."),
100
155
  content: z.string().optional(),
101
156
  priority: PrioritySchema.nullable().optional(),
@@ -108,5 +163,5 @@ export const TodoInputToolSchema = z.discriminatedUnion("operation", [
108
163
  TodoUpdateOperationSchema,
109
164
  ]);
110
165
  export const TodoOutputToolSchema = z.object({
111
- todos: z.array(TodoRootItemSchema),
166
+ todos: TodoListSchema,
112
167
  });
@@ -0,0 +1,71 @@
1
+ import { Database as BunDatabase } from "bun:sqlite";
2
+ import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite";
3
+ /**
4
+ * Database connection for todo storage.
5
+ *
6
+ * One file holds every session; rows are partitioned by `session_key`. Two
7
+ * separate OpenCode processes reach the same file:
8
+ *
9
+ * - the server process runs `manage_todo_list` and writes
10
+ * - the CLI process renders the TUI widget and reads
11
+ *
12
+ * They share no memory, so the PRAGMAs below are not decoration:
13
+ *
14
+ * - `journal_mode = WAL` lets a reader work while a writer commits
15
+ * - `busy_timeout` makes a concurrent writer wait instead of failing
16
+ * - `foreign_keys = ON` is per-connection in SQLite, so it must be set on
17
+ * every open or the composite parent/child constraint silently stops
18
+ * being enforced
19
+ *
20
+ * PRAGMAs run before `migrate()` so the migration itself benefits from WAL and
21
+ * enforced foreign keys.
22
+ *
23
+ * `run` is used rather than `exec`: in `bun:sqlite` they are the same call, but
24
+ * `exec` is deprecated in favour of `run`, and a deprecated alias on the hot
25
+ * path is noise in every build.
26
+ */
27
+ /**
28
+ * No relations generic: this schema is queried with the core builder
29
+ * (`select`/`insert`/`update`/`delete`), never with relational `db.query`.
30
+ * Drizzle 1.0 dropped `schema` from the SQLite config for that reason, so there
31
+ * is nothing to thread through here.
32
+ */
33
+ export type TodoDatabase = SQLiteBunDatabase;
34
+ export interface TodoDatabaseHandle {
35
+ /** Raw connection, for statements Drizzle does not expose (e.g. PRAGMA). */
36
+ readonly sqlite: BunDatabase;
37
+ readonly db: TodoDatabase;
38
+ readonly path: string;
39
+ }
40
+ /**
41
+ * Folder holding the generated migrations.
42
+ *
43
+ * `import.meta.dirname` resolves to the plugin's own directory because OpenCode
44
+ * loads local plugins as ordinary files from disk (not embedded in the host
45
+ * binary). That was verified against a standalone Bun host importing an
46
+ * external plugin: `import.meta.dirname` pointed at the real folder.
47
+ *
48
+ * At runtime this file lives in `dist/storage/`, so the migrations folder sits
49
+ * two levels up at the package root. Each migration is its own timestamped
50
+ * subfolder holding `migration.sql` — the layout `drizzle-kit` 1.0 emits and
51
+ * the migrator reads back.
52
+ */
53
+ export declare function resolveMigrationsFolder(): string;
54
+ export declare function openDatabase(path: string, migrationsFolder?: string): TodoDatabaseHandle;
55
+ /**
56
+ * Process-wide singleton.
57
+ *
58
+ * Opening is not free: it applies migrations and sets up WAL. The tool and the
59
+ * widget each call this repeatedly, so the connection is created once per
60
+ * process. `resetDatabaseForTests` exists for tests that need a fresh handle.
61
+ */
62
+ export declare function getDatabase(path: string): TodoDatabaseHandle;
63
+ export declare function resetDatabaseForTests(): void;
64
+ /**
65
+ * SQLite's cross-connection change counter.
66
+ *
67
+ * `PRAGMA data_version` changes when *another* connection commits, and stays
68
+ * put for this connection's own writes. That is exactly the signal a reader
69
+ * wants, so the TUI can poll it and skip work when nothing external changed.
70
+ */
71
+ export declare function getDataVersion(sqlite: BunDatabase): number;
@@ -0,0 +1,59 @@
1
+ import { Database as BunDatabase } from "bun:sqlite";
2
+ import { drizzle } from "drizzle-orm/bun-sqlite";
3
+ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
4
+ import { mkdirSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ let handle;
7
+ /**
8
+ * Folder holding the generated migrations.
9
+ *
10
+ * `import.meta.dirname` resolves to the plugin's own directory because OpenCode
11
+ * loads local plugins as ordinary files from disk (not embedded in the host
12
+ * binary). That was verified against a standalone Bun host importing an
13
+ * external plugin: `import.meta.dirname` pointed at the real folder.
14
+ *
15
+ * At runtime this file lives in `dist/storage/`, so the migrations folder sits
16
+ * two levels up at the package root. Each migration is its own timestamped
17
+ * subfolder holding `migration.sql` — the layout `drizzle-kit` 1.0 emits and
18
+ * the migrator reads back.
19
+ */
20
+ export function resolveMigrationsFolder() {
21
+ return join(import.meta.dirname, "..", "..", "drizzle");
22
+ }
23
+ export function openDatabase(path, migrationsFolder = resolveMigrationsFolder()) {
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ const sqlite = new BunDatabase(path, { create: true });
26
+ sqlite.run("PRAGMA journal_mode = WAL");
27
+ sqlite.run("PRAGMA synchronous = NORMAL");
28
+ sqlite.run("PRAGMA busy_timeout = 5000");
29
+ sqlite.run("PRAGMA cache_size = -64000");
30
+ sqlite.run("PRAGMA foreign_keys = ON");
31
+ const db = drizzle({ client: sqlite });
32
+ migrate(db, { migrationsFolder });
33
+ return { sqlite, db, path };
34
+ }
35
+ /**
36
+ * Process-wide singleton.
37
+ *
38
+ * Opening is not free: it applies migrations and sets up WAL. The tool and the
39
+ * widget each call this repeatedly, so the connection is created once per
40
+ * process. `resetDatabaseForTests` exists for tests that need a fresh handle.
41
+ */
42
+ export function getDatabase(path) {
43
+ return (handle ??= openDatabase(path));
44
+ }
45
+ export function resetDatabaseForTests() {
46
+ handle?.sqlite.close();
47
+ handle = undefined;
48
+ }
49
+ /**
50
+ * SQLite's cross-connection change counter.
51
+ *
52
+ * `PRAGMA data_version` changes when *another* connection commits, and stays
53
+ * put for this connection's own writes. That is exactly the signal a reader
54
+ * wants, so the TUI can poll it and skip work when nothing external changed.
55
+ */
56
+ export function getDataVersion(sqlite) {
57
+ const row = sqlite.query("PRAGMA data_version").get();
58
+ return row?.data_version ?? 0;
59
+ }
@@ -0,0 +1,82 @@
1
+ import { type TodoRootItem } from "../schemas/todo";
2
+ import type { TodoDatabaseHandle } from "./db";
3
+ /**
4
+ * One-time import of the legacy per-session JSON files into SQLite, plus the
5
+ * shared write primitive every caller uses.
6
+ *
7
+ * The old layout kept one file per session:
8
+ *
9
+ * <base>/s_<hash>/todos/todos-<timestamp>.json
10
+ * <base>/s_<hash>/todos/version
11
+ *
12
+ * Design rules:
13
+ *
14
+ * - **Never delete data.** A file is renamed to `.json.migrated` only after the
15
+ * transaction committed, so a crash mid-import leaves the original intact.
16
+ * - **Idempotent.** Importing a session that already holds rows is refused
17
+ * rather than merged, so a partial import cannot silently duplicate items.
18
+ * - **Validated.** JSON is checked with the same `TodoListSchema` the tool
19
+ * uses, so a corrupt legacy file fails loudly here instead of producing a
20
+ * half-valid database.
21
+ */
22
+ export interface MigrationResult {
23
+ readonly imported: string[];
24
+ readonly skipped: string[];
25
+ readonly failed: {
26
+ file: string;
27
+ error: string;
28
+ }[];
29
+ }
30
+ /** `todos-<ts>.json` holds the payload; `version` holds the counter. */
31
+ export declare function readLegacyVersion(sessionDir: string): number;
32
+ /** Newest payload file wins, matching the old "latest wins" behaviour. */
33
+ export declare function findLegacyTodoFile(sessionDir: string): string | undefined;
34
+ /** Parses a legacy payload, accepting the wrapped object and the bare array. */
35
+ export declare function parseLegacyTodos(raw: string): TodoRootItem[];
36
+ /**
37
+ * Replaces a session's whole list in one transaction.
38
+ *
39
+ * Used by both the importer and every normal write, so ordering (`position`)
40
+ * and atomicity are identical everywhere.
41
+ *
42
+ * `behavior: "immediate"` takes the write lock before reading, so two
43
+ * processes cannot both observe an old revision and then write rows that
44
+ * conflict. Combined with `busy_timeout` the second writer waits instead of
45
+ * failing.
46
+ */
47
+ export declare function replaceSessionTodos(handle: TodoDatabaseHandle, sessionKey: string, todos: readonly TodoRootItem[]): number;
48
+ /** Reads a session's revision, or 0 when the session has never been written. */
49
+ export declare function readRevision(handle: TodoDatabaseHandle, sessionKey: string): number;
50
+ /**
51
+ * Read-modify-write under one `IMMEDIATE` transaction.
52
+ *
53
+ * Every write goes through here so the read, the caller's transform, and the
54
+ * replacement cannot interleave with another process's write. Reading outside
55
+ * the transaction and writing inside it would allow a lost update: two
56
+ * processes could each read revision N, then both write revision N+1, and one
57
+ * change would silently disappear.
58
+ *
59
+ * The transform receives the current list and returns the next one; validation
60
+ * happens inside the transaction so an invalid result rolls the whole thing
61
+ * back without bumping the revision.
62
+ */
63
+ export declare function mutateSessionTodos(handle: TodoDatabaseHandle, sessionKey: string, transform: (current: TodoRootItem[]) => TodoRootItem[]): {
64
+ todos: TodoRootItem[];
65
+ revision: number;
66
+ };
67
+ /** Reads a session's list in `position` order, rebuilt into the nested shape. */
68
+ export declare function selectTodos(executor: Pick<TodoDatabaseHandle["db"], "select">, sessionKey: string): TodoRootItem[];
69
+ /**
70
+ * Writes the full list for a session inside a transaction, without opening one.
71
+ *
72
+ * Kept separate so `mutateSessionTodos` can compose it with a read in the same
73
+ * transaction.
74
+ */
75
+ export declare function writeSessionTodos(tx: Pick<TodoDatabaseHandle["db"], "select" | "insert" | "update" | "delete">, sessionKey: string, todos: readonly TodoRootItem[]): number;
76
+ /**
77
+ * Scans every session folder under `baseDir` and imports the legacy JSON.
78
+ *
79
+ * Safe to run on every startup: sessions already present in SQLite are
80
+ * skipped, and consumed files no longer match the payload pattern.
81
+ */
82
+ export declare function importLegacyJson(handle: TodoDatabaseHandle, baseDir: string): MigrationResult;
@@ -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
+ }