@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,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 { type TodoRootItem } from "../schemas/todo";
1
+ import type { TodoRootItem } from "../schemas/todo";
2
2
  /**
3
- * Environment/OS context used to resolve storage locations. All fields are
4
- * optional so callers (and tests) can simulate any platform on any host.
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 static readonly FILE_PREFIX;
31
- private static readonly FILE_SUFFIX;
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
- * Deterministic cryptographic hash of sessionID.
37
- * Produces an opaque 32-character hexadecimal folder name prefixed with "s_".
38
- * Prevents raw sessionIDs from leaking in the filesystem structure.
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 that stores per-session todo folders.
71
+ * Base directory holding the database and the legacy session folders.
69
72
  *
70
73
  * Priority:
71
- * 1. `OPENCODE_TODOS_DIR` — explicit plugin override (tests/tooling).
72
- * 2. The user's home directory + `.opencode/sessions`.
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
- getTodoLockPath(sessionID: string): string;
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
- getSessionIDFilePath(sessionID: string): string;
82
- getVersionFilePath(sessionID: string): string;
83
- getTodoFilePath(sessionID: string): string;
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
- private scanTodoFiles;
90
- private listSessionFiles;
91
- private readVersionFile;
92
- private static parseTodoFile;
93
- /** Shared error policy for readTodos / readTodosSync. */
94
- private recoverFromReadError;
95
- private writeTextAtomically;
96
- private writeJsonAtomically;
97
- private migrateFlatTodoFiles;
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;