@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.
- package/dist/icons/glyph.d.ts +77 -0
- package/dist/icons/glyph.js +103 -0
- package/dist/icons/glyphs.d.ts +51 -0
- package/dist/icons/glyphs.js +56 -0
- package/dist/icons/svg-icon.js +12 -11
- package/dist/index.d.ts +21 -2
- package/dist/index.js +41 -3
- package/dist/schemas/todo.d.ts +76 -58
- package/dist/schemas/todo.js +73 -18
- package/dist/storage/db.d.ts +71 -0
- package/dist/storage/db.js +59 -0
- package/dist/storage/migrate-json.d.ts +82 -0
- package/dist/storage/migrate-json.js +278 -0
- package/dist/storage/schema.d.ts +775 -0
- package/dist/storage/schema.js +108 -0
- package/dist/storage/todo-store.d.ts +104 -56
- package/dist/storage/todo-store.js +178 -297
- package/dist/todos/hint.d.ts +13 -2
- package/dist/todos/hint.js +16 -19
- package/dist/todos/todo-tree.d.ts +16 -3
- package/dist/todos/todo-tree.js +22 -4
- package/dist/todos/update.js +2 -2
- package/dist/tui/todo-panel.js +77 -69
- package/drizzle/20261014050406_eminent_zaran/migration.sql +49 -0
- package/drizzle/20261014050406_eminent_zaran/snapshot.json +470 -0
- package/package.json +8 -5
package/dist/schemas/todo.js
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
+
}
|