@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.
- package/README.md +61 -32
- 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/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 +73 -65
- package/drizzle/20261014050406_eminent_zaran/migration.sql +49 -0
- package/drizzle/20261014050406_eminent_zaran/snapshot.json +470 -0
- package/package.json +7 -5
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { index, integer, primaryKey, sqliteTable, text, uniqueIndex, foreignKey, } from "drizzle-orm/sqlite-core";
|
|
2
|
+
/**
|
|
3
|
+
* Relational storage for todos.
|
|
4
|
+
*
|
|
5
|
+
* Layout: one database file holds every session. Rows are partitioned by
|
|
6
|
+
* `session_key`, the OpenCode session id, so a session's data never leaks into
|
|
7
|
+
* another session's queries while still sharing one connection. Rows written by
|
|
8
|
+
* an older build keyed by a SHA-256 digest of that id are still found through
|
|
9
|
+
* `TodoStore.legacyKey`, which recomputes the same digest.
|
|
10
|
+
*
|
|
11
|
+
* Why a database instead of the previous per-session JSON files:
|
|
12
|
+
* - foreign keys make parent/child integrity structural instead of validated
|
|
13
|
+
* after the fact
|
|
14
|
+
* - transactions make a multi-row write atomic, so a reader can never observe
|
|
15
|
+
* a half-written list
|
|
16
|
+
* - `PRAGMA data_version` gives cross-process change detection, which is what
|
|
17
|
+
* the TUI needs (the tool and the widget run in separate processes)
|
|
18
|
+
*
|
|
19
|
+
* `id` is TEXT, not INTEGER: child ids are dotted values such as `1.1`, and
|
|
20
|
+
* storing them as numbers would lose the shape. `position` carries ordering;
|
|
21
|
+
* ids are stable identifiers only.
|
|
22
|
+
*/
|
|
23
|
+
/** One row per OpenCode session that has ever stored a todo list. */
|
|
24
|
+
export const todoSession = sqliteTable("todo_session", {
|
|
25
|
+
sessionKey: text("session_key").primaryKey(),
|
|
26
|
+
/** Bumped on every write; readers poll `PRAGMA data_version` to detect it. */
|
|
27
|
+
revision: integer("revision").notNull().default(0),
|
|
28
|
+
/** Revision at which the resume hint was last injected, so it fires once. */
|
|
29
|
+
hintRevision: integer("hint_revision").notNull().default(0),
|
|
30
|
+
createdAt: text("created_at").notNull(),
|
|
31
|
+
updatedAt: text("updated_at").notNull(),
|
|
32
|
+
});
|
|
33
|
+
/** One row per todo item: a root when `parent_id` is NULL, otherwise a child. */
|
|
34
|
+
export const todoItem = sqliteTable("todo_item", {
|
|
35
|
+
sessionKey: text("session_key")
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => todoSession.sessionKey, { onDelete: "cascade" }),
|
|
38
|
+
id: text("id").notNull(),
|
|
39
|
+
/** NULL for a root; the parent's `id` for a subtask. */
|
|
40
|
+
parentId: text("parent_id"),
|
|
41
|
+
content: text("content").notNull(),
|
|
42
|
+
status: text("status").$type().notNull(),
|
|
43
|
+
priority: text("priority").$type(),
|
|
44
|
+
note: text("note"),
|
|
45
|
+
/** Render order within the session. */
|
|
46
|
+
position: integer("position").notNull(),
|
|
47
|
+
}, (t) => [
|
|
48
|
+
primaryKey({ columns: [t.sessionKey, t.id] }),
|
|
49
|
+
// Composite self-reference: the parent must live in the same session, so a
|
|
50
|
+
// child can never point at another session's item.
|
|
51
|
+
foreignKey({
|
|
52
|
+
columns: [t.sessionKey, t.parentId],
|
|
53
|
+
foreignColumns: [t.sessionKey, t.id],
|
|
54
|
+
}).onDelete("cascade"),
|
|
55
|
+
index("todo_item_session_position_idx").on(t.sessionKey, t.position),
|
|
56
|
+
]);
|
|
57
|
+
/** Free-form labels. Present so tagging does not need a later migration. */
|
|
58
|
+
export const tag = sqliteTable("tag", {
|
|
59
|
+
id: text("id").primaryKey(),
|
|
60
|
+
name: text("name").notNull().unique(),
|
|
61
|
+
});
|
|
62
|
+
/** Many-to-many between items and labels. */
|
|
63
|
+
export const todoItemTag = sqliteTable("todo_item_tag", {
|
|
64
|
+
sessionKey: text("session_key").notNull(),
|
|
65
|
+
todoId: text("todo_id").notNull(),
|
|
66
|
+
tagId: text("tag_id").notNull(),
|
|
67
|
+
}, (t) => [
|
|
68
|
+
primaryKey({ columns: [t.sessionKey, t.todoId, t.tagId] }),
|
|
69
|
+
foreignKey({
|
|
70
|
+
columns: [t.sessionKey, t.todoId],
|
|
71
|
+
foreignColumns: [todoItem.sessionKey, todoItem.id],
|
|
72
|
+
}).onDelete("cascade"),
|
|
73
|
+
foreignKey({
|
|
74
|
+
columns: [t.tagId],
|
|
75
|
+
foreignColumns: [tag.id],
|
|
76
|
+
}).onDelete("cascade"),
|
|
77
|
+
index("todo_item_tag_tag_idx").on(t.tagId),
|
|
78
|
+
]);
|
|
79
|
+
/**
|
|
80
|
+
* Ordering constraints between items in the same session.
|
|
81
|
+
*
|
|
82
|
+
* Not enforced by the tool yet; the table exists so the storage layer does not
|
|
83
|
+
* need a schema change when dependency support lands.
|
|
84
|
+
*/
|
|
85
|
+
export const todoItemDependency = sqliteTable("todo_item_dependency", {
|
|
86
|
+
sessionKey: text("session_key").notNull(),
|
|
87
|
+
todoId: text("todo_id").notNull(),
|
|
88
|
+
dependsOnId: text("depends_on_id").notNull(),
|
|
89
|
+
}, (t) => [
|
|
90
|
+
primaryKey({ columns: [t.sessionKey, t.todoId, t.dependsOnId] }),
|
|
91
|
+
foreignKey({
|
|
92
|
+
columns: [t.sessionKey, t.todoId],
|
|
93
|
+
foreignColumns: [todoItem.sessionKey, todoItem.id],
|
|
94
|
+
}).onDelete("cascade"),
|
|
95
|
+
foreignKey({
|
|
96
|
+
columns: [t.sessionKey, t.dependsOnId],
|
|
97
|
+
foreignColumns: [todoItem.sessionKey, todoItem.id],
|
|
98
|
+
}).onDelete("cascade"),
|
|
99
|
+
uniqueIndex("todo_item_dependency_pair_idx").on(t.sessionKey, t.todoId, t.dependsOnId),
|
|
100
|
+
index("todo_item_dependency_depends_on_idx").on(t.sessionKey, t.dependsOnId),
|
|
101
|
+
]);
|
|
102
|
+
export const schema = {
|
|
103
|
+
todoSession,
|
|
104
|
+
todoItem,
|
|
105
|
+
tag,
|
|
106
|
+
todoItemTag,
|
|
107
|
+
todoItemDependency,
|
|
108
|
+
};
|
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { TodoRootItem } from "../schemas/todo";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Per-session todo persistence on SQLite.
|
|
4
|
+
*
|
|
5
|
+
* Storage shape:
|
|
6
|
+
*
|
|
7
|
+
* <base>/todos.db one file, every session, partitioned by session_key
|
|
8
|
+
* <base>/todos.db-wal write-ahead log
|
|
9
|
+
*
|
|
10
|
+
* The public API is unchanged from the previous JSON implementation, so the
|
|
11
|
+
* tool, the hint builder, and the TUI widget keep working without edits. Only
|
|
12
|
+
* the storage engine moved.
|
|
13
|
+
*
|
|
14
|
+
* Why SQLite here:
|
|
15
|
+
* - `parent_id` becomes a real foreign key, so a subtask cannot dangle
|
|
16
|
+
* - a full-list write is one transaction, so a reader never sees a half list
|
|
17
|
+
* - `PRAGMA data_version` lets the TUI detect writes made by the other process
|
|
18
|
+
*
|
|
19
|
+
* The tool and the widget run in separate processes, so nothing is shared in
|
|
20
|
+
* memory and this file is the hand-off point.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Environment/OS context used to resolve storage locations. Every field is
|
|
24
|
+
* optional so callers and tests can simulate any platform on any host.
|
|
5
25
|
*/
|
|
6
26
|
export interface PlatformContext {
|
|
7
27
|
platform?: NodeJS.Platform;
|
|
@@ -14,89 +34,113 @@ export interface TodoData {
|
|
|
14
34
|
created_at: string;
|
|
15
35
|
updated_at: string;
|
|
16
36
|
}
|
|
17
|
-
/**
|
|
18
|
-
* Per-session todo persistence.
|
|
19
|
-
*
|
|
20
|
-
* Layout (per session):
|
|
21
|
-
* <base>/s_<hash>/todos/todos-<timestamp>.json todo payload (latest wins)
|
|
22
|
-
* <base>/s_<hash>/todos/version monotonically increasing version
|
|
23
|
-
* <base>/s_<hash>/todos/id hashed session folder name
|
|
24
|
-
* <base>/s_<hash>/todos/todos.lock cross-process lock
|
|
25
|
-
* <base>/s_<hash>/todos/hint-marker.json hint injection marker
|
|
26
|
-
*/
|
|
27
37
|
export declare class TodoStore {
|
|
28
38
|
private readonly ctx;
|
|
29
39
|
private static readonly SESSION_ID_PATTERN;
|
|
30
|
-
private
|
|
31
|
-
private
|
|
40
|
+
private handle;
|
|
41
|
+
private importedBaseDir;
|
|
32
42
|
constructor(ctx?: PlatformContext);
|
|
33
43
|
static isMissingFile(error: unknown): boolean;
|
|
34
44
|
static normalizeSessionID(sessionID: string): string;
|
|
35
45
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
46
|
+
* Storage partition key for a session: the OpenCode session id verbatim.
|
|
47
|
+
*
|
|
48
|
+
* The id is already unique and `normalizeSessionID` already restricts it to
|
|
49
|
+
* `[A-Za-z0-9_-]`, so hashing it bought nothing and cost readability — a raw
|
|
50
|
+
* id can be traced back to a session by eye, a digest cannot.
|
|
39
51
|
*/
|
|
52
|
+
static sessionKey(sessionID: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* The key older builds used for this session, kept so their rows still work.
|
|
55
|
+
*
|
|
56
|
+
* A SHA-256 digest cannot be reversed, so a stored hash can never be turned
|
|
57
|
+
* back into an id. It does not need to be: hashing is deterministic, so given
|
|
58
|
+
* the id we recompute the same digest and find the old row, which is what
|
|
59
|
+
* `resolveKey` does. A session that already has rows keeps its old key; only
|
|
60
|
+
* sessions that are new get the readable one.
|
|
61
|
+
*/
|
|
62
|
+
static legacyKey(sessionID: string): string;
|
|
63
|
+
/** @deprecated Use {@link TodoStore.legacyKey}; kept for existing callers. */
|
|
40
64
|
static hashSessionID(sessionID: string): string;
|
|
41
|
-
private static setPrivateMode;
|
|
42
65
|
private get env();
|
|
43
66
|
private get platform();
|
|
44
67
|
private joinForPlatform;
|
|
45
|
-
/**
|
|
46
|
-
* Cross-platform home directory resolution.
|
|
47
|
-
*
|
|
48
|
-
* `os.homedir()` already adapts to the OS, but it can return an empty string
|
|
49
|
-
* in stripped environments, and Windows shells sometimes expose a
|
|
50
|
-
* POSIX-style `HOME` (e.g. Git Bash `/c/Users/...`). We therefore prefer the
|
|
51
|
-
* canonical variable for the running platform before falling back.
|
|
52
|
-
*
|
|
53
|
-
* @see {@link https://nodejs.org/api/os.html#oshomedir}
|
|
54
|
-
*/
|
|
55
68
|
resolveHomeDir(): string;
|
|
56
|
-
/**
|
|
57
|
-
* Platform-native base directory for user configuration/data.
|
|
58
|
-
*
|
|
59
|
-
* - Windows : `%APPDATA%` (fallback `%LOCALAPPDATA%` → `~/AppData/Roaming`)
|
|
60
|
-
* - macOS : `~/Library/Application Support`
|
|
61
|
-
* - Linux : `$XDG_CONFIG_HOME` (fallback `~/.config`)
|
|
62
|
-
*
|
|
63
|
-
* `XDG_CONFIG_HOME` is honored first on every platform so users who prefer an
|
|
64
|
-
* XDG layout (including those running OpenCode's own convention) can force it.
|
|
65
|
-
*/
|
|
66
69
|
resolveConfigHome(): string;
|
|
67
70
|
/**
|
|
68
|
-
* Base directory
|
|
71
|
+
* Base directory holding the database and the legacy session folders.
|
|
69
72
|
*
|
|
70
73
|
* Priority:
|
|
71
|
-
* 1. `OPENCODE_TODOS_DIR` — explicit
|
|
72
|
-
* 2.
|
|
74
|
+
* 1. `OPENCODE_TODOS_DIR` — explicit override for tests and tooling.
|
|
75
|
+
* 2. `<home>/.opencode/sessions`
|
|
73
76
|
*/
|
|
74
77
|
resolveTodosBaseDir(): string;
|
|
75
|
-
/** Base directory for all sessions (replaces the old `TODOS_DIRECTORY` const). */
|
|
76
78
|
get todosRoot(): string;
|
|
79
|
+
getDatabasePath(): string;
|
|
80
|
+
/**
|
|
81
|
+
* Legacy per-session directory.
|
|
82
|
+
*
|
|
83
|
+
* No longer written to. Kept because it is still read once to import old JSON
|
|
84
|
+
* and because the TUI watches this directory for filesystem changes.
|
|
85
|
+
*/
|
|
77
86
|
getSessionDirectory(sessionID: string): string;
|
|
78
87
|
getTodosDirectory(sessionID?: string): string;
|
|
79
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Path of the legacy hint marker.
|
|
90
|
+
*
|
|
91
|
+
* The marker now lives in `todo_session.hint_revision`, so nothing writes
|
|
92
|
+
* here. The path is still exposed because callers may need to locate and
|
|
93
|
+
* clean up a leftover file from an older install.
|
|
94
|
+
*/
|
|
80
95
|
getHintMarkerPath(sessionID: string): string;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
private database;
|
|
97
|
+
private key;
|
|
98
|
+
/**
|
|
99
|
+
* Resolves the key a session's rows currently live under.
|
|
100
|
+
*
|
|
101
|
+
* Prefers the real session id. A session first written by an older build
|
|
102
|
+
* still sits under its hashed key, so that is checked as a fallback — without
|
|
103
|
+
* it, upgrading would look like every todo list had vanished.
|
|
104
|
+
*/
|
|
105
|
+
private resolveKey;
|
|
106
|
+
/**
|
|
107
|
+
* Reads a session's list.
|
|
108
|
+
*
|
|
109
|
+
* Synchronous at its core because `bun:sqlite` is synchronous; the `async`
|
|
110
|
+
* signature is kept so existing callers do not need to change.
|
|
111
|
+
*/
|
|
84
112
|
readTodos(sessionID: string): Promise<TodoRootItem[]>;
|
|
85
113
|
readTodosSync(sessionID: string): TodoRootItem[];
|
|
86
114
|
readTodoData(sessionID: string): TodoData | null;
|
|
115
|
+
/** Revision counter, used by the TUI to detect changes cheaply. */
|
|
116
|
+
getRevision(sessionID: string): number;
|
|
117
|
+
/**
|
|
118
|
+
* Reads the hint marker value and, when it is stale, records the new
|
|
119
|
+
* revision. Returns the previous revision so the caller can decide.
|
|
120
|
+
*/
|
|
121
|
+
takeHintRevision(sessionID: string, currentRevision: number): number;
|
|
122
|
+
/** Cross-process change signal for the TUI watcher. */
|
|
123
|
+
getDataVersion(): number;
|
|
124
|
+
migrationsFolder(): string;
|
|
87
125
|
writeTodos(sessionID: string, todos: TodoRootItem[]): Promise<void>;
|
|
88
126
|
updateTodos(sessionID: string, update: (todos: TodoRootItem[]) => TodoRootItem[]): Promise<TodoRootItem[]>;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
/** Replaces a list without running the tree validation (import path). */
|
|
128
|
+
replaceTodosSync(sessionID: string, todos: TodoRootItem[]): void;
|
|
129
|
+
/**
|
|
130
|
+
* Drops the cached connection so the next call reopens from scratch.
|
|
131
|
+
*
|
|
132
|
+
* Only for tests: the store deliberately caches one connection per process
|
|
133
|
+
* (opening applies migrations and sets up WAL, which is not free). A test
|
|
134
|
+
* that points `OPENCODE_TODOS_DIR` at a new directory must reset, otherwise
|
|
135
|
+
* the previous connection keeps servicing the old, unlinked file.
|
|
136
|
+
*/
|
|
137
|
+
resetForTests(): void;
|
|
98
138
|
}
|
|
99
139
|
export declare const getTodosDirectory: (sessionID?: string) => string;
|
|
140
|
+
export declare const getDatabasePath: () => string;
|
|
141
|
+
export declare const sessionKey: (sessionID: string) => string;
|
|
142
|
+
export declare const legacySessionKey: (sessionID: string) => string;
|
|
143
|
+
/** @deprecated Use {@link legacySessionKey}; the hash is no longer the key. */
|
|
100
144
|
export declare const hashSessionID: (sessionID: string) => string;
|
|
101
145
|
export declare const readTodos: (sessionID: string) => Promise<{
|
|
102
146
|
id: number;
|
|
@@ -143,3 +187,7 @@ export declare const updateTodos: (sessionID: string, update: (todos: TodoRootIt
|
|
|
143
187
|
}[]>;
|
|
144
188
|
export declare const readTodoData: (sessionID: string) => TodoData | null;
|
|
145
189
|
export declare const getHintMarkerPath: (sessionID: string) => string;
|
|
190
|
+
export declare const getRevision: (sessionID: string) => number;
|
|
191
|
+
export declare const takeHintRevision: (sessionID: string, revision: number) => number;
|
|
192
|
+
export declare const getDataVersion: () => number;
|
|
193
|
+
export declare const resetTodoStoreForTests: () => void;
|