feinai 0.5.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/src/specs.ts ADDED
@@ -0,0 +1,289 @@
1
+ import type { DbInstance } from "./db";
2
+ import { recordEvent } from "./db";
3
+
4
+ export type SpecStatus = "lista" | "en_progreso" | "hecha" | "archivada";
5
+
6
+ export interface Spec {
7
+ id: string;
8
+ numero: number | null;
9
+ title: string;
10
+ status: SpecStatus;
11
+ content: string | null;
12
+ pr: string | null;
13
+ merged_date: string | null;
14
+ created_at: string;
15
+ updated_at: string;
16
+ }
17
+
18
+ export interface Plan {
19
+ id: number;
20
+ spec_id: string;
21
+ content: string;
22
+ version: number;
23
+ created_at: string;
24
+ }
25
+
26
+ export interface AddSpecInput {
27
+ id: string;
28
+ title: string;
29
+ numero?: number;
30
+ content?: string;
31
+ }
32
+
33
+ export function listSpecs(db: DbInstance, status?: SpecStatus): Spec[] {
34
+ const sql = status
35
+ ? "SELECT * FROM specs WHERE status = ? ORDER BY numero ASC"
36
+ : "SELECT * FROM specs ORDER BY numero ASC";
37
+ const args = status ? [status] : [];
38
+ return db.prepare(sql).all(...args) as Spec[];
39
+ }
40
+
41
+ export function getSpec(db: DbInstance, id: string): Spec | null {
42
+ return db.prepare("SELECT * FROM specs WHERE id = ?").get(id) as Spec | null;
43
+ }
44
+
45
+ export function addSpec(db: DbInstance, input: AddSpecInput, actor: string | null = null): Spec {
46
+ // Auto-extract numero from id if not provided (e.g., SPEC-121 → 121)
47
+ let numero = input.numero;
48
+ if (numero === undefined) {
49
+ const match = input.id.match(/(\d+)$/);
50
+ numero = match ? Number(match[1]) : null as unknown as number;
51
+ }
52
+
53
+ db.prepare(
54
+ `INSERT INTO specs (id, numero, title, content)
55
+ VALUES (?, ?, ?, ?)`,
56
+ ).run(input.id, numero ?? null, input.title, input.content ?? null);
57
+
58
+ recordEvent(db, "spec", input.id, "created", { title: input.title }, actor);
59
+
60
+ return getSpec(db, input.id) as Spec;
61
+ }
62
+
63
+ export function startSpec(db: DbInstance, id: string, actor: string | null = null): Spec {
64
+ const upd = db
65
+ .prepare(
66
+ `UPDATE specs SET status = 'en_progreso', updated_at = datetime('now')
67
+ WHERE id = ? AND status = 'lista'`,
68
+ )
69
+ .run(id);
70
+
71
+ if (upd.changes === 0) {
72
+ const existing = getSpec(db, id);
73
+ if (!existing) throw new Error(`Spec ${id} not found`);
74
+ throw new Error(`Spec ${id} cannot be started (status: ${existing.status})`);
75
+ }
76
+
77
+ recordEvent(db, "spec", id, "started", null, actor);
78
+ return getSpec(db, id) as Spec;
79
+ }
80
+
81
+ export function doneSpec(
82
+ db: DbInstance,
83
+ id: string,
84
+ opts: { pr?: string; merged_date?: string } = {},
85
+ actor: string | null = null,
86
+ ): Spec {
87
+ const upd = db
88
+ .prepare(
89
+ `UPDATE specs SET status = 'hecha',
90
+ pr = COALESCE(?, pr),
91
+ merged_date = COALESCE(?, merged_date),
92
+ updated_at = datetime('now')
93
+ WHERE id = ?`,
94
+ )
95
+ .run(opts.pr ?? null, opts.merged_date ?? null, id);
96
+
97
+ if (upd.changes === 0) {
98
+ throw new Error(`Spec ${id} not found`);
99
+ }
100
+
101
+ recordEvent(db, "spec", id, "completed", opts, actor);
102
+ return getSpec(db, id) as Spec;
103
+ }
104
+
105
+ /**
106
+ * Update the spec's markdown content. Useful when brainstorming refines a spec.
107
+ */
108
+ export function setSpecContent(
109
+ db: DbInstance,
110
+ id: string,
111
+ content: string,
112
+ actor: string | null = null,
113
+ ): Spec {
114
+ const upd = db
115
+ .prepare(
116
+ `UPDATE specs SET content = ?, updated_at = datetime('now') WHERE id = ?`,
117
+ )
118
+ .run(content, id);
119
+
120
+ if (upd.changes === 0) throw new Error(`Spec ${id} not found`);
121
+ recordEvent(db, "spec", id, "content_updated", { bytes: content.length }, actor);
122
+ return getSpec(db, id) as Spec;
123
+ }
124
+
125
+ export interface EditSpecInput {
126
+ title?: string;
127
+ }
128
+
129
+ export function editSpec(
130
+ db: DbInstance,
131
+ id: string,
132
+ input: EditSpecInput,
133
+ actor: string | null = null,
134
+ ): Spec {
135
+ if (input.title === undefined) {
136
+ throw new Error('editSpec: provide at least one field to edit');
137
+ }
138
+
139
+ const spec = getSpec(db, id);
140
+ if (!spec) throw new Error(`Spec ${id} not found`);
141
+
142
+ const sets: string[] = ["updated_at = datetime('now')"];
143
+ const args: (string | null)[] = [];
144
+ const changed: Record<string, unknown> = {};
145
+
146
+ if (input.title !== undefined) {
147
+ sets.push('title = ?');
148
+ args.push(input.title);
149
+ changed.title = input.title;
150
+ }
151
+
152
+ args.push(id);
153
+ db.prepare(`UPDATE specs SET ${sets.join(', ')} WHERE id = ?`).run(...args);
154
+ recordEvent(db, 'spec', id, 'edited', changed, actor);
155
+
156
+ return getSpec(db, id) as Spec;
157
+ }
158
+
159
+ /**
160
+ * Add a new plan revision for a spec. Each new plan gets the next version number.
161
+ */
162
+ export function addPlan(
163
+ db: DbInstance,
164
+ specId: string,
165
+ content: string,
166
+ actor: string | null = null,
167
+ ): Plan {
168
+ const spec = getSpec(db, specId);
169
+ if (!spec) throw new Error(`Spec ${specId} not found`);
170
+
171
+ const row = db
172
+ .prepare(
173
+ `SELECT COALESCE(MAX(version), 0) AS max_version FROM plans WHERE spec_id = ?`,
174
+ )
175
+ .get(specId) as { max_version: number };
176
+
177
+ const nextVersion = row.max_version + 1;
178
+
179
+ const insert = db
180
+ .prepare(
181
+ `INSERT INTO plans (spec_id, content, version) VALUES (?, ?, ?)
182
+ RETURNING *`,
183
+ )
184
+ .get(specId, content, nextVersion) as Plan;
185
+
186
+ recordEvent(
187
+ db,
188
+ "plan",
189
+ String(insert.id),
190
+ "created",
191
+ { spec_id: specId, version: nextVersion, bytes: content.length },
192
+ actor,
193
+ );
194
+
195
+ return insert;
196
+ }
197
+
198
+ /**
199
+ * Get the latest plan for a spec, or null if no plan exists.
200
+ */
201
+ export function getLatestPlan(db: DbInstance, specId: string): Plan | null {
202
+ return db
203
+ .prepare(
204
+ `SELECT * FROM plans WHERE spec_id = ? ORDER BY version DESC LIMIT 1`,
205
+ )
206
+ .get(specId) as Plan | null;
207
+ }
208
+
209
+ /**
210
+ * Get all plans for a spec (history), ordered oldest → newest.
211
+ */
212
+ export function listPlans(db: DbInstance, specId: string): Plan[] {
213
+ return db
214
+ .prepare(`SELECT * FROM plans WHERE spec_id = ? ORDER BY version ASC`)
215
+ .all(specId) as Plan[];
216
+ }
217
+
218
+ /**
219
+ * Archive a spec and unassign its tasks so they disappear from active task lists.
220
+ */
221
+ export function archiveSpec(
222
+ db: DbInstance,
223
+ id: string,
224
+ actor: string | null = null,
225
+ ): Spec {
226
+ const spec = getSpec(db, id);
227
+ if (!spec) throw new Error(`Spec ${id} not found`);
228
+
229
+ const upd = db
230
+ .prepare(
231
+ `UPDATE specs SET status = 'archivada', updated_at = datetime('now') WHERE id = ?`,
232
+ )
233
+ .run(id);
234
+
235
+ if (upd.changes === 0) throw new Error(`Spec ${id} could not be archived`);
236
+
237
+ // Unassign owner and set status to pending for all related tasks so they leave active lists
238
+ db.prepare(
239
+ `UPDATE tasks SET status = 'pending', owner = NULL, taken_at = NULL, updated_at = datetime('now') WHERE spec_id = ?`,
240
+ ).run(id);
241
+
242
+ recordEvent(db, "spec", id, "archived", { title: spec.title }, actor);
243
+ return getSpec(db, id) as Spec;
244
+ }
245
+
246
+ /**
247
+ * Unarchive a spec — restore it to active work.
248
+ */
249
+ export function unarchiveSpec(
250
+ db: DbInstance,
251
+ id: string,
252
+ actor: string | null = null,
253
+ ): Spec {
254
+ const spec = getSpec(db, id);
255
+ if (!spec) throw new Error(`Spec ${id} not found`);
256
+
257
+ const upd = db
258
+ .prepare(
259
+ `UPDATE specs SET status = 'lista', updated_at = datetime('now') WHERE id = ? AND status = 'archivada'`,
260
+ )
261
+ .run(id);
262
+
263
+ if (upd.changes === 0) throw new Error(`Spec ${id} is not archived`);
264
+
265
+ recordEvent(db, "spec", id, "unarchived", { title: spec.title }, actor);
266
+ return getSpec(db, id) as Spec;
267
+ }
268
+
269
+ /**
270
+ * Delete a spec and all related data (plans, tasks) in cascade.
271
+ */
272
+ export function deleteSpec(
273
+ db: DbInstance,
274
+ id: string,
275
+ actor: string | null = null,
276
+ ): { deleted: boolean } {
277
+ const spec = getSpec(db, id);
278
+ if (!spec) throw new Error(`Spec ${id} not found`);
279
+
280
+ // Delete related tasks first (foreign key, though SQLite may not enforce without PRAGMA)
281
+ db.prepare(`DELETE FROM tasks WHERE spec_id = ?`).run(id);
282
+ // Delete plans
283
+ db.prepare(`DELETE FROM plans WHERE spec_id = ?`).run(id);
284
+ // Delete spec
285
+ db.prepare(`DELETE FROM specs WHERE id = ?`).run(id);
286
+
287
+ recordEvent(db, "spec", id, "deleted", { title: spec.title }, actor);
288
+ return { deleted: true };
289
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * SQLite backend detection and thin adapter.
3
+ *
4
+ * Tries backends in order (least to most external deps):
5
+ * 1. bun:sqlite — built-in when running under Bun
6
+ * 2. node:sqlite — built-in since Node 22.5 (experimental)
7
+ * 3. better-sqlite3 — npm optional dependency
8
+ *
9
+ * All three share the same synchronous .prepare().run/.get/.all API,
10
+ * so the adapter surface is minimal.
11
+ */
12
+
13
+ export interface Statement {
14
+ run(...args: unknown[]): { changes: number; lastInsertRowid: number | bigint };
15
+ get(...args: unknown[]): unknown;
16
+ all(...args: unknown[]): unknown[];
17
+ }
18
+
19
+ export interface DbAdapter {
20
+ prepare(sql: string): Statement;
21
+ run(sql: string, ...args: unknown[]): void;
22
+ close(): void;
23
+ }
24
+
25
+ type DbConstructor = (path: string, options?: { create?: boolean }) => DbAdapter;
26
+
27
+ // ---- backend loaders -------------------------------------------------------
28
+
29
+ function loadBun(): DbConstructor | null {
30
+ try {
31
+ // Only attempt if we're actually running under Bun
32
+ if (typeof (globalThis as unknown as { Bun?: unknown }).Bun === "undefined") return null;
33
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
34
+ const { Database } = require("bun:sqlite") as { Database: new (path: string, opts?: { create?: boolean }) => DbAdapter };
35
+ return (path, opts) => new Database(path, opts);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function loadNodeSqlite(): DbConstructor | null {
42
+ try {
43
+ // node:sqlite is available in Node 22.5+ (experimental)
44
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
45
+ const { DatabaseSync } = require("node:sqlite") as {
46
+ DatabaseSync: new (path: string, opts?: { open?: boolean }) => {
47
+ prepare(sql: string): {
48
+ run(...args: unknown[]): { changes: number; lastInsertRowid: number | bigint };
49
+ get(...args: unknown[]): unknown;
50
+ all(...args: unknown[]): unknown[];
51
+ };
52
+ exec(sql: string): void;
53
+ close(): void;
54
+ };
55
+ };
56
+ return (path, opts) => {
57
+ const raw = new DatabaseSync(path, { open: opts?.create !== false });
58
+ return {
59
+ prepare: (sql) => raw.prepare(sql),
60
+ run: (sql, ...args) => {
61
+ if (args.length === 0) {
62
+ raw.exec(sql);
63
+ } else {
64
+ raw.prepare(sql).run(...args);
65
+ }
66
+ },
67
+ close: () => raw.close(),
68
+ };
69
+ };
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function loadBetterSqlite3(): DbConstructor | null {
76
+ try {
77
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
78
+ const BetterSqlite3 = require("better-sqlite3") as new (path: string, opts?: { readonly?: boolean }) => {
79
+ prepare(sql: string): Statement;
80
+ exec(sql: string): void;
81
+ close(): void;
82
+ };
83
+ return (path) => {
84
+ const raw = new BetterSqlite3(path);
85
+ return {
86
+ prepare: (sql) => raw.prepare(sql),
87
+ run: (sql, ...args) => {
88
+ if (args.length === 0) {
89
+ raw.exec(sql);
90
+ } else {
91
+ raw.prepare(sql).run(...args);
92
+ }
93
+ },
94
+ close: () => raw.close(),
95
+ };
96
+ };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // ---- public API ------------------------------------------------------------
103
+
104
+ let _constructor: DbConstructor | null | undefined = undefined;
105
+
106
+ export function getSqliteConstructor(): DbConstructor {
107
+ if (_constructor !== undefined) return _constructor!;
108
+
109
+ _constructor =
110
+ loadBun() ??
111
+ loadNodeSqlite() ??
112
+ loadBetterSqlite3() ??
113
+ null;
114
+
115
+ if (!_constructor) {
116
+ console.error(`
117
+ Error: No SQLite backend found. tasca requires one of:
118
+ • Bun 1.0+ (current runtime)
119
+ • Node.js 22.5+ (built-in node:sqlite)
120
+ • better-sqlite3 (npm install -g better-sqlite3)
121
+ `);
122
+ process.exit(1);
123
+ }
124
+
125
+ return _constructor;
126
+ }
127
+
128
+ export function openSqlite(path: string, opts?: { create?: boolean }): DbAdapter {
129
+ return getSqliteConstructor()(path, opts);
130
+ }