@sascha384/tic 1.35.0 → 2.0.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/.claude-plugin/plugin.json +1 -1
- package/dist/backends/availability.js +4 -2
- package/dist/backends/availability.js.map +1 -1
- package/dist/backends/factory.d.ts +5 -3
- package/dist/backends/factory.js +28 -43
- package/dist/backends/factory.js.map +1 -1
- package/dist/backends/files/hash.d.ts +1 -0
- package/dist/backends/files/hash.js +5 -0
- package/dist/backends/files/hash.js.map +1 -0
- package/dist/backends/files/index.d.ts +48 -0
- package/dist/backends/files/index.js +174 -0
- package/dist/backends/files/index.js.map +1 -0
- package/dist/backends/files/sync.d.ts +13 -0
- package/dist/backends/files/sync.js +69 -0
- package/dist/backends/files/sync.js.map +1 -0
- package/dist/backends/jira/config.d.ts +1 -1
- package/dist/backends/jira/config.js +6 -9
- package/dist/backends/jira/config.js.map +1 -1
- package/dist/backends/types.d.ts +12 -0
- package/dist/backends/types.js +5 -1
- package/dist/backends/types.js.map +1 -1
- package/dist/cli/commands/config.js +27 -14
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/init.js +10 -3
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts +4 -4
- package/dist/cli/commands/mcp.js +16 -25
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/index.js +16 -19
- package/dist/cli/index.js.map +1 -1
- package/dist/commands.js +0 -6
- package/dist/commands.js.map +1 -1
- package/dist/components/Header.js +3 -2
- package/dist/components/Header.js.map +1 -1
- package/dist/components/OverlayPanel.d.ts +2 -1
- package/dist/components/OverlayPanel.js +8 -1
- package/dist/components/OverlayPanel.js.map +1 -1
- package/dist/components/Settings.js +6 -11
- package/dist/components/Settings.js.map +1 -1
- package/dist/components/StatusScreen.js +1 -1
- package/dist/components/StatusScreen.js.map +1 -1
- package/dist/components/WorkItemForm.js +6 -9
- package/dist/components/WorkItemForm.js.map +1 -1
- package/dist/components/WorkItemList.js +102 -51
- package/dist/components/WorkItemList.js.map +1 -1
- package/dist/index.js +20 -8
- package/dist/index.js.map +1 -1
- package/dist/storage/config.d.ts +61 -0
- package/dist/storage/config.js +309 -0
- package/dist/storage/config.js.map +1 -0
- package/dist/storage/db.d.ts +11 -0
- package/dist/storage/db.js +34 -0
- package/dist/storage/db.js.map +1 -0
- package/dist/storage/index.d.ts +73 -0
- package/dist/storage/index.js +966 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/mappers.d.ts +35 -0
- package/dist/storage/mappers.js +70 -0
- package/dist/storage/mappers.js.map +1 -0
- package/dist/storage/schema.d.ts +1844 -0
- package/dist/storage/schema.js +197 -0
- package/dist/storage/schema.js.map +1 -0
- package/dist/storage/syncQueue.d.ts +13 -0
- package/dist/storage/syncQueue.js +98 -0
- package/dist/storage/syncQueue.js.map +1 -0
- package/dist/storage/undo.d.ts +22 -0
- package/dist/storage/undo.js +129 -0
- package/dist/storage/undo.js.map +1 -0
- package/dist/stores/backendDataStore.d.ts +4 -1
- package/dist/stores/backendDataStore.js +61 -40
- package/dist/stores/backendDataStore.js.map +1 -1
- package/dist/stores/configStore.d.ts +3 -1
- package/dist/stores/configStore.js +25 -65
- package/dist/stores/configStore.js.map +1 -1
- package/dist/stores/filterStore.d.ts +2 -0
- package/dist/stores/filterStore.js +7 -2
- package/dist/stores/filterStore.js.map +1 -1
- package/dist/stores/uiStore.d.ts +0 -2
- package/dist/stores/uiStore.js.map +1 -1
- package/dist/stores/undoStore.d.ts +4 -0
- package/dist/stores/undoStore.js +32 -0
- package/dist/stores/undoStore.js.map +1 -1
- package/dist/sync/SyncManager.d.ts +3 -4
- package/dist/sync/SyncManager.js +78 -37
- package/dist/sync/SyncManager.js.map +1 -1
- package/dist/sync/types.d.ts +10 -1
- package/package.json +5 -1
- package/dist/backends/local/config.d.ts +0 -38
- package/dist/backends/local/config.js +0 -42
- package/dist/backends/local/config.js.map +0 -1
- package/dist/backends/local/index.d.ts +0 -45
- package/dist/backends/local/index.js +0 -291
- package/dist/backends/local/index.js.map +0 -1
- package/dist/sync/queue.d.ts +0 -12
- package/dist/sync/queue.js +0 -56
- package/dist/sync/queue.js.map +0 -1
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
import { eq, and, isNull, isNotNull, inArray } from 'drizzle-orm';
|
|
6
|
+
import { BaseBackend } from '../backends/types.js';
|
|
7
|
+
import { createDatabase } from './db.js';
|
|
8
|
+
import * as schema from './schema.js';
|
|
9
|
+
import { insertConfigTx } from './config.js';
|
|
10
|
+
import { rowToWorkItem, rowToTemplate, } from './mappers.js';
|
|
11
|
+
const DEFAULT_STATUSES = ['backlog', 'todo', 'in-progress', 'review', 'done'];
|
|
12
|
+
const DEFAULT_TYPES = ['epic', 'issue', 'task'];
|
|
13
|
+
const DEFAULT_ITERATIONS = ['default'];
|
|
14
|
+
const DEFAULT_CURRENT_ITERATION = 'default';
|
|
15
|
+
const DEFAULT_NEXT_ID = 1;
|
|
16
|
+
const DEFAULT_BRANCH_MODE = 'worktree';
|
|
17
|
+
const DEFAULT_AUTO_UPDATE = true;
|
|
18
|
+
const DEFAULT_BRANCH_COMMAND = `claude "Brainstorm the implementation of issue #$TIC_ITEM_ID: $TIC_ITEM_TITLE. $TIC_ITEM_DESCRIPTION"`;
|
|
19
|
+
const DEFAULT_COPY_TO_CLIPBOARD = true;
|
|
20
|
+
export class Storage extends BaseBackend {
|
|
21
|
+
db;
|
|
22
|
+
root;
|
|
23
|
+
tempIds;
|
|
24
|
+
constructor(db, root, options) {
|
|
25
|
+
super(0); // No TTL — DB is always fresh
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.root = root;
|
|
28
|
+
this.tempIds = options?.tempIds ?? false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a Storage instance, initializing the database and seeding defaults.
|
|
32
|
+
*/
|
|
33
|
+
static create(root, options) {
|
|
34
|
+
const db = createDatabase(root);
|
|
35
|
+
const backend = new Storage(db, root, options);
|
|
36
|
+
backend.seedDefaults();
|
|
37
|
+
backend.migrateFromYaml();
|
|
38
|
+
return backend;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create a Storage instance from an existing database instance (for testing).
|
|
42
|
+
*/
|
|
43
|
+
static createFromDb(db, options) {
|
|
44
|
+
const backend = new Storage(db, ':memory:', options);
|
|
45
|
+
backend.seedDefaults();
|
|
46
|
+
return backend;
|
|
47
|
+
}
|
|
48
|
+
getDatabase() {
|
|
49
|
+
return this.db;
|
|
50
|
+
}
|
|
51
|
+
getRoot() {
|
|
52
|
+
return this.root;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Seed default configuration, statuses, types, and iterations using INSERT OR IGNORE.
|
|
56
|
+
*/
|
|
57
|
+
seedDefaults() {
|
|
58
|
+
// Seed project config (id=1) only if not already present
|
|
59
|
+
this.db
|
|
60
|
+
.insert(schema.projectConfig)
|
|
61
|
+
.values({
|
|
62
|
+
id: 1,
|
|
63
|
+
backend: 'drizzle',
|
|
64
|
+
currentIteration: DEFAULT_CURRENT_ITERATION,
|
|
65
|
+
nextId: DEFAULT_NEXT_ID,
|
|
66
|
+
branchMode: DEFAULT_BRANCH_MODE,
|
|
67
|
+
branchCommand: DEFAULT_BRANCH_COMMAND,
|
|
68
|
+
copyToClipboard: DEFAULT_COPY_TO_CLIPBOARD,
|
|
69
|
+
autoUpdate: DEFAULT_AUTO_UPDATE,
|
|
70
|
+
})
|
|
71
|
+
.onConflictDoNothing()
|
|
72
|
+
.run();
|
|
73
|
+
// Seed statuses
|
|
74
|
+
for (let i = 0; i < DEFAULT_STATUSES.length; i++) {
|
|
75
|
+
this.db
|
|
76
|
+
.insert(schema.statuses)
|
|
77
|
+
.values({ name: DEFAULT_STATUSES[i], sortOrder: i })
|
|
78
|
+
.onConflictDoNothing()
|
|
79
|
+
.run();
|
|
80
|
+
}
|
|
81
|
+
// Seed types
|
|
82
|
+
for (let i = 0; i < DEFAULT_TYPES.length; i++) {
|
|
83
|
+
this.db
|
|
84
|
+
.insert(schema.workItemTypes)
|
|
85
|
+
.values({ name: DEFAULT_TYPES[i], sortOrder: i })
|
|
86
|
+
.onConflictDoNothing()
|
|
87
|
+
.run();
|
|
88
|
+
}
|
|
89
|
+
// Seed iterations
|
|
90
|
+
for (let i = 0; i < DEFAULT_ITERATIONS.length; i++) {
|
|
91
|
+
this.db
|
|
92
|
+
.insert(schema.iterations)
|
|
93
|
+
.values({ name: DEFAULT_ITERATIONS[i], sortOrder: i })
|
|
94
|
+
.onConflictDoNothing()
|
|
95
|
+
.run();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* If a legacy config.yml exists and the DB still has seed defaults,
|
|
100
|
+
* migrate the YAML config into the database and rename the file.
|
|
101
|
+
*/
|
|
102
|
+
migrateFromYaml() {
|
|
103
|
+
if (this.root === ':memory:')
|
|
104
|
+
return;
|
|
105
|
+
const yamlPath = path.join(this.root, '.tic', 'config.yml');
|
|
106
|
+
if (!fs.existsSync(yamlPath))
|
|
107
|
+
return;
|
|
108
|
+
// Only migrate if DB still has the seed default backend ('drizzle')
|
|
109
|
+
const row = this.db
|
|
110
|
+
.select({ backend: schema.projectConfig.backend })
|
|
111
|
+
.from(schema.projectConfig)
|
|
112
|
+
.where(eq(schema.projectConfig.id, 1))
|
|
113
|
+
.get();
|
|
114
|
+
if (row?.backend !== 'drizzle')
|
|
115
|
+
return;
|
|
116
|
+
try {
|
|
117
|
+
const raw = fs.readFileSync(yamlPath, 'utf-8');
|
|
118
|
+
const config = yaml.parse(raw);
|
|
119
|
+
this.db.transaction((tx) => {
|
|
120
|
+
insertConfigTx(tx, config);
|
|
121
|
+
});
|
|
122
|
+
fs.renameSync(yamlPath, yamlPath + '.migrated');
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Migration failure is not fatal — continue with DB defaults
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Close the database connection. Call this when done with the backend.
|
|
130
|
+
*/
|
|
131
|
+
destroy() {
|
|
132
|
+
this.db.close();
|
|
133
|
+
}
|
|
134
|
+
// ─── Capabilities ───────────────────────────────────────────────────
|
|
135
|
+
getCapabilities() {
|
|
136
|
+
return {
|
|
137
|
+
relationships: true,
|
|
138
|
+
customTypes: true,
|
|
139
|
+
customStatuses: true,
|
|
140
|
+
iterations: true,
|
|
141
|
+
comments: true,
|
|
142
|
+
fields: {
|
|
143
|
+
priority: true,
|
|
144
|
+
assignee: true,
|
|
145
|
+
labels: true,
|
|
146
|
+
parent: true,
|
|
147
|
+
dependsOn: true,
|
|
148
|
+
},
|
|
149
|
+
templates: true,
|
|
150
|
+
templateFields: {
|
|
151
|
+
type: true,
|
|
152
|
+
status: true,
|
|
153
|
+
priority: true,
|
|
154
|
+
assignee: true,
|
|
155
|
+
labels: true,
|
|
156
|
+
iteration: true,
|
|
157
|
+
parent: true,
|
|
158
|
+
dependsOn: true,
|
|
159
|
+
description: true,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// ─── Read: metadata lists ──────────────────────────────────────────
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
165
|
+
async getStatuses() {
|
|
166
|
+
const rows = this.db
|
|
167
|
+
.select()
|
|
168
|
+
.from(schema.statuses)
|
|
169
|
+
.orderBy(schema.statuses.sortOrder)
|
|
170
|
+
.all();
|
|
171
|
+
return rows.map((r) => r.name);
|
|
172
|
+
}
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
174
|
+
async getIterations() {
|
|
175
|
+
const rows = this.db
|
|
176
|
+
.select()
|
|
177
|
+
.from(schema.iterations)
|
|
178
|
+
.orderBy(schema.iterations.sortOrder)
|
|
179
|
+
.all();
|
|
180
|
+
return rows.map((r) => r.name);
|
|
181
|
+
}
|
|
182
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
183
|
+
async getWorkItemTypes() {
|
|
184
|
+
const rows = this.db
|
|
185
|
+
.select()
|
|
186
|
+
.from(schema.workItemTypes)
|
|
187
|
+
.orderBy(schema.workItemTypes.sortOrder)
|
|
188
|
+
.all();
|
|
189
|
+
return rows.map((r) => r.name);
|
|
190
|
+
}
|
|
191
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
192
|
+
async getAssignees() {
|
|
193
|
+
const rows = this.db
|
|
194
|
+
.select({ assignee: schema.workItems.assignee })
|
|
195
|
+
.from(schema.workItems)
|
|
196
|
+
.where(isNull(schema.workItems.deletedAt))
|
|
197
|
+
.all();
|
|
198
|
+
// Filter out empty assignees in JS (they don't represent real assignees)
|
|
199
|
+
const assignees = new Set();
|
|
200
|
+
for (const r of rows) {
|
|
201
|
+
if (r.assignee)
|
|
202
|
+
assignees.add(r.assignee);
|
|
203
|
+
}
|
|
204
|
+
return [...assignees].sort();
|
|
205
|
+
}
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
207
|
+
async getLabels() {
|
|
208
|
+
// Get labels only for non-deleted items
|
|
209
|
+
const rows = this.db
|
|
210
|
+
.select({ label: schema.workItemLabels.label })
|
|
211
|
+
.from(schema.workItemLabels)
|
|
212
|
+
.innerJoin(schema.workItems, eq(schema.workItemLabels.workItemId, schema.workItems.id))
|
|
213
|
+
.where(isNull(schema.workItems.deletedAt))
|
|
214
|
+
.all();
|
|
215
|
+
const labels = new Set();
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
labels.add(r.label);
|
|
218
|
+
}
|
|
219
|
+
return [...labels].sort();
|
|
220
|
+
}
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
222
|
+
async getCurrentIteration() {
|
|
223
|
+
const row = this.db
|
|
224
|
+
.select({ currentIteration: schema.projectConfig.currentIteration })
|
|
225
|
+
.from(schema.projectConfig)
|
|
226
|
+
.where(eq(schema.projectConfig.id, 1))
|
|
227
|
+
.get();
|
|
228
|
+
return row?.currentIteration ?? DEFAULT_CURRENT_ITERATION;
|
|
229
|
+
}
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
231
|
+
async setCurrentIteration(name) {
|
|
232
|
+
// Ensure iteration exists
|
|
233
|
+
this.db
|
|
234
|
+
.insert(schema.iterations)
|
|
235
|
+
.values({ name, sortOrder: 0 })
|
|
236
|
+
.onConflictDoNothing()
|
|
237
|
+
.run();
|
|
238
|
+
this.db
|
|
239
|
+
.update(schema.projectConfig)
|
|
240
|
+
.set({ currentIteration: name })
|
|
241
|
+
.where(eq(schema.projectConfig.id, 1))
|
|
242
|
+
.run();
|
|
243
|
+
}
|
|
244
|
+
// ─── Read: work items ──────────────────────────────────────────────
|
|
245
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
246
|
+
async listWorkItems(iteration) {
|
|
247
|
+
const itemRows = iteration
|
|
248
|
+
? this.db
|
|
249
|
+
.select()
|
|
250
|
+
.from(schema.workItems)
|
|
251
|
+
.where(and(isNull(schema.workItems.deletedAt), eq(schema.workItems.iteration, iteration)))
|
|
252
|
+
.all()
|
|
253
|
+
: this.db
|
|
254
|
+
.select()
|
|
255
|
+
.from(schema.workItems)
|
|
256
|
+
.where(isNull(schema.workItems.deletedAt))
|
|
257
|
+
.all();
|
|
258
|
+
if (itemRows.length === 0)
|
|
259
|
+
return [];
|
|
260
|
+
return this.assembleWorkItems(itemRows);
|
|
261
|
+
}
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
263
|
+
async getWorkItem(id) {
|
|
264
|
+
const row = this.db
|
|
265
|
+
.select()
|
|
266
|
+
.from(schema.workItems)
|
|
267
|
+
.where(and(eq(schema.workItems.id, id), isNull(schema.workItems.deletedAt)))
|
|
268
|
+
.get();
|
|
269
|
+
if (!row) {
|
|
270
|
+
throw new Error(`Work item #${id} not found`);
|
|
271
|
+
}
|
|
272
|
+
const labels = this.db
|
|
273
|
+
.select()
|
|
274
|
+
.from(schema.workItemLabels)
|
|
275
|
+
.where(eq(schema.workItemLabels.workItemId, id))
|
|
276
|
+
.all();
|
|
277
|
+
const deps = this.db
|
|
278
|
+
.select()
|
|
279
|
+
.from(schema.workItemDeps)
|
|
280
|
+
.where(eq(schema.workItemDeps.workItemId, id))
|
|
281
|
+
.all();
|
|
282
|
+
const itemComments = this.db
|
|
283
|
+
.select()
|
|
284
|
+
.from(schema.comments)
|
|
285
|
+
.where(eq(schema.comments.workItemId, id))
|
|
286
|
+
.all();
|
|
287
|
+
return rowToWorkItem(row, labels, deps, itemComments);
|
|
288
|
+
}
|
|
289
|
+
// ─── Read: relationships (SQL-optimized overrides) ─────────────────
|
|
290
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
291
|
+
async getChildren(id) {
|
|
292
|
+
const childRows = this.db
|
|
293
|
+
.select()
|
|
294
|
+
.from(schema.workItems)
|
|
295
|
+
.where(and(eq(schema.workItems.parent, id), isNull(schema.workItems.deletedAt)))
|
|
296
|
+
.all();
|
|
297
|
+
if (childRows.length === 0)
|
|
298
|
+
return [];
|
|
299
|
+
return this.assembleWorkItems(childRows);
|
|
300
|
+
}
|
|
301
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
302
|
+
async getDependents(id) {
|
|
303
|
+
// Find items that depend on `id`
|
|
304
|
+
const depRows = this.db
|
|
305
|
+
.select({ workItemId: schema.workItemDeps.workItemId })
|
|
306
|
+
.from(schema.workItemDeps)
|
|
307
|
+
.where(eq(schema.workItemDeps.dependsOnId, id))
|
|
308
|
+
.all();
|
|
309
|
+
if (depRows.length === 0)
|
|
310
|
+
return [];
|
|
311
|
+
const dependentIds = depRows.map((r) => r.workItemId);
|
|
312
|
+
const itemRows = this.db
|
|
313
|
+
.select()
|
|
314
|
+
.from(schema.workItems)
|
|
315
|
+
.where(and(inArray(schema.workItems.id, dependentIds), isNull(schema.workItems.deletedAt)))
|
|
316
|
+
.all();
|
|
317
|
+
if (itemRows.length === 0)
|
|
318
|
+
return [];
|
|
319
|
+
return this.assembleWorkItems(itemRows);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Helper: given a set of work item rows, fetch their labels/deps/comments and assemble.
|
|
323
|
+
*/
|
|
324
|
+
assembleWorkItems(itemRows) {
|
|
325
|
+
const itemIds = itemRows.map((r) => r.id);
|
|
326
|
+
const labelRows = this.db
|
|
327
|
+
.select()
|
|
328
|
+
.from(schema.workItemLabels)
|
|
329
|
+
.where(inArray(schema.workItemLabels.workItemId, itemIds))
|
|
330
|
+
.all();
|
|
331
|
+
const depRows = this.db
|
|
332
|
+
.select()
|
|
333
|
+
.from(schema.workItemDeps)
|
|
334
|
+
.where(inArray(schema.workItemDeps.workItemId, itemIds))
|
|
335
|
+
.all();
|
|
336
|
+
const commentRows = this.db
|
|
337
|
+
.select()
|
|
338
|
+
.from(schema.comments)
|
|
339
|
+
.where(inArray(schema.comments.workItemId, itemIds))
|
|
340
|
+
.all();
|
|
341
|
+
const labelsByItem = new Map();
|
|
342
|
+
for (const l of labelRows) {
|
|
343
|
+
const arr = labelsByItem.get(l.workItemId);
|
|
344
|
+
if (arr)
|
|
345
|
+
arr.push(l);
|
|
346
|
+
else
|
|
347
|
+
labelsByItem.set(l.workItemId, [l]);
|
|
348
|
+
}
|
|
349
|
+
const depsByItem = new Map();
|
|
350
|
+
for (const d of depRows) {
|
|
351
|
+
const arr = depsByItem.get(d.workItemId);
|
|
352
|
+
if (arr)
|
|
353
|
+
arr.push(d);
|
|
354
|
+
else
|
|
355
|
+
depsByItem.set(d.workItemId, [d]);
|
|
356
|
+
}
|
|
357
|
+
const commentsByItem = new Map();
|
|
358
|
+
for (const c of commentRows) {
|
|
359
|
+
const arr = commentsByItem.get(c.workItemId);
|
|
360
|
+
if (arr)
|
|
361
|
+
arr.push(c);
|
|
362
|
+
else
|
|
363
|
+
commentsByItem.set(c.workItemId, [c]);
|
|
364
|
+
}
|
|
365
|
+
return itemRows.map((row) => rowToWorkItem(row, labelsByItem.get(row.id) ?? [], depsByItem.get(row.id) ?? [], commentsByItem.get(row.id) ?? []));
|
|
366
|
+
}
|
|
367
|
+
// ─── Read: item URL ────────────────────────────────────────────────
|
|
368
|
+
getItemUrl(id) {
|
|
369
|
+
return `${this.root}/.tic/items/${id}.md`;
|
|
370
|
+
}
|
|
371
|
+
// ─── Relationship validation ─────────────────────────────────────
|
|
372
|
+
validateRelationships(id, parent, dependsOn) {
|
|
373
|
+
// Validate parent
|
|
374
|
+
if (parent !== null && parent !== undefined) {
|
|
375
|
+
if (parent === id) {
|
|
376
|
+
throw new Error(`Work item #${id} cannot be its own parent`);
|
|
377
|
+
}
|
|
378
|
+
const parentRow = this.db
|
|
379
|
+
.select({ id: schema.workItems.id })
|
|
380
|
+
.from(schema.workItems)
|
|
381
|
+
.where(and(eq(schema.workItems.id, parent), isNull(schema.workItems.deletedAt)))
|
|
382
|
+
.get();
|
|
383
|
+
if (!parentRow) {
|
|
384
|
+
throw new Error(`Parent #${parent} does not exist`);
|
|
385
|
+
}
|
|
386
|
+
// Walk up the parent chain to detect circular references
|
|
387
|
+
let current = parent;
|
|
388
|
+
const visited = new Set();
|
|
389
|
+
while (current !== null) {
|
|
390
|
+
if (current === id) {
|
|
391
|
+
throw new Error(`Circular parent chain detected for #${id}`);
|
|
392
|
+
}
|
|
393
|
+
if (visited.has(current))
|
|
394
|
+
break;
|
|
395
|
+
visited.add(current);
|
|
396
|
+
const row = this.db
|
|
397
|
+
.select({ parent: schema.workItems.parent })
|
|
398
|
+
.from(schema.workItems)
|
|
399
|
+
.where(and(eq(schema.workItems.id, current), isNull(schema.workItems.deletedAt)))
|
|
400
|
+
.get();
|
|
401
|
+
current = row?.parent ?? null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Validate dependencies
|
|
405
|
+
if (dependsOn !== undefined && dependsOn.length > 0) {
|
|
406
|
+
for (const depId of dependsOn) {
|
|
407
|
+
if (depId === id) {
|
|
408
|
+
throw new Error(`Work item #${id} cannot depend on itself`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Check all deps exist in one query
|
|
412
|
+
const existingRows = this.db
|
|
413
|
+
.select({ id: schema.workItems.id })
|
|
414
|
+
.from(schema.workItems)
|
|
415
|
+
.where(and(inArray(schema.workItems.id, dependsOn), isNull(schema.workItems.deletedAt)))
|
|
416
|
+
.all();
|
|
417
|
+
const existingIds = new Set(existingRows.map((r) => r.id));
|
|
418
|
+
for (const depId of dependsOn) {
|
|
419
|
+
if (!existingIds.has(depId)) {
|
|
420
|
+
throw new Error(`Dependency #${depId} does not exist`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Check for circular dependency chains
|
|
424
|
+
const hasCycle = (startId, targetId) => {
|
|
425
|
+
const visited = new Set();
|
|
426
|
+
const stack = [startId];
|
|
427
|
+
while (stack.length > 0) {
|
|
428
|
+
const current = stack.pop();
|
|
429
|
+
if (current === targetId)
|
|
430
|
+
return true;
|
|
431
|
+
if (visited.has(current))
|
|
432
|
+
continue;
|
|
433
|
+
visited.add(current);
|
|
434
|
+
const deps = this.db
|
|
435
|
+
.select({ dependsOnId: schema.workItemDeps.dependsOnId })
|
|
436
|
+
.from(schema.workItemDeps)
|
|
437
|
+
.where(eq(schema.workItemDeps.workItemId, current))
|
|
438
|
+
.all();
|
|
439
|
+
for (const dep of deps) {
|
|
440
|
+
stack.push(dep.dependsOnId);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
};
|
|
445
|
+
for (const depId of dependsOn) {
|
|
446
|
+
if (hasCycle(depId, id)) {
|
|
447
|
+
throw new Error(`Circular dependency chain detected for #${id}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// ─── Write: createWorkItem ────────────────────────────────────────
|
|
453
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
454
|
+
async createWorkItem(data) {
|
|
455
|
+
this.validateFields(data);
|
|
456
|
+
const now = new Date().toISOString();
|
|
457
|
+
// Get and increment nextId
|
|
458
|
+
const config = this.db
|
|
459
|
+
.select()
|
|
460
|
+
.from(schema.projectConfig)
|
|
461
|
+
.where(eq(schema.projectConfig.id, 1))
|
|
462
|
+
.get();
|
|
463
|
+
const nextId = config?.nextId ?? 1;
|
|
464
|
+
const id = this.tempIds ? `local-${nextId}` : String(nextId);
|
|
465
|
+
// Validate relationships before inserting
|
|
466
|
+
this.validateRelationships(id, data.parent, data.dependsOn);
|
|
467
|
+
this.db.transaction((tx) => {
|
|
468
|
+
tx.update(schema.projectConfig)
|
|
469
|
+
.set({ nextId: nextId + 1 })
|
|
470
|
+
.where(eq(schema.projectConfig.id, 1))
|
|
471
|
+
.run();
|
|
472
|
+
// Ensure iteration exists
|
|
473
|
+
if (data.iteration) {
|
|
474
|
+
tx.insert(schema.iterations)
|
|
475
|
+
.values({ name: data.iteration, sortOrder: 0 })
|
|
476
|
+
.onConflictDoNothing()
|
|
477
|
+
.run();
|
|
478
|
+
}
|
|
479
|
+
// Insert work item
|
|
480
|
+
tx.insert(schema.workItems)
|
|
481
|
+
.values({
|
|
482
|
+
id,
|
|
483
|
+
title: data.title,
|
|
484
|
+
type: data.type,
|
|
485
|
+
status: data.status,
|
|
486
|
+
iteration: data.iteration,
|
|
487
|
+
priority: data.priority,
|
|
488
|
+
assignee: data.assignee,
|
|
489
|
+
description: data.description,
|
|
490
|
+
parent: data.parent,
|
|
491
|
+
created: now,
|
|
492
|
+
updated: now,
|
|
493
|
+
})
|
|
494
|
+
.run();
|
|
495
|
+
// Insert labels
|
|
496
|
+
if (data.labels.length > 0) {
|
|
497
|
+
tx.insert(schema.workItemLabels)
|
|
498
|
+
.values(data.labels.map((label) => ({ workItemId: id, label })))
|
|
499
|
+
.run();
|
|
500
|
+
}
|
|
501
|
+
// Insert deps
|
|
502
|
+
if (data.dependsOn.length > 0) {
|
|
503
|
+
tx.insert(schema.workItemDeps)
|
|
504
|
+
.values(data.dependsOn.map((dependsOnId) => ({
|
|
505
|
+
workItemId: id,
|
|
506
|
+
dependsOnId,
|
|
507
|
+
})))
|
|
508
|
+
.run();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
this.invalidateCache();
|
|
512
|
+
return {
|
|
513
|
+
id,
|
|
514
|
+
title: data.title,
|
|
515
|
+
type: data.type,
|
|
516
|
+
status: data.status,
|
|
517
|
+
iteration: data.iteration,
|
|
518
|
+
priority: data.priority,
|
|
519
|
+
assignee: data.assignee,
|
|
520
|
+
labels: [...data.labels],
|
|
521
|
+
description: data.description,
|
|
522
|
+
parent: data.parent,
|
|
523
|
+
dependsOn: [...data.dependsOn],
|
|
524
|
+
created: now,
|
|
525
|
+
updated: now,
|
|
526
|
+
comments: [],
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
// ─── Write: importWorkItem (for sync) ───────────────────────────────
|
|
530
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
531
|
+
async importWorkItem(item) {
|
|
532
|
+
this.db.transaction((tx) => {
|
|
533
|
+
// Ensure iteration exists
|
|
534
|
+
if (item.iteration) {
|
|
535
|
+
tx.insert(schema.iterations)
|
|
536
|
+
.values({ name: item.iteration, sortOrder: 0 })
|
|
537
|
+
.onConflictDoNothing()
|
|
538
|
+
.run();
|
|
539
|
+
}
|
|
540
|
+
// Upsert work item
|
|
541
|
+
tx.insert(schema.workItems)
|
|
542
|
+
.values({
|
|
543
|
+
id: item.id,
|
|
544
|
+
title: item.title,
|
|
545
|
+
type: item.type,
|
|
546
|
+
status: item.status,
|
|
547
|
+
iteration: item.iteration,
|
|
548
|
+
priority: item.priority,
|
|
549
|
+
assignee: item.assignee,
|
|
550
|
+
description: item.description,
|
|
551
|
+
parent: item.parent,
|
|
552
|
+
created: item.created,
|
|
553
|
+
updated: item.updated,
|
|
554
|
+
})
|
|
555
|
+
.onConflictDoUpdate({
|
|
556
|
+
target: schema.workItems.id,
|
|
557
|
+
set: {
|
|
558
|
+
title: item.title,
|
|
559
|
+
type: item.type,
|
|
560
|
+
status: item.status,
|
|
561
|
+
iteration: item.iteration,
|
|
562
|
+
priority: item.priority,
|
|
563
|
+
assignee: item.assignee,
|
|
564
|
+
description: item.description,
|
|
565
|
+
parent: item.parent,
|
|
566
|
+
created: item.created,
|
|
567
|
+
updated: item.updated,
|
|
568
|
+
},
|
|
569
|
+
})
|
|
570
|
+
.run();
|
|
571
|
+
// Replace labels
|
|
572
|
+
tx.delete(schema.workItemLabels)
|
|
573
|
+
.where(eq(schema.workItemLabels.workItemId, item.id))
|
|
574
|
+
.run();
|
|
575
|
+
if (item.labels.length > 0) {
|
|
576
|
+
tx.insert(schema.workItemLabels)
|
|
577
|
+
.values(item.labels.map((label) => ({ workItemId: item.id, label })))
|
|
578
|
+
.run();
|
|
579
|
+
}
|
|
580
|
+
// Replace deps
|
|
581
|
+
tx.delete(schema.workItemDeps)
|
|
582
|
+
.where(eq(schema.workItemDeps.workItemId, item.id))
|
|
583
|
+
.run();
|
|
584
|
+
if (item.dependsOn.length > 0) {
|
|
585
|
+
tx.insert(schema.workItemDeps)
|
|
586
|
+
.values(item.dependsOn.map((dependsOnId) => ({
|
|
587
|
+
workItemId: item.id,
|
|
588
|
+
dependsOnId,
|
|
589
|
+
})))
|
|
590
|
+
.run();
|
|
591
|
+
}
|
|
592
|
+
// Replace comments
|
|
593
|
+
tx.delete(schema.comments)
|
|
594
|
+
.where(eq(schema.comments.workItemId, item.id))
|
|
595
|
+
.run();
|
|
596
|
+
if (item.comments.length > 0) {
|
|
597
|
+
for (const c of item.comments) {
|
|
598
|
+
tx.insert(schema.comments)
|
|
599
|
+
.values({
|
|
600
|
+
workItemId: item.id,
|
|
601
|
+
author: c.author,
|
|
602
|
+
body: c.body,
|
|
603
|
+
created: c.date,
|
|
604
|
+
})
|
|
605
|
+
.run();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
this.invalidateCache();
|
|
610
|
+
return item;
|
|
611
|
+
}
|
|
612
|
+
// ─── Write: updateWorkItem ────────────────────────────────────────
|
|
613
|
+
async updateWorkItem(id, data) {
|
|
614
|
+
this.validateFields(data);
|
|
615
|
+
// 1. Read existing item (throw if not found)
|
|
616
|
+
const existingRow = this.db
|
|
617
|
+
.select()
|
|
618
|
+
.from(schema.workItems)
|
|
619
|
+
.where(and(eq(schema.workItems.id, id), isNull(schema.workItems.deletedAt)))
|
|
620
|
+
.get();
|
|
621
|
+
if (!existingRow) {
|
|
622
|
+
throw new Error(`Work item #${id} not found`);
|
|
623
|
+
}
|
|
624
|
+
// 2. Validate relationships if parent/dependsOn changed
|
|
625
|
+
const newParent = 'parent' in data ? data.parent : undefined;
|
|
626
|
+
const newDepsOn = 'dependsOn' in data ? data.dependsOn : undefined;
|
|
627
|
+
if (newParent !== undefined || newDepsOn !== undefined) {
|
|
628
|
+
this.validateRelationships(id, newParent !== undefined ? (newParent ?? null) : undefined, newDepsOn);
|
|
629
|
+
}
|
|
630
|
+
const now = new Date().toISOString();
|
|
631
|
+
// 3. In a transaction: update workItems row, delete+re-insert labels/deps
|
|
632
|
+
this.db.transaction((tx) => {
|
|
633
|
+
// Build the set of fields to update on the work_items row
|
|
634
|
+
const updateSet = { updated: now };
|
|
635
|
+
if ('title' in data)
|
|
636
|
+
updateSet['title'] = data.title;
|
|
637
|
+
if ('type' in data)
|
|
638
|
+
updateSet['type'] = data.type;
|
|
639
|
+
if ('status' in data)
|
|
640
|
+
updateSet['status'] = data.status;
|
|
641
|
+
if ('iteration' in data)
|
|
642
|
+
updateSet['iteration'] = data.iteration;
|
|
643
|
+
if ('priority' in data)
|
|
644
|
+
updateSet['priority'] = data.priority;
|
|
645
|
+
if ('assignee' in data)
|
|
646
|
+
updateSet['assignee'] = data.assignee;
|
|
647
|
+
if ('description' in data)
|
|
648
|
+
updateSet['description'] = data.description;
|
|
649
|
+
if ('parent' in data)
|
|
650
|
+
updateSet['parent'] = data.parent ?? null;
|
|
651
|
+
tx.update(schema.workItems)
|
|
652
|
+
.set(updateSet)
|
|
653
|
+
.where(eq(schema.workItems.id, id))
|
|
654
|
+
.run();
|
|
655
|
+
// Replace labels if changed
|
|
656
|
+
if ('labels' in data && data.labels !== undefined) {
|
|
657
|
+
tx.delete(schema.workItemLabels)
|
|
658
|
+
.where(eq(schema.workItemLabels.workItemId, id))
|
|
659
|
+
.run();
|
|
660
|
+
if (data.labels.length > 0) {
|
|
661
|
+
tx.insert(schema.workItemLabels)
|
|
662
|
+
.values(data.labels.map((label) => ({ workItemId: id, label })))
|
|
663
|
+
.run();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Replace deps if changed
|
|
667
|
+
if ('dependsOn' in data && data.dependsOn !== undefined) {
|
|
668
|
+
tx.delete(schema.workItemDeps)
|
|
669
|
+
.where(eq(schema.workItemDeps.workItemId, id))
|
|
670
|
+
.run();
|
|
671
|
+
if (data.dependsOn.length > 0) {
|
|
672
|
+
tx.insert(schema.workItemDeps)
|
|
673
|
+
.values(data.dependsOn.map((dependsOnId) => ({
|
|
674
|
+
workItemId: id,
|
|
675
|
+
dependsOnId,
|
|
676
|
+
})))
|
|
677
|
+
.run();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Ensure iteration exists if changed
|
|
681
|
+
if ('iteration' in data && data.iteration) {
|
|
682
|
+
tx.insert(schema.iterations)
|
|
683
|
+
.values({ name: data.iteration, sortOrder: 0 })
|
|
684
|
+
.onConflictDoNothing()
|
|
685
|
+
.run();
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
this.invalidateCache();
|
|
689
|
+
// 4. Return updated item
|
|
690
|
+
return this.getWorkItem(id);
|
|
691
|
+
}
|
|
692
|
+
// ─── Write: deleteWorkItem ────────────────────────────────────────
|
|
693
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
694
|
+
async deleteWorkItem(id) {
|
|
695
|
+
this.db.transaction((tx) => {
|
|
696
|
+
// 1. Null out parent on children
|
|
697
|
+
tx.update(schema.workItems)
|
|
698
|
+
.set({ parent: null })
|
|
699
|
+
.where(eq(schema.workItems.parent, id))
|
|
700
|
+
.run();
|
|
701
|
+
// 2. Remove deps referencing this item (other items depending on this one)
|
|
702
|
+
tx.delete(schema.workItemDeps)
|
|
703
|
+
.where(eq(schema.workItemDeps.dependsOnId, id))
|
|
704
|
+
.run();
|
|
705
|
+
// 3. Delete the item (cascade handles labels, deps, comments of this item)
|
|
706
|
+
tx.delete(schema.workItems).where(eq(schema.workItems.id, id)).run();
|
|
707
|
+
});
|
|
708
|
+
this.invalidateCache();
|
|
709
|
+
}
|
|
710
|
+
// ─── Write: soft delete ───────────────────────────────────────────
|
|
711
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
712
|
+
async softDeleteWorkItem(id) {
|
|
713
|
+
const now = new Date().toISOString();
|
|
714
|
+
this.db
|
|
715
|
+
.update(schema.workItems)
|
|
716
|
+
.set({ deletedAt: now })
|
|
717
|
+
.where(eq(schema.workItems.id, id))
|
|
718
|
+
.run();
|
|
719
|
+
this.invalidateCache();
|
|
720
|
+
}
|
|
721
|
+
// ─── Write: addComment ────────────────────────────────────────────
|
|
722
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
723
|
+
async addComment(workItemId, comment) {
|
|
724
|
+
// 1. Verify item exists
|
|
725
|
+
const row = this.db
|
|
726
|
+
.select({ id: schema.workItems.id })
|
|
727
|
+
.from(schema.workItems)
|
|
728
|
+
.where(and(eq(schema.workItems.id, workItemId), isNull(schema.workItems.deletedAt)))
|
|
729
|
+
.get();
|
|
730
|
+
if (!row) {
|
|
731
|
+
throw new Error(`Work item #${workItemId} not found`);
|
|
732
|
+
}
|
|
733
|
+
// 2. Insert comment (do NOT update work item's updated timestamp)
|
|
734
|
+
const now = new Date().toISOString();
|
|
735
|
+
this.db
|
|
736
|
+
.insert(schema.comments)
|
|
737
|
+
.values({
|
|
738
|
+
workItemId,
|
|
739
|
+
author: comment.author,
|
|
740
|
+
body: comment.body,
|
|
741
|
+
created: now,
|
|
742
|
+
})
|
|
743
|
+
.run();
|
|
744
|
+
this.invalidateCache();
|
|
745
|
+
return {
|
|
746
|
+
author: comment.author,
|
|
747
|
+
date: now,
|
|
748
|
+
body: comment.body,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
// ─── Write: restore (undo soft delete) ──────────────────────────
|
|
752
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
753
|
+
async restoreWorkItem(id) {
|
|
754
|
+
this.db
|
|
755
|
+
.update(schema.workItems)
|
|
756
|
+
.set({ deletedAt: null })
|
|
757
|
+
.where(eq(schema.workItems.id, id))
|
|
758
|
+
.run();
|
|
759
|
+
this.invalidateCache();
|
|
760
|
+
}
|
|
761
|
+
// ─── Write: permanent delete ───────────────────────────────────
|
|
762
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
763
|
+
async permanentlyDeleteWorkItem(id) {
|
|
764
|
+
this.db.delete(schema.workItems).where(eq(schema.workItems.id, id)).run();
|
|
765
|
+
this.invalidateCache();
|
|
766
|
+
}
|
|
767
|
+
// ─── Write: cleanup all soft-deleted items ─────────────────────
|
|
768
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
769
|
+
async cleanupTrash() {
|
|
770
|
+
this.db
|
|
771
|
+
.delete(schema.workItems)
|
|
772
|
+
.where(isNotNull(schema.workItems.deletedAt))
|
|
773
|
+
.run();
|
|
774
|
+
this.invalidateCache();
|
|
775
|
+
}
|
|
776
|
+
// ─── Open item in editor ───────────────────────────────────────
|
|
777
|
+
async openItem(id) {
|
|
778
|
+
const filePath = this.getItemUrl(id);
|
|
779
|
+
const editor = process.env['VISUAL'] || process.env['EDITOR'] || 'vi';
|
|
780
|
+
return new Promise((resolve, reject) => {
|
|
781
|
+
const child = spawn(editor, [filePath], { stdio: 'inherit' });
|
|
782
|
+
child.on('close', (code) => {
|
|
783
|
+
if (code === 0)
|
|
784
|
+
resolve();
|
|
785
|
+
else
|
|
786
|
+
reject(new Error(`Editor exited with code ${code}`));
|
|
787
|
+
});
|
|
788
|
+
child.on('error', reject);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
// ─── Templates ─────────────────────────────────────────────────
|
|
792
|
+
/**
|
|
793
|
+
* Slugify a template name (same logic as LocalBackend).
|
|
794
|
+
*/
|
|
795
|
+
slugifyName(name) {
|
|
796
|
+
return name
|
|
797
|
+
.toLowerCase()
|
|
798
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
799
|
+
.replace(/[\s-]+/g, '-')
|
|
800
|
+
.replace(/^-+|-+$/g, '');
|
|
801
|
+
}
|
|
802
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
803
|
+
async listTemplates() {
|
|
804
|
+
const templateRows = this.db.select().from(schema.templates).all();
|
|
805
|
+
if (templateRows.length === 0)
|
|
806
|
+
return [];
|
|
807
|
+
const slugs = templateRows.map((r) => r.slug);
|
|
808
|
+
const labelRows = this.db
|
|
809
|
+
.select()
|
|
810
|
+
.from(schema.templateLabels)
|
|
811
|
+
.where(inArray(schema.templateLabels.templateSlug, slugs))
|
|
812
|
+
.all();
|
|
813
|
+
const depRows = this.db
|
|
814
|
+
.select()
|
|
815
|
+
.from(schema.templateDeps)
|
|
816
|
+
.where(inArray(schema.templateDeps.templateSlug, slugs))
|
|
817
|
+
.all();
|
|
818
|
+
const labelsBySlug = new Map();
|
|
819
|
+
for (const l of labelRows) {
|
|
820
|
+
const arr = labelsBySlug.get(l.templateSlug);
|
|
821
|
+
if (arr)
|
|
822
|
+
arr.push(l);
|
|
823
|
+
else
|
|
824
|
+
labelsBySlug.set(l.templateSlug, [l]);
|
|
825
|
+
}
|
|
826
|
+
const depsBySlug = new Map();
|
|
827
|
+
for (const d of depRows) {
|
|
828
|
+
const arr = depsBySlug.get(d.templateSlug);
|
|
829
|
+
if (arr)
|
|
830
|
+
arr.push(d);
|
|
831
|
+
else
|
|
832
|
+
depsBySlug.set(d.templateSlug, [d]);
|
|
833
|
+
}
|
|
834
|
+
return templateRows.map((row) => rowToTemplate(row, labelsBySlug.get(row.slug) ?? [], depsBySlug.get(row.slug) ?? []));
|
|
835
|
+
}
|
|
836
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
837
|
+
async getTemplate(slug) {
|
|
838
|
+
const row = this.db
|
|
839
|
+
.select()
|
|
840
|
+
.from(schema.templates)
|
|
841
|
+
.where(eq(schema.templates.slug, slug))
|
|
842
|
+
.get();
|
|
843
|
+
if (!row) {
|
|
844
|
+
throw new Error(`Template '${slug}' not found`);
|
|
845
|
+
}
|
|
846
|
+
const labels = this.db
|
|
847
|
+
.select()
|
|
848
|
+
.from(schema.templateLabels)
|
|
849
|
+
.where(eq(schema.templateLabels.templateSlug, slug))
|
|
850
|
+
.all();
|
|
851
|
+
const deps = this.db
|
|
852
|
+
.select()
|
|
853
|
+
.from(schema.templateDeps)
|
|
854
|
+
.where(eq(schema.templateDeps.templateSlug, slug))
|
|
855
|
+
.all();
|
|
856
|
+
return rowToTemplate(row, labels, deps);
|
|
857
|
+
}
|
|
858
|
+
async createTemplate(template) {
|
|
859
|
+
const slug = this.slugifyName(template.name);
|
|
860
|
+
this.db.transaction((tx) => {
|
|
861
|
+
tx.insert(schema.templates)
|
|
862
|
+
.values({
|
|
863
|
+
slug,
|
|
864
|
+
name: template.name,
|
|
865
|
+
type: template.type ?? '',
|
|
866
|
+
status: template.status ?? '',
|
|
867
|
+
priority: template.priority ?? '',
|
|
868
|
+
assignee: template.assignee ?? '',
|
|
869
|
+
iteration: template.iteration ?? '',
|
|
870
|
+
parent: template.parent ?? null,
|
|
871
|
+
description: template.description ?? '',
|
|
872
|
+
})
|
|
873
|
+
.run();
|
|
874
|
+
if (template.labels && template.labels.length > 0) {
|
|
875
|
+
tx.insert(schema.templateLabels)
|
|
876
|
+
.values(template.labels.map((label) => ({ templateSlug: slug, label })))
|
|
877
|
+
.run();
|
|
878
|
+
}
|
|
879
|
+
if (template.dependsOn && template.dependsOn.length > 0) {
|
|
880
|
+
tx.insert(schema.templateDeps)
|
|
881
|
+
.values(template.dependsOn.map((dependsOnId) => ({
|
|
882
|
+
templateSlug: slug,
|
|
883
|
+
dependsOnId,
|
|
884
|
+
})))
|
|
885
|
+
.run();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
return this.getTemplate(slug);
|
|
889
|
+
}
|
|
890
|
+
async updateTemplate(oldSlug, template) {
|
|
891
|
+
const newSlug = this.slugifyName(template.name);
|
|
892
|
+
this.db.transaction((tx) => {
|
|
893
|
+
if (oldSlug !== newSlug) {
|
|
894
|
+
// Slug changed: delete old (cascade handles labels/deps), insert new
|
|
895
|
+
tx.delete(schema.templates)
|
|
896
|
+
.where(eq(schema.templates.slug, oldSlug))
|
|
897
|
+
.run();
|
|
898
|
+
tx.insert(schema.templates)
|
|
899
|
+
.values({
|
|
900
|
+
slug: newSlug,
|
|
901
|
+
name: template.name,
|
|
902
|
+
type: template.type ?? '',
|
|
903
|
+
status: template.status ?? '',
|
|
904
|
+
priority: template.priority ?? '',
|
|
905
|
+
assignee: template.assignee ?? '',
|
|
906
|
+
iteration: template.iteration ?? '',
|
|
907
|
+
parent: template.parent ?? null,
|
|
908
|
+
description: template.description ?? '',
|
|
909
|
+
})
|
|
910
|
+
.run();
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
// Same slug: update in place
|
|
914
|
+
tx.update(schema.templates)
|
|
915
|
+
.set({
|
|
916
|
+
name: template.name,
|
|
917
|
+
type: template.type ?? '',
|
|
918
|
+
status: template.status ?? '',
|
|
919
|
+
priority: template.priority ?? '',
|
|
920
|
+
assignee: template.assignee ?? '',
|
|
921
|
+
iteration: template.iteration ?? '',
|
|
922
|
+
parent: template.parent ?? null,
|
|
923
|
+
description: template.description ?? '',
|
|
924
|
+
})
|
|
925
|
+
.where(eq(schema.templates.slug, oldSlug))
|
|
926
|
+
.run();
|
|
927
|
+
// Delete and re-insert labels
|
|
928
|
+
tx.delete(schema.templateLabels)
|
|
929
|
+
.where(eq(schema.templateLabels.templateSlug, oldSlug))
|
|
930
|
+
.run();
|
|
931
|
+
}
|
|
932
|
+
// Insert labels (for both new slug and same slug cases)
|
|
933
|
+
if (template.labels && template.labels.length > 0) {
|
|
934
|
+
tx.insert(schema.templateLabels)
|
|
935
|
+
.values(template.labels.map((label) => ({
|
|
936
|
+
templateSlug: newSlug,
|
|
937
|
+
label,
|
|
938
|
+
})))
|
|
939
|
+
.run();
|
|
940
|
+
}
|
|
941
|
+
// Delete and re-insert deps (only for same-slug; new slug cascade already handled it)
|
|
942
|
+
if (oldSlug === newSlug) {
|
|
943
|
+
tx.delete(schema.templateDeps)
|
|
944
|
+
.where(eq(schema.templateDeps.templateSlug, oldSlug))
|
|
945
|
+
.run();
|
|
946
|
+
}
|
|
947
|
+
if (template.dependsOn && template.dependsOn.length > 0) {
|
|
948
|
+
tx.insert(schema.templateDeps)
|
|
949
|
+
.values(template.dependsOn.map((dependsOnId) => ({
|
|
950
|
+
templateSlug: newSlug,
|
|
951
|
+
dependsOnId,
|
|
952
|
+
})))
|
|
953
|
+
.run();
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
return this.getTemplate(newSlug);
|
|
957
|
+
}
|
|
958
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
959
|
+
async deleteTemplate(slug) {
|
|
960
|
+
this.db
|
|
961
|
+
.delete(schema.templates)
|
|
962
|
+
.where(eq(schema.templates.slug, slug))
|
|
963
|
+
.run();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
//# sourceMappingURL=index.js.map
|