@andrestobelem/clone 0.1.3 → 0.2.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.
@@ -0,0 +1,1393 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { lastInsertId } from "./database.js";
5
+ import { InvalidInputError, RelationshipConflictError } from "./errors.js";
6
+ import { issueStatuses, priorities, schema } from "./schema.js";
7
+ import { mutationPayload } from "./validation.js";
8
+ export function now() {
9
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00");
10
+ }
11
+ export function initializeSchema(database) {
12
+ database.exec(schema);
13
+ }
14
+ export function createMemberActors(database) {
15
+ database.exec(`INSERT INTO actors(id,kind,name,active,member_id,avatar_color,created_at)
16
+ SELECT id,'member',name,active,id,avatar_color,created_at FROM members`);
17
+ }
18
+ export function seedEmpty(database) {
19
+ initializeSchema(database);
20
+ if (scalar(database, "SELECT COUNT(*) FROM workspace"))
21
+ return;
22
+ const stamp = now();
23
+ run(database, "INSERT INTO workspace(id,name,icon,created_at) VALUES(1,?,?,?)", "My Workspace", "M", stamp);
24
+ run(database, "INSERT INTO members(id,name,email,role,avatar_color,created_at) VALUES(1,?,?,?,?,?)", "Local User", "local@clone.local", "Workspace admin", "#88a8ce", stamp);
25
+ createMemberActors(database);
26
+ updateSetting(database, "preferences", {
27
+ defaultHome: "Issues",
28
+ theme: "Light",
29
+ sidebar: {},
30
+ });
31
+ updateSetting(database, "workspace", {
32
+ name: "My Workspace",
33
+ timezone: "UTC",
34
+ issueIdentifier: "ISS",
35
+ inviteDomain: null,
36
+ });
37
+ }
38
+ export function seedDemo(database) {
39
+ initializeSchema(database);
40
+ if (scalar(database, "SELECT COUNT(*) FROM workspace"))
41
+ return;
42
+ const stamp = now();
43
+ run(database, "INSERT INTO workspace(id,name,icon,created_at) VALUES(1,?,?,?)", "Acme Studio", "A", stamp);
44
+ const people = [
45
+ [1, "Alex Morgan", "alex@acme.local", "Workspace admin", "#cda58f"],
46
+ [2, "Maya Chen", "maya@acme.local", "Member", "#ac95d4"],
47
+ [3, "Sam Rivera", "sam@acme.local", "Member", "#79a99b"],
48
+ [4, "Jordan Lee", "jordan@acme.local", "Member", "#d79da5"],
49
+ [5, "Taylor Kim", "taylor@acme.local", "Member", "#88a8ce"],
50
+ ];
51
+ for (const person of people) {
52
+ run(database, "INSERT INTO members(id,name,email,role,avatar_color,created_at) VALUES(?,?,?,?,?,?)", ...person, stamp);
53
+ }
54
+ createMemberActors(database);
55
+ run(database, "INSERT INTO teams(id,name,key,description,icon,created_at) VALUES(1,'Product','PRO','Build a product people love.','✳',?)", stamp);
56
+ run(database, "INSERT INTO teams(id,name,key,description,icon,created_at) VALUES(2,'Design','DSN','Shape a clear, considered experience.','◇',?)", stamp);
57
+ for (const [teamId, memberId, role] of [
58
+ [1, 1, "Lead"],
59
+ [1, 2, "Member"],
60
+ [1, 3, "Member"],
61
+ [1, 4, "Member"],
62
+ [2, 1, "Lead"],
63
+ [2, 2, "Member"],
64
+ [2, 4, "Member"],
65
+ [2, 5, "Member"],
66
+ ]) {
67
+ run(database, "INSERT INTO team_members(team_id,member_id,role) VALUES(?,?,?)", teamId, memberId, role);
68
+ }
69
+ const colors = {
70
+ Backlog: "#a3a3ad",
71
+ Todo: "#6e9dd5",
72
+ "In Progress": "#8584dd",
73
+ "In Review": "#b881d2",
74
+ Done: "#54b58b",
75
+ Canceled: "#8b8b93",
76
+ Duplicate: "#8b8b93",
77
+ };
78
+ for (const teamId of [1, 2]) {
79
+ issueStatuses.forEach(([name, category], position) => run(database, "INSERT INTO issue_statuses(team_id,name,category,position,color,is_default) VALUES(?,?,?,?,?,?)", teamId, name, category, position, colors[name], name === "Backlog" ? 1 : 0));
80
+ }
81
+ seedDemoContent(database, stamp);
82
+ }
83
+ function seedDemoContent(database, stamp) {
84
+ const labels = [
85
+ [1, "Feature", "#7c78d8", 1],
86
+ [2, "Bug", "#d96c68", 1],
87
+ [3, "Improvement", "#6394d4", 1],
88
+ [4, "Design", "#c27ad2", 1],
89
+ [5, "Urgent", "#e3a74f", 1],
90
+ [6, "Accessibility", "#58a88d", 1],
91
+ [7, "Frontend", "#7496d4", 1],
92
+ [8, "Research", "#c58e58", 2],
93
+ ];
94
+ for (const item of labels)
95
+ run(database, "INSERT INTO labels(id,name,color,team_id) VALUES(?,?,?,?)", ...item);
96
+ const cycles = [
97
+ [1, 1, "Cycle 24", "2026-10-24", "2026-11-07", "Active", 24],
98
+ [2, 1, "Cycle 25", "2026-11-07", "2026-11-21", "Upcoming", 24],
99
+ [3, 2, "Design cycle 12", "2026-10-28", "2026-11-11", "Active", 18],
100
+ ];
101
+ for (const item of cycles)
102
+ run(database, "INSERT INTO cycles(id,team_id,name,starts_at,ends_at,status,capacity,created_at) VALUES(?,?,?,?,?,?,?,?)", ...item, stamp);
103
+ const projects = [
104
+ [
105
+ 1,
106
+ "Workspace foundations",
107
+ "Make everyday project work feel fast and clear.",
108
+ "Improve the core planning experience for teams.",
109
+ "In Progress",
110
+ 2,
111
+ "◈",
112
+ 1,
113
+ "2026-09-15",
114
+ "2026-10-18",
115
+ ],
116
+ [
117
+ 2,
118
+ "Spring product launch",
119
+ "Bring the next version of Acme to every team.",
120
+ "Prepare the product launch across product and design.",
121
+ "In Progress",
122
+ 1,
123
+ "✳",
124
+ 1,
125
+ "2026-09-20",
126
+ "2026-11-02",
127
+ ],
128
+ [
129
+ 3,
130
+ "Design system refresh",
131
+ "A sharper, more consistent UI across the product.",
132
+ "Refresh components, tokens, and documentation.",
133
+ "Planned",
134
+ 3,
135
+ "⬡",
136
+ 4,
137
+ "2026-10-05",
138
+ "2026-12-01",
139
+ ],
140
+ ];
141
+ for (const item of projects)
142
+ run(database, "INSERT INTO projects(id,name,summary,description,status,priority,icon,lead_id,start_date,target_date,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", ...item, stamp, stamp);
143
+ for (const item of [
144
+ [1, 1],
145
+ [1, 2],
146
+ [2, 1],
147
+ [2, 2],
148
+ [3, 2],
149
+ ])
150
+ run(database, "INSERT INTO project_teams(project_id,team_id) VALUES(?,?)", ...item);
151
+ for (const item of [
152
+ [1, 1],
153
+ [1, 2],
154
+ [1, 3],
155
+ [2, 1],
156
+ [2, 2],
157
+ [2, 4],
158
+ [3, 4],
159
+ [3, 5],
160
+ ])
161
+ run(database, "INSERT INTO project_members(project_id,member_id) VALUES(?,?)", ...item);
162
+ run(database, "INSERT INTO project_dependencies(project_id,depends_on_id,dependency_type) VALUES(2,1,'blocks')");
163
+ const milestones = [
164
+ [1, 1, "Prototype review", "2026-10-04", "Done", 0],
165
+ [2, 1, "Team beta", "2026-10-12", "In Progress", 1],
166
+ [3, 1, "General availability", "2026-10-18", "Todo", 2],
167
+ [4, 2, "Launch readiness", "2026-10-22", "Todo", 0],
168
+ [5, 3, "Token audit", "2026-10-20", "Todo", 0],
169
+ ];
170
+ for (const item of milestones)
171
+ run(database, "INSERT INTO milestones(id,project_id,name,due_date,status,position) VALUES(?,?,?,?,?,?)", ...item);
172
+ const issueData = [
173
+ [
174
+ 248,
175
+ 1,
176
+ "Add keyboard shortcuts to command menu",
177
+ "Make command navigation available without leaving the keyboard.",
178
+ "In Progress",
179
+ 0,
180
+ 1,
181
+ 2,
182
+ 1,
183
+ 1,
184
+ "2026-10-05",
185
+ 3,
186
+ ],
187
+ [
188
+ 246,
189
+ 1,
190
+ "Support custom issue labels",
191
+ "Let teams add, color, and filter their own labels.",
192
+ "In Progress",
193
+ 1,
194
+ 2,
195
+ 1,
196
+ 1,
197
+ 1,
198
+ "2026-10-08",
199
+ 5,
200
+ ],
201
+ [
202
+ 241,
203
+ 1,
204
+ "Fix layout shift on the project overview",
205
+ "Prevent cards jumping while progress data loads.",
206
+ "In Progress",
207
+ 2,
208
+ 4,
209
+ 1,
210
+ 1,
211
+ 1,
212
+ "2026-10-09",
213
+ 2,
214
+ ],
215
+ [
216
+ 239,
217
+ 1,
218
+ "Add a compact sidebar density option",
219
+ "Offer a narrower navigation layout.",
220
+ "Todo",
221
+ 1,
222
+ 3,
223
+ 1,
224
+ 1,
225
+ 2,
226
+ "2026-10-12",
227
+ null,
228
+ ],
229
+ [
230
+ 237,
231
+ 1,
232
+ "Remember the last selected team view",
233
+ "Restore the last active view on the next visit.",
234
+ "Todo",
235
+ 2,
236
+ 1,
237
+ 1,
238
+ 1,
239
+ 2,
240
+ null,
241
+ 2,
242
+ ],
243
+ [
244
+ 232,
245
+ 2,
246
+ "Improve keyboard focus in issue details",
247
+ "Keep focus visible when navigating issue properties.",
248
+ "Backlog",
249
+ 3,
250
+ 2,
251
+ 1,
252
+ 3,
253
+ 3,
254
+ null,
255
+ null,
256
+ ],
257
+ [
258
+ 229,
259
+ 2,
260
+ "Create project update templates",
261
+ "Start project updates from reusable templates.",
262
+ "Backlog",
263
+ 4,
264
+ 3,
265
+ 1,
266
+ 2,
267
+ 3,
268
+ null,
269
+ null,
270
+ ],
271
+ [
272
+ 226,
273
+ 2,
274
+ "Polish empty states across the workspace",
275
+ "Add helpful next steps to empty product views.",
276
+ "Done",
277
+ 2,
278
+ 4,
279
+ 1,
280
+ 1,
281
+ 3,
282
+ null,
283
+ null,
284
+ ],
285
+ [
286
+ 218,
287
+ 2,
288
+ "Document color token usage",
289
+ "Record accessibility contrast constraints for the palette.",
290
+ "In Review",
291
+ 3,
292
+ 5,
293
+ 2,
294
+ 3,
295
+ 3,
296
+ "2026-10-14",
297
+ null,
298
+ ],
299
+ [
300
+ 216,
301
+ 1,
302
+ "Add keyboard shortcuts help panel",
303
+ "Show discoverable shortcuts from the help menu.",
304
+ "Backlog",
305
+ 4,
306
+ 1,
307
+ 1,
308
+ null,
309
+ 2,
310
+ null,
311
+ null,
312
+ ],
313
+ [
314
+ 95,
315
+ 2,
316
+ "Audit empty state illustrations",
317
+ "Review illustration consistency across feature areas.",
318
+ "Todo",
319
+ 3,
320
+ 4,
321
+ 2,
322
+ 3,
323
+ 3,
324
+ "2026-10-16",
325
+ null,
326
+ ],
327
+ [
328
+ 93,
329
+ 1,
330
+ "Add issue dependency indicators",
331
+ "Show blocked-by relationships in list and detail views.",
332
+ "Backlog",
333
+ 2,
334
+ 2,
335
+ 1,
336
+ 1,
337
+ 2,
338
+ null,
339
+ 3,
340
+ ],
341
+ ];
342
+ for (const item of issueData) {
343
+ const [number, team, title, description, status, priority, assignee, creator, project, cycle, due, estimate,] = item;
344
+ const statusId = scalar(database, "SELECT id FROM issue_statuses WHERE team_id=? AND name=?", team, status);
345
+ run(database, "INSERT INTO issues(identifier,number,team_id,title,description,status_id,priority,assignee_id,creator_id,project_id,cycle_id,due_date,estimate,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", `${team === 1 ? "PRO" : "DSN"}-${number}`, number, team, title, description, sql(statusId), priority, assignee, creator, project, cycle, due, estimate, stamp, stamp);
346
+ }
347
+ const issueId = (identifier) => Number(scalar(database, "SELECT id FROM issues WHERE identifier=?", identifier));
348
+ const issueLabels = {
349
+ "PRO-248": [1, 7],
350
+ "PRO-246": [1],
351
+ "PRO-241": [2, 7],
352
+ "PRO-239": [3],
353
+ "DSN-232": [6],
354
+ "DSN-218": [4, 8],
355
+ "DSN-95": [4],
356
+ };
357
+ for (const [identifier, labelIds] of Object.entries(issueLabels))
358
+ for (const labelId of labelIds)
359
+ run(database, "INSERT INTO issue_labels(issue_id,label_id) VALUES(?,?)", issueId(identifier), labelId);
360
+ for (const item of [
361
+ ["PRO-248", 1],
362
+ ["PRO-248", 2],
363
+ ["PRO-246", 1],
364
+ ["DSN-218", 1],
365
+ ])
366
+ run(database, "INSERT INTO issue_subscribers(issue_id,member_id) VALUES(?,?)", issueId(item[0]), item[1]);
367
+ run(database, "INSERT INTO issue_relations(issue_id,related_issue_id,relation_type) VALUES(?,?,?)", issueId("PRO-241"), issueId("PRO-93"), "blocked by");
368
+ run(database, "INSERT INTO issue_relations(issue_id,related_issue_id,relation_type) VALUES(?,?,?)", issueId("PRO-248"), issueId("PRO-216"), "relates to");
369
+ run(database, "INSERT INTO comments(issue_id,author_id,body,created_at) VALUES(?,?,?,?)", issueId("PRO-248"), 2, "I drafted the shortcut map; we can review the final keys with the team.", stamp);
370
+ run(database, "INSERT INTO team_resources(team_id,section,title,url) VALUES(1,'Resources','Product principles','https://example.local/principles')");
371
+ const notifications = [
372
+ [
373
+ 1,
374
+ 1,
375
+ "comment",
376
+ "Maya commented on PRO-248",
377
+ "I drafted the shortcut map; we can review the final keys with the team.",
378
+ "issue",
379
+ issueId("PRO-248"),
380
+ 0,
381
+ ],
382
+ [
383
+ 2,
384
+ 1,
385
+ "assignment",
386
+ "PRO-237 was assigned to you",
387
+ "Remember the last selected team view",
388
+ "issue",
389
+ issueId("PRO-237"),
390
+ 0,
391
+ ],
392
+ [
393
+ 3,
394
+ 1,
395
+ "project_update",
396
+ "Workspace foundations update",
397
+ "Prototype review is complete. The team beta is next.",
398
+ "project",
399
+ 1,
400
+ 1,
401
+ ],
402
+ [
403
+ 4,
404
+ 2,
405
+ "mention",
406
+ "Alex mentioned you in PRO-246",
407
+ "Can you review the labels proposal?",
408
+ "issue",
409
+ issueId("PRO-246"),
410
+ 0,
411
+ ],
412
+ ];
413
+ for (const item of notifications)
414
+ run(database, "INSERT INTO notifications(id,recipient_id,kind,title,body,entity_type,entity_id,is_read,created_at) VALUES(?,?,?,?,?,?,?,?,?)", ...item, stamp);
415
+ const views = [
416
+ [
417
+ 1,
418
+ "All issues",
419
+ "Everything in the Product team",
420
+ "◈",
421
+ "issues",
422
+ "Team",
423
+ null,
424
+ 1,
425
+ { status: "all" },
426
+ { layout: "List", groupBy: "Status", orderBy: "Priority" },
427
+ 1,
428
+ ],
429
+ [
430
+ 2,
431
+ "My open issues",
432
+ "Assigned to Alex and not completed",
433
+ "◎",
434
+ "issues",
435
+ "Personal",
436
+ 1,
437
+ 1,
438
+ { assignee: "Alex Morgan", completed: false },
439
+ { layout: "List", groupBy: "Status", orderBy: "Updated" },
440
+ 1,
441
+ ],
442
+ [
443
+ 3,
444
+ "Project tracking",
445
+ "Active project delivery",
446
+ "✳",
447
+ "projects",
448
+ "Workspace",
449
+ null,
450
+ null,
451
+ { status: "In Progress" },
452
+ { layout: "Board", groupBy: "Status", orderBy: "Target date" },
453
+ 0,
454
+ ],
455
+ ];
456
+ for (const item of views) {
457
+ const [id, name, description, icon, entity, scope, owner, team, filters, display, favorite,] = item;
458
+ run(database, "INSERT INTO views(id,name,description,icon,entity,scope,owner_id,team_id,filters_json,display_json,is_favorite,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", id, name, description, icon, entity, scope, owner, team, JSON.stringify(filters), JSON.stringify(display), favorite, stamp, stamp);
459
+ }
460
+ const settings = {
461
+ preferences: {
462
+ defaultHome: "Issues",
463
+ displayNames: "Full name",
464
+ firstDayOfWeek: "Monday",
465
+ commentSubmit: "Enter",
466
+ fontSize: "Default",
467
+ theme: "Light",
468
+ spelling: true,
469
+ autoAssignToSelf: false,
470
+ sidebar: { Inbox: true, "My issues": true, Projects: true, Views: true },
471
+ orderCompletedByRecency: false,
472
+ },
473
+ workspace: {
474
+ name: "Acme Studio",
475
+ timezone: "America/Argentina/Buenos_Aires",
476
+ issueIdentifier: "PRO",
477
+ inviteDomain: null,
478
+ },
479
+ integrations: [
480
+ { name: "GitHub", connected: false },
481
+ { name: "Slack", connected: false },
482
+ ],
483
+ featureFlags: {
484
+ cycles: true,
485
+ triage: true,
486
+ automations: true,
487
+ customerRequests: false,
488
+ releases: true,
489
+ initiatives: true,
490
+ pulse: false,
491
+ asks: false,
492
+ },
493
+ };
494
+ for (const [key, value] of Object.entries(settings))
495
+ updateSetting(database, key, value);
496
+ const nextRun = new Date(Date.now() + 7 * 86400000)
497
+ .toISOString()
498
+ .slice(0, 10);
499
+ run(database, "INSERT INTO recurring_rules(team_id,title,description,cadence,next_run,enabled,created_at) VALUES(1,?,?,?,?,1,?)", "Weekly planning reminder", "Review open issues before cycle planning.", "Weekly", nextRun, stamp);
500
+ run(database, "INSERT INTO automations(team_id,name,trigger_json,condition_json,action_json,enabled,created_at,updated_at) VALUES(1,?,?,?,?,1,?,?)", "Assign urgent bugs", JSON.stringify({ event: "issue.created" }), JSON.stringify({ label: "Bug", priority: "Urgent" }), JSON.stringify({ assignTo: 1 }), stamp, stamp);
501
+ }
502
+ export function actorRecord(database, actorId) {
503
+ return (one(database, `SELECT a.*,m.email,m.role FROM actors a
504
+ LEFT JOIN members m ON m.id=a.member_id WHERE a.id=?`, actorId) ?? null);
505
+ }
506
+ export function actorRecords(database, kind = null, includeInactive = false) {
507
+ return rows(database, "SELECT id FROM actors WHERE (? IS NULL OR kind=?) AND (? OR active=1) ORDER BY id", kind, kind, includeInactive ? 1 : 0)
508
+ .map((row) => actorRecord(database, Number(row.id)))
509
+ .filter((row) => row !== null);
510
+ }
511
+ export function requireActor(database, actorId) {
512
+ if (!Number.isInteger(actorId))
513
+ throw new InvalidInputError("Actor ID must be an integer");
514
+ const record = actorRecord(database, Number(actorId));
515
+ if (!record || !record.active)
516
+ throw new RelationshipConflictError("Actor not found or inactive");
517
+ return Number(actorId);
518
+ }
519
+ export function issueRow(database, issueId) {
520
+ const result = one(database, `SELECT i.*,t.name AS team_name,t.key AS team_key,s.name AS status,s.category AS status_category,s.color AS status_color,
521
+ CASE WHEN a.kind='agent' THEN a.name||' (Agent)' ELSE a.name END AS assignee,(SELECT email FROM members WHERE id=a.member_id) AS assignee_email,a.avatar_color AS assignee_color,a.kind AS assignee_kind,c.kind AS creator_kind,CASE WHEN c.kind='agent' THEN c.name||' (Agent)' ELSE c.name END AS creator,
522
+ p.name AS project,cy.name AS cycle,m.name AS milestone
523
+ FROM issues i JOIN teams t ON t.id=i.team_id JOIN issue_statuses s ON s.id=i.status_id
524
+ LEFT JOIN actors a ON a.id=i.assignee_id LEFT JOIN actors c ON c.id=i.creator_id
525
+ LEFT JOIN projects p ON p.id=i.project_id LEFT JOIN cycles cy ON cy.id=i.cycle_id LEFT JOIN milestones m ON m.id=i.milestone_id
526
+ WHERE i.id=?`, issueId);
527
+ if (!result)
528
+ return null;
529
+ const output = {
530
+ ...result,
531
+ priority_name: priorities[Number(result.priority)],
532
+ };
533
+ output.labels = rows(database, "SELECT l.id,l.name,l.color FROM labels l JOIN issue_labels il ON il.label_id=l.id WHERE il.issue_id=?", issueId);
534
+ output.subscribers = rows(database, "SELECT member_id FROM issue_subscribers WHERE issue_id=?", issueId).map((row) => actorRecord(database, Number(row.member_id)));
535
+ output.comments = rows(database, "SELECT c.id,c.body,c.created_at,m.id AS author_id,CASE WHEN m.kind='agent' THEN m.name||' (Agent)' ELSE m.name END AS author,m.avatar_color FROM comments c JOIN actors m ON m.id=c.author_id WHERE c.issue_id=? ORDER BY c.id", issueId);
536
+ output.attachments = rows(database, "SELECT id,name,content_type,size,created_at FROM issue_attachments WHERE issue_id=? ORDER BY id", issueId);
537
+ output.relations = rows(database, "SELECT r.id,r.relation_type,r.url,i.identifier AS related_identifier,i.title AS related_title FROM issue_relations r LEFT JOIN issues i ON i.id=r.related_issue_id WHERE r.issue_id=?", issueId);
538
+ output.subissues = rows(database, "SELECT id,identifier,title,status_id FROM issues WHERE parent_id=? AND archived=0", issueId);
539
+ output.status_changed_at = result.created_at;
540
+ let statusFound = false;
541
+ output.activity = rows(database, "SELECT e.*,CASE WHEN m.kind='agent' THEN m.name||' (Agent)' ELSE m.name END AS actor FROM activity_events e LEFT JOIN actors m ON m.id=e.actor_id WHERE e.entity_type='issue' AND e.entity_id=? ORDER BY e.created_at DESC,e.id DESC", issueId).map((entry) => {
542
+ const payload = parseJson(entry.payload, {});
543
+ if (!statusFound &&
544
+ ("status" in payload ||
545
+ (Array.isArray(payload.changes) &&
546
+ payload.changes.some((change) => isObject(change) && change.field === "status_id")))) {
547
+ output.status_changed_at = entry.created_at;
548
+ statusFound = true;
549
+ }
550
+ return { ...entry, payload };
551
+ });
552
+ return output;
553
+ }
554
+ export function bootstrap(database, currentActor = 1) {
555
+ const workspace = one(database, "SELECT * FROM workspace LIMIT 1") ?? {};
556
+ const teams = rows(database, "SELECT * FROM teams ORDER BY id").map((team) => ({
557
+ ...team,
558
+ members: rows(database, "SELECT member_id FROM team_members WHERE team_id=?", sql(team.id)).map((row) => actorRecord(database, Number(row.member_id))),
559
+ statuses: rows(database, "SELECT * FROM issue_statuses WHERE team_id=? ORDER BY position", sql(team.id)),
560
+ resources: rows(database, "SELECT * FROM team_resources WHERE team_id=? ORDER BY section,id", sql(team.id)),
561
+ }));
562
+ const projects = rows(database, "SELECT * FROM projects WHERE archived=0 ORDER BY id DESC").map((project) => projectRecord(database, project));
563
+ const issues = rows(database, "SELECT id FROM issues WHERE archived=0 ORDER BY number DESC").map((row) => issueRow(database, Number(row.id)));
564
+ const views = rows(database, "SELECT * FROM views ORDER BY is_favorite DESC,updated_at DESC").map(({ filters_json, display_json, ...view }) => ({
565
+ ...view,
566
+ filters: parseJson(filters_json, {}),
567
+ display: parseJson(display_json, {}),
568
+ }));
569
+ const cycles = rows(database, "SELECT c.*,t.name AS team_name,t.key FROM cycles c JOIN teams t ON t.id=c.team_id ORDER BY c.starts_at DESC").map((cycle) => cycleRecord(database, cycle));
570
+ const settings = Object.fromEntries(rows(database, "SELECT * FROM app_settings").map((row) => [
571
+ String(row.key),
572
+ parseJson(row.value_json, {}),
573
+ ]));
574
+ return {
575
+ workspace,
576
+ teams,
577
+ members: rows(database, "SELECT * FROM members WHERE active=1 ORDER BY name"),
578
+ issues,
579
+ projects,
580
+ views,
581
+ notifications: rows(database, "SELECT * FROM notifications WHERE archived=0 AND recipient_id=? ORDER BY created_at DESC", currentActor),
582
+ cycles,
583
+ statuses: rows(database, "SELECT * FROM issue_statuses ORDER BY team_id,position"),
584
+ labels: rows(database, "SELECT * FROM labels ORDER BY name"),
585
+ recurring: rows(database, "SELECT * FROM recurring_rules ORDER BY id"),
586
+ automations: rows(database, "SELECT * FROM automations ORDER BY id"),
587
+ archives: {
588
+ issues: rows(database, "SELECT id FROM issues WHERE archived=1 ORDER BY updated_at DESC").map((row) => issueRow(database, Number(row.id))),
589
+ projects: rows(database, "SELECT id,name,summary,status,updated_at FROM projects WHERE archived=1 ORDER BY updated_at DESC"),
590
+ },
591
+ settings,
592
+ currentMemberId: 1,
593
+ currentActorId: currentActor,
594
+ actors: actorRecords(database, null, true),
595
+ };
596
+ }
597
+ export function dispatchMutation(database, method, route, raw, paths) {
598
+ const data = mutationPayload(raw, route);
599
+ const actor = requireActor(database, data.actorId ?? 1);
600
+ validateActorFields(database, data, method, route);
601
+ const stamp = now();
602
+ return (mutateAgent(database, method, route, data, stamp) ??
603
+ mutateTeam(database, method, route, data, stamp, actor) ??
604
+ mutateProject(database, method, route, data, stamp, actor) ??
605
+ mutateIssue(database, method, route, data, stamp, actor, paths) ??
606
+ mutateGeneral(database, method, route, data, stamp, actor));
607
+ }
608
+ function mutateAgent(database, method, route, data, stamp) {
609
+ if (route === "/api/agents" && method === "POST") {
610
+ const key = text(data.key, "Agent key is required");
611
+ const name = text(data.name, "Agent name is required");
612
+ run(database, "INSERT INTO actors(kind,name,key,created_at) VALUES('agent',?,?,?)", name, key, stamp);
613
+ const agentId = lastInsertId(database);
614
+ if (data.teamId !== undefined)
615
+ run(database, "INSERT INTO team_members(team_id,member_id,role) VALUES(?,?,'Member')", sql(data.teamId), agentId);
616
+ return { agent: actorRecord(database, agentId), _status: 201 };
617
+ }
618
+ const match = route.match(/^\/api\/agents\/(\d+)(?:\/(deactivate|restore))?$/);
619
+ if (!match)
620
+ return null;
621
+ const agentId = Number(match[1]);
622
+ const target = actorRecord(database, agentId);
623
+ if (!target || target.kind !== "agent")
624
+ fail("Agent not found");
625
+ if (match[2] && method === "POST")
626
+ run(database, "UPDATE actors SET active=? WHERE id=?", match[2] === "restore" ? 1 : 0, agentId);
627
+ else if (!match[2] && method === "PATCH") {
628
+ if ("key" in data)
629
+ fail("Agent key cannot change");
630
+ run(database, "UPDATE actors SET name=? WHERE id=?", text(data.name, "Agent name is required"), agentId);
631
+ }
632
+ else
633
+ return { error: "Not found", _status: 404 };
634
+ return { agent: actorRecord(database, agentId) };
635
+ }
636
+ function mutateTeam(database, method, route, data, stamp, actor) {
637
+ if (route === "/api/teams" && method === "POST") {
638
+ const name = text(data.name, "Team name is required");
639
+ const key = String(data.key ?? name.slice(0, 3))
640
+ .replace(/[^A-Za-z0-9]/g, "")
641
+ .toUpperCase()
642
+ .slice(0, 5);
643
+ if (!key)
644
+ fail("Team key must contain letters or digits");
645
+ run(database, "INSERT INTO teams(name,key,description,icon,created_at) VALUES(?,?,?,?,?)", name, key, sql(data.description ?? ""), sql(data.icon ?? "✳"), stamp);
646
+ const teamId = lastInsertId(database);
647
+ run(database, "INSERT INTO team_members(team_id,member_id,role) VALUES(?,?,'Lead')", teamId, actor);
648
+ for (const participant of array(data.memberIds))
649
+ run(database, "INSERT OR IGNORE INTO team_members(team_id,member_id) VALUES(?,?)", teamId, sql(participant));
650
+ issueStatuses.forEach(([status, category], position) => run(database, "INSERT INTO issue_statuses(team_id,name,category,position,color,is_default) VALUES(?,?,?,?,?,?)", teamId, status, category, position, "#8e8e98", status === "Backlog" ? 1 : 0));
651
+ return { teamId, _status: 201 };
652
+ }
653
+ if (route === "/api/statuses" && method === "POST") {
654
+ const teamId = Number(data.teamId ?? 1);
655
+ const name = text(data.name, "Status name is required");
656
+ const position = Number(scalar(database, "SELECT COALESCE(MAX(position),-1)+1 FROM issue_statuses WHERE team_id=?", teamId));
657
+ run(database, "INSERT INTO issue_statuses(team_id,name,category,position,color) VALUES(?,?,?,?,?)", teamId, name, sql(data.category ?? "Unstarted"), position, sql(data.color ?? "#8989a1"));
658
+ return { statusId: lastInsertId(database), _status: 201 };
659
+ }
660
+ if (route === "/api/labels" && method === "POST") {
661
+ run(database, "INSERT INTO labels(name,color,team_id) VALUES(?,?,?)", text(data.name, "Label name is required"), sql(data.color ?? "#8585cc"), sql(data.teamId ?? 1));
662
+ return { labelId: lastInsertId(database), _status: 201 };
663
+ }
664
+ const team = route.match(/^\/api\/teams\/(\d+)$/);
665
+ if (team && ["PUT", "PATCH"].includes(method)) {
666
+ const teamId = Number(team[1]);
667
+ if (!one(database, "SELECT id FROM teams WHERE id=?", teamId))
668
+ fail("Team not found");
669
+ updateFields(database, "teams", "id", teamId, data, {
670
+ name: "name",
671
+ description: "description",
672
+ icon: "icon",
673
+ timezone: "timezone",
674
+ estimateType: "estimate_type",
675
+ });
676
+ if ("memberIds" in data)
677
+ replaceLinks(database, "team_members", "team_id", teamId, "member_id", array(data.memberIds));
678
+ return { ok: true };
679
+ }
680
+ const resources = route.match(/^\/api\/teams\/(\d+)\/resources(?:\/(\d+))?$/);
681
+ if (resources && !resources[2] && method === "POST") {
682
+ const url = String(data.url ?? "").trim();
683
+ if (!/^https?:\/\//i.test(url))
684
+ fail("Resource links must start with http:// or https://");
685
+ run(database, "INSERT INTO team_resources(team_id,section,title,url) VALUES(?,?,?,?)", Number(resources[1]), String(data.section ?? "Resources").trim() || "Resources", text(data.title, "Resource title is required"), url);
686
+ return { resourceId: lastInsertId(database), _status: 201 };
687
+ }
688
+ if (resources?.[2] && method === "DELETE") {
689
+ run(database, "DELETE FROM team_resources WHERE team_id=? AND id=?", Number(resources[1]), Number(resources[2]));
690
+ return { ok: true };
691
+ }
692
+ return null;
693
+ }
694
+ function mutateProject(database, method, route, data, stamp, actor) {
695
+ if (route === "/api/projects" && method === "POST") {
696
+ const name = text(data.name, "Project name is required");
697
+ run(database, "INSERT INTO projects(name,summary,description,status,priority,icon,lead_id,start_date,target_date,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", name, sql(data.summary ?? ""), sql(data.description ?? ""), sql(data.status ?? "Backlog"), priorityNumber(data.priority ?? 4), sql(data.icon ?? "◈"), sql(data.leadId ?? actor), sql(data.startDate), sql(data.targetDate), stamp, stamp);
698
+ const projectId = lastInsertId(database);
699
+ for (const teamId of array(data.teamIds, [1]))
700
+ run(database, "INSERT OR IGNORE INTO project_teams(project_id,team_id) VALUES(?,?)", projectId, sql(teamId));
701
+ for (const memberId of array(data.memberIds, [actor]))
702
+ run(database, "INSERT OR IGNORE INTO project_members(project_id,member_id) VALUES(?,?)", projectId, sql(memberId));
703
+ for (const labelId of array(data.labelIds))
704
+ run(database, "INSERT OR IGNORE INTO project_labels(project_id,label_id) VALUES(?,?)", projectId, sql(labelId));
705
+ for (const dependency of array(data.dependencies))
706
+ if (Number(dependency) !== projectId)
707
+ run(database, "INSERT OR IGNORE INTO project_dependencies(project_id,depends_on_id,dependency_type) VALUES(?,?,'blocks')", projectId, sql(dependency));
708
+ array(data.milestones).forEach((raw, position) => {
709
+ const item = raw;
710
+ run(database, "INSERT INTO milestones(project_id,name,due_date,status,position) VALUES(?,?,?,?,?)", projectId, sql(item.name), sql(item.dueDate), sql(item.status ?? "Todo"), position);
711
+ });
712
+ event(database, "project", projectId, actor, "created", { name });
713
+ return { projectId, _status: 201 };
714
+ }
715
+ const match = route.match(/^\/api\/projects\/(\d+)(?:\/(restore|milestones|updates|dependencies))?$/);
716
+ if (!match)
717
+ return null;
718
+ const projectId = Number(match[1]);
719
+ const action = match[2];
720
+ if (!one(database, "SELECT id FROM projects WHERE id=?", projectId))
721
+ fail("Project not found");
722
+ if (!action && ["PUT", "PATCH"].includes(method)) {
723
+ if ("teamIds" in data) {
724
+ const next = new Set(array(data.teamIds).map(Number));
725
+ if (rows(database, "SELECT DISTINCT team_id FROM issues WHERE project_id=?", projectId).some((row) => !next.has(Number(row.team_id))))
726
+ fail("Move or unlink this project's issues before removing their team");
727
+ }
728
+ const changed = updateFields(database, "projects", "id", projectId, data, {
729
+ name: "name",
730
+ summary: "summary",
731
+ description: "description",
732
+ status: "status",
733
+ icon: "icon",
734
+ leadId: "lead_id",
735
+ startDate: "start_date",
736
+ targetDate: "target_date",
737
+ }, { priority: priorityNumber });
738
+ if (changed)
739
+ run(database, "UPDATE projects SET updated_at=? WHERE id=?", stamp, projectId);
740
+ for (const [key, table, column] of [
741
+ ["teamIds", "project_teams", "team_id"],
742
+ ["memberIds", "project_members", "member_id"],
743
+ ["labelIds", "project_labels", "label_id"],
744
+ ])
745
+ if (key in data)
746
+ replaceLinks(database, table, "project_id", projectId, column, array(data[key]));
747
+ event(database, "project", projectId, actor, "updated", data);
748
+ return { projectId };
749
+ }
750
+ if (!action && method === "DELETE") {
751
+ run(database, "UPDATE projects SET archived=1,updated_at=? WHERE id=?", stamp, projectId);
752
+ event(database, "project", projectId, actor, "archived", {});
753
+ return { ok: true };
754
+ }
755
+ if (action === "restore" && method === "POST") {
756
+ run(database, "UPDATE projects SET archived=0,updated_at=? WHERE id=?", stamp, projectId);
757
+ event(database, "project", projectId, actor, "restored", {});
758
+ return { ok: true };
759
+ }
760
+ if (action === "milestones" && method === "POST") {
761
+ const name = text(data.name, "Milestone name is required");
762
+ const position = Number(scalar(database, "SELECT COUNT(*) FROM milestones WHERE project_id=?", projectId));
763
+ run(database, "INSERT INTO milestones(project_id,name,due_date,status,position) VALUES(?,?,?,?,?)", projectId, name, sql(data.dueDate), sql(data.status ?? "Todo"), position);
764
+ event(database, "project", projectId, actor, "milestone_created", { name });
765
+ return { ok: true, _status: 201 };
766
+ }
767
+ if (action === "updates" && method === "POST") {
768
+ const body = text(data.body, "Write an update before saving");
769
+ const health = String(data.health ?? "On track");
770
+ if (!["On track", "At risk", "Off track"].includes(health))
771
+ fail("Unknown project health");
772
+ run(database, "INSERT INTO project_updates(project_id,health,body,author_id,created_at) VALUES(?,?,?,?,?)", projectId, health, body, actor, stamp);
773
+ event(database, "project", projectId, actor, "update_posted", { health });
774
+ return { ok: true, _status: 201 };
775
+ }
776
+ if (action === "dependencies" && method === "POST") {
777
+ const dependency = Number(data.dependsOnId);
778
+ if (projectId === dependency)
779
+ fail("A project cannot depend on itself");
780
+ run(database, "INSERT OR IGNORE INTO project_dependencies(project_id,depends_on_id,dependency_type) VALUES(?,?,?)", projectId, dependency, sql(data.type ?? "blocks"));
781
+ event(database, "project", projectId, actor, "dependency_added", {
782
+ dependsOnId: dependency,
783
+ });
784
+ return { ok: true, _status: 201 };
785
+ }
786
+ return null;
787
+ }
788
+ function mutateIssue(database, method, route, data, stamp, actor, paths) {
789
+ if (route === "/api/issues" && method === "POST") {
790
+ const title = text(data.title, "Issue title is required");
791
+ const teamId = Number(data.teamId ?? 1);
792
+ const team = one(database, "SELECT key FROM teams WHERE id=?", teamId);
793
+ if (!team)
794
+ fail("Team not found");
795
+ validateIssueLinks(database, data);
796
+ const number = nextIssueNumber(database, teamId, String(team.key));
797
+ const status = issueStatus(database, teamId, data.status);
798
+ run(database, "INSERT INTO issues(identifier,number,team_id,title,description,status_id,priority,assignee_id,creator_id,project_id,cycle_id,milestone_id,due_date,estimate,parent_id,recurring_rule,external_url,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", `${team.key}-${number}`, number, teamId, title, sql(data.description ?? ""), sql(status.id), priorityNumber(data.priority ?? 4), sql(data.assigneeId), actor, sql(data.projectId), sql(data.cycleId), sql(data.milestoneId), sql(data.dueDate), sql(data.estimate), sql(data.parentId), sql(data.recurringRule), sql(data.externalUrl), stamp, stamp);
799
+ const issueId = lastInsertId(database);
800
+ for (const label of array(data.labelIds))
801
+ run(database, "INSERT OR IGNORE INTO issue_labels(issue_id,label_id) VALUES(?,?)", issueId, sql(label));
802
+ run(database, "INSERT OR IGNORE INTO issue_subscribers(issue_id,member_id) VALUES(?,?)", issueId, actor);
803
+ for (const subscriber of array(data.subscriberIds))
804
+ run(database, "INSERT OR IGNORE INTO issue_subscribers VALUES(?,?)", issueId, sql(subscriber));
805
+ event(database, "issue", issueId, actor, "created", {
806
+ title,
807
+ status: String(status.name),
808
+ });
809
+ applyAutomations(database, teamId, issueId, "issue.created", actor);
810
+ return { issue: issueRow(database, issueId), _status: 201 };
811
+ }
812
+ const base = route.match(/^\/api\/issues\/([^/]+)$/);
813
+ if (base && ["PUT", "PATCH"].includes(method)) {
814
+ const current = one(database, "SELECT * FROM issues WHERE identifier=?", base[1]);
815
+ if (!current)
816
+ fail("Issue not found");
817
+ const issueId = Number(current.id);
818
+ const targetTeam = Number(data.teamId ?? current.team_id);
819
+ validateIssueLinks(database, data, current);
820
+ const oldCategory = String(scalar(database, "SELECT category FROM issue_statuses WHERE id=?", sql(current.status_id)));
821
+ const changes = {};
822
+ const map = {
823
+ title: "title",
824
+ description: "description",
825
+ assigneeId: "assignee_id",
826
+ projectId: "project_id",
827
+ cycleId: "cycle_id",
828
+ milestoneId: "milestone_id",
829
+ dueDate: "due_date",
830
+ estimate: "estimate",
831
+ parentId: "parent_id",
832
+ recurringRule: "recurring_rule",
833
+ externalUrl: "external_url",
834
+ };
835
+ for (const [key, column] of Object.entries(map))
836
+ if (key in data)
837
+ changes[column] = data[key];
838
+ if ("priority" in data)
839
+ changes.priority = priorityNumber(data.priority);
840
+ if ("status" in data)
841
+ changes.status_id = issueStatus(database, targetTeam, String(data.status)).id;
842
+ if ("teamId" in data) {
843
+ if (!one(database, "SELECT id FROM teams WHERE id=?", targetTeam))
844
+ fail("Team not found");
845
+ changes.team_id = targetTeam;
846
+ if (!("status" in data)) {
847
+ const name = scalar(database, "SELECT name FROM issue_statuses WHERE id=?", sql(current.status_id));
848
+ changes.status_id = (one(database, "SELECT id FROM issue_statuses WHERE team_id=? AND name=?", targetTeam, sql(name)) ?? issueStatus(database, targetTeam)).id;
849
+ }
850
+ }
851
+ updateColumns(database, "issues", "id", issueId, {
852
+ ...changes,
853
+ updated_at: stamp,
854
+ });
855
+ if ("labelIds" in data)
856
+ replaceLinks(database, "issue_labels", "issue_id", issueId, "label_id", array(data.labelIds));
857
+ if ("subscriberIds" in data)
858
+ replaceLinks(database, "issue_subscribers", "issue_id", issueId, "member_id", array(data.subscriberIds));
859
+ const changed = Object.fromEntries(Object.keys(data)
860
+ .filter((key) => key in map ||
861
+ [
862
+ "priority",
863
+ "status",
864
+ "teamId",
865
+ "labelIds",
866
+ "subscriberIds",
867
+ ].includes(key))
868
+ .map((key) => [key, data[key]]));
869
+ event(database, "issue", issueId, actor, "updated", changed);
870
+ applyAutomations(database, targetTeam, issueId, "issue.updated", actor);
871
+ const newCategory = String(scalar(database, "SELECT s.category FROM issues i JOIN issue_statuses s ON s.id=i.status_id WHERE i.id=?", issueId));
872
+ if (oldCategory !== "Completed" && newCategory === "Completed")
873
+ applyAutomations(database, targetTeam, issueId, "issue.completed", actor);
874
+ return { issue: issueRow(database, issueId) };
875
+ }
876
+ if (base && method === "DELETE") {
877
+ const row = one(database, "SELECT id FROM issues WHERE identifier=?", base[1]);
878
+ if (!row)
879
+ fail("Issue not found");
880
+ run(database, "UPDATE issues SET archived=1,updated_at=? WHERE id=?", stamp, sql(row.id));
881
+ event(database, "issue", Number(row.id), actor, "archived", {});
882
+ return { ok: true };
883
+ }
884
+ const action = route.match(/^\/api\/issues\/([^/]+)\/(restore|comments|attachments|relations)$/);
885
+ if (!action)
886
+ return null;
887
+ const issue = one(database, "SELECT id FROM issues WHERE identifier=?", action[1]);
888
+ if (!issue)
889
+ fail("Issue not found");
890
+ const issueId = Number(issue.id);
891
+ if (action[2] === "restore" && method === "POST") {
892
+ run(database, "UPDATE issues SET archived=0,updated_at=? WHERE id=?", stamp, issueId);
893
+ event(database, "issue", issueId, actor, "restored", {});
894
+ return { ok: true };
895
+ }
896
+ if (action[2] === "comments" && method === "POST") {
897
+ const body = text(data.body, "Comment is required");
898
+ run(database, "INSERT INTO comments(issue_id,author_id,body,created_at) VALUES(?,?,?,?)", issueId, actor, body, stamp);
899
+ event(database, "issue", issueId, actor, "commented", {
900
+ body: body.slice(0, 120),
901
+ });
902
+ return { issue: issueRow(database, issueId), _status: 201 };
903
+ }
904
+ if (action[2] === "relations" && method === "POST") {
905
+ const relatedIdentifier = data.relatedIssueId;
906
+ const url = data.url;
907
+ if (!relatedIdentifier && !url)
908
+ fail("Choose an issue or provide a URL");
909
+ let related = null;
910
+ if (relatedIdentifier) {
911
+ const found = one(database, "SELECT id FROM issues WHERE identifier=?", sql(relatedIdentifier));
912
+ if (!found)
913
+ fail("Related issue not found");
914
+ related = Number(found.id);
915
+ }
916
+ run(database, "INSERT INTO issue_relations(issue_id,related_issue_id,relation_type,url) VALUES(?,?,?,?)", issueId, related, sql(data.type ?? "relates to"), sql(url));
917
+ event(database, "issue", issueId, actor, "linked", {
918
+ relation: (relatedIdentifier ?? url),
919
+ });
920
+ return { issue: issueRow(database, issueId), _status: 201 };
921
+ }
922
+ if (action[2] === "attachments" && method === "POST")
923
+ return attachFiles(database, issueId, data, stamp, actor, paths);
924
+ return null;
925
+ }
926
+ function mutateGeneral(database, method, route, data, stamp, actor) {
927
+ if (route === "/api/views" && method === "POST") {
928
+ run(database, "INSERT INTO views(name,description,icon,entity,scope,owner_id,team_id,filters_json,display_json,is_favorite,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", text(data.name, "View name is required"), sql(data.description ?? ""), sql(data.icon ?? "◈"), sql(data.entity ?? "issues"), sql(data.scope ?? "Personal"), actor, sql(data.teamId), JSON.stringify(data.filters ?? {}), JSON.stringify(data.display ?? {}), data.isFavorite ? 1 : 0, stamp, stamp);
929
+ return { viewId: lastInsertId(database), _status: 201 };
930
+ }
931
+ const view = route.match(/^\/api\/views\/(\d+)$/);
932
+ if (view && ["PUT", "PATCH"].includes(method)) {
933
+ updateFields(database, "views", "id", Number(view[1]), data, {
934
+ name: "name",
935
+ description: "description",
936
+ icon: "icon",
937
+ entity: "entity",
938
+ scope: "scope",
939
+ teamId: "team_id",
940
+ isFavorite: "is_favorite",
941
+ });
942
+ if ("filters" in data)
943
+ run(database, "UPDATE views SET filters_json=?,updated_at=? WHERE id=?", JSON.stringify(data.filters), stamp, Number(view[1]));
944
+ if ("display" in data)
945
+ run(database, "UPDATE views SET display_json=?,updated_at=? WHERE id=?", JSON.stringify(data.display), stamp, Number(view[1]));
946
+ return { ok: true };
947
+ }
948
+ if (view && method === "DELETE") {
949
+ run(database, "DELETE FROM views WHERE id=?", Number(view[1]));
950
+ return { ok: true };
951
+ }
952
+ if (route === "/api/cycles" && method === "POST") {
953
+ run(database, "INSERT INTO cycles(team_id,name,starts_at,ends_at,status,capacity,created_at) VALUES(?,?,?,?,?,?,?)", sql(data.teamId ?? 1), text(data.name, "Cycle name is required"), sql(data.startsAt), sql(data.endsAt), sql(data.status ?? "Upcoming"), sql(data.capacity ?? 20), stamp);
954
+ return { cycleId: lastInsertId(database), _status: 201 };
955
+ }
956
+ if (route === "/api/preferences" && ["PUT", "PATCH"].includes(method)) {
957
+ updateSetting(database, "preferences", withoutActor(data));
958
+ return { ok: true };
959
+ }
960
+ const notification = route.match(/^\/api\/notifications\/(\d+)\/(read|unread|archive)$/);
961
+ if (notification && method === "POST") {
962
+ const assignment = notification[2] === "archive"
963
+ ? "archived=1"
964
+ : notification[2] === "read"
965
+ ? "is_read=1"
966
+ : "is_read=0";
967
+ run(database, `UPDATE notifications SET ${assignment} WHERE id=? AND recipient_id=?`, Number(notification[1]), actor);
968
+ return { ok: true };
969
+ }
970
+ if (route === "/api/notifications/read-all" && method === "POST") {
971
+ run(database, "UPDATE notifications SET is_read=1 WHERE recipient_id=?", actor);
972
+ return { ok: true };
973
+ }
974
+ if ([
975
+ "/api/notifications/archive-selected",
976
+ "/api/notifications/read-selected",
977
+ ].includes(route) &&
978
+ method === "POST") {
979
+ const ids = array(data.ids).map(Number);
980
+ if (!ids.length)
981
+ return { ok: true, count: 0 };
982
+ const set = route.endsWith("archive-selected") ? "archived=1" : "is_read=1";
983
+ const statement = database.prepare(`UPDATE notifications SET ${set} WHERE recipient_id=? AND id IN (${ids.map(() => "?").join(",")})`);
984
+ const result = statement.run(actor, ...ids);
985
+ return { ok: true, count: Number(result.changes) };
986
+ }
987
+ if (route === "/api/import" && method === "POST") {
988
+ const created = [];
989
+ for (const raw of array(data.issues)) {
990
+ const item = {
991
+ ...raw,
992
+ actorId: actor,
993
+ teamId: raw.teamId ?? 1,
994
+ };
995
+ const result = dispatchMutation(database, "POST", "/api/issues", item, {
996
+ database: "",
997
+ dataDirectory: "",
998
+ snapshotDirectory: "",
999
+ attachmentsDirectory: "",
1000
+ backupsDirectory: "",
1001
+ });
1002
+ created.push(String(result.issue.identifier));
1003
+ }
1004
+ return { created, count: created.length };
1005
+ }
1006
+ if (route === "/api/automations" && method === "POST") {
1007
+ run(database, "INSERT INTO automations(team_id,name,trigger_json,condition_json,action_json,enabled,created_at,updated_at) VALUES(?,?,?,?,?,1,?,?)", sql(data.teamId ?? 1), text(data.name, "Automation name is required"), JSON.stringify(data.trigger ?? {}), JSON.stringify(data.condition ?? {}), JSON.stringify(data.action ?? {}), stamp, stamp);
1008
+ return { automationId: lastInsertId(database), _status: 201 };
1009
+ }
1010
+ if (route === "/api/recurring" && method === "POST") {
1011
+ run(database, "INSERT INTO recurring_rules(team_id,title,description,cadence,next_run,enabled,created_at) VALUES(?,?,?,?,?,1,?)", sql(data.teamId ?? 1), sql(data.title), sql(data.description ?? ""), sql(data.cadence ?? "Weekly"), sql(data.nextRun ?? stamp.slice(0, 10)), stamp);
1012
+ return { ok: true, _status: 201 };
1013
+ }
1014
+ if (route === "/api/settings" && ["PUT", "PATCH"].includes(method)) {
1015
+ const key = String(data.key ?? "");
1016
+ if (!key)
1017
+ fail("Setting key is required");
1018
+ updateSetting(database, key, data.value ?? {});
1019
+ if (key === "workspace" && isObject(data.value))
1020
+ updateFields(database, "workspace", "id", 1, data.value, {
1021
+ name: "name",
1022
+ icon: "icon",
1023
+ });
1024
+ return { ok: true };
1025
+ }
1026
+ return { error: "Not found", _status: 404 };
1027
+ }
1028
+ export function search(database, query, scope = "All") {
1029
+ const trimmed = query.trim();
1030
+ if (!trimmed)
1031
+ return { results: [] };
1032
+ const like = `%${trimmed}%`;
1033
+ const result = [];
1034
+ if (["All", "Issues"].includes(scope))
1035
+ result.push(...rows(database, "SELECT i.id,i.identifier AS key,i.title,s.name AS status,'Issue' AS type FROM issues i JOIN issue_statuses s ON s.id=i.status_id WHERE i.archived=0 AND (i.identifier LIKE ? OR i.title LIKE ? OR i.description LIKE ?) ORDER BY i.updated_at DESC LIMIT 40", like, like, like));
1036
+ if (["All", "Projects"].includes(scope))
1037
+ result.push(...rows(database, "SELECT id,id AS key,name AS title,status,'Project' AS type FROM projects WHERE archived=0 AND (name LIKE ? OR summary LIKE ? OR description LIKE ?) LIMIT 30", like, like, like));
1038
+ return { results: result.slice(0, 80) };
1039
+ }
1040
+ export function runDueRecurring(database, paths) {
1041
+ const today = new Date().toISOString().slice(0, 10);
1042
+ let created = 0;
1043
+ for (const rule of rows(database, "SELECT * FROM recurring_rules WHERE enabled=1 ORDER BY id")) {
1044
+ let due = String(rule.next_run).slice(0, 10);
1045
+ if (due > today)
1046
+ continue;
1047
+ dispatchMutation(database, "POST", "/api/issues", {
1048
+ actorId: 1,
1049
+ teamId: Number(rule.team_id),
1050
+ title: String(rule.title),
1051
+ description: String(rule.description),
1052
+ recurringRule: {
1053
+ ruleId: Number(rule.id),
1054
+ cadence: String(rule.cadence),
1055
+ },
1056
+ }, paths);
1057
+ do
1058
+ due = recurringNext(due, String(rule.cadence));
1059
+ while (due <= today);
1060
+ run(database, "UPDATE recurring_rules SET next_run=? WHERE id=?", due, sql(rule.id));
1061
+ created += 1;
1062
+ }
1063
+ return created;
1064
+ }
1065
+ function attachFiles(database, issueId, data, stamp, actor, paths) {
1066
+ const files = array(data.files);
1067
+ if (!files.length)
1068
+ fail("Choose at least one attachment");
1069
+ fs.mkdirSync(paths.attachmentsDirectory, { recursive: true });
1070
+ let total = 0;
1071
+ const created = [];
1072
+ for (const raw of files) {
1073
+ const item = raw;
1074
+ const name = path
1075
+ .basename(String(item.name ?? "attachment"))
1076
+ .trim()
1077
+ .slice(0, 180) || "attachment";
1078
+ let content;
1079
+ try {
1080
+ content = Buffer.from(String(item.data ?? "")
1081
+ .split(",")
1082
+ .at(-1) ?? "", "base64");
1083
+ }
1084
+ catch {
1085
+ fail("Attachment data is invalid");
1086
+ }
1087
+ total += content.length;
1088
+ if (!content.length)
1089
+ fail("Empty files cannot be attached");
1090
+ if (content.length > 3_000_000 || total > 3_000_000)
1091
+ fail("Attachments must total 3 MB or less");
1092
+ const storageName = randomUUID().replaceAll("-", "");
1093
+ fs.writeFileSync(path.join(paths.attachmentsDirectory, storageName), content);
1094
+ const contentType = /^[A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+$/.test(String(item.type ?? ""))
1095
+ ? String(item.type)
1096
+ : "application/octet-stream";
1097
+ run(database, "INSERT INTO issue_attachments(issue_id,name,content_type,size,storage_name,created_at) VALUES(?,?,?,?,?,?)", issueId, name, contentType, content.length, storageName, stamp);
1098
+ created.push({ id: lastInsertId(database), name });
1099
+ }
1100
+ event(database, "issue", issueId, actor, "attachments_added", {
1101
+ count: created.length,
1102
+ });
1103
+ return { attachments: created, _status: 201 };
1104
+ }
1105
+ function applyAutomations(database, teamId, issueId, eventName, actor) {
1106
+ const issue = one(database, "SELECT i.*,s.name AS status FROM issues i JOIN issue_statuses s ON s.id=i.status_id WHERE i.id=?", issueId);
1107
+ if (!issue)
1108
+ return;
1109
+ const labels = new Set(rows(database, "SELECT l.name FROM labels l JOIN issue_labels il ON il.label_id=l.id WHERE il.issue_id=?", issueId).map((row) => String(row.name)));
1110
+ for (const rule of rows(database, "SELECT * FROM automations WHERE team_id=? AND enabled=1 ORDER BY id", teamId)) {
1111
+ const trigger = parseJson(rule.trigger_json, {});
1112
+ const condition = parseJson(rule.condition_json, {});
1113
+ const action = parseJson(rule.action_json, {});
1114
+ if (trigger.event !== eventName)
1115
+ continue;
1116
+ if (![null, "", "Any", undefined].includes(condition.priority) &&
1117
+ priorities[Number(issue.priority)] !== condition.priority)
1118
+ continue;
1119
+ if (![null, "", "Any", undefined].includes(condition.label) &&
1120
+ !labels.has(String(condition.label)))
1121
+ continue;
1122
+ const name = String(action.name ?? "");
1123
+ const changes = [];
1124
+ if (action.assignTo !== undefined) {
1125
+ const id = requireActor(database, action.assignTo);
1126
+ run(database, "UPDATE issues SET assignee_id=?,updated_at=? WHERE id=?", id, now(), issueId);
1127
+ changes.push({ field: "assignee_id", value: id });
1128
+ }
1129
+ else if (name === "Assign to me") {
1130
+ run(database, "UPDATE issues SET assignee_id=?,updated_at=? WHERE id=?", actor, now(), issueId);
1131
+ changes.push({ field: "assignee_id", value: actor });
1132
+ }
1133
+ else if (name.startsWith("Add ") && name.endsWith(" label")) {
1134
+ const labelName = name.slice(4, -6).trim();
1135
+ const label = one(database, "SELECT id FROM labels WHERE name=? AND (team_id=? OR team_id IS NULL) ORDER BY team_id DESC LIMIT 1", labelName, teamId);
1136
+ if (label && !labels.has(labelName)) {
1137
+ run(database, "INSERT OR IGNORE INTO issue_labels(issue_id,label_id) VALUES(?,?)", issueId, sql(label.id));
1138
+ labels.add(labelName);
1139
+ changes.push({ field: "label", value: labelName });
1140
+ }
1141
+ }
1142
+ else if (name.startsWith("Set status: ")) {
1143
+ const status = one(database, "SELECT id FROM issue_statuses WHERE team_id=? AND name=?", teamId, name.split(":", 2)[1].trim());
1144
+ if (status) {
1145
+ run(database, "UPDATE issues SET status_id=?,updated_at=? WHERE id=?", sql(status.id), now(), issueId);
1146
+ changes.push({ field: "status_id", value: Number(status.id) });
1147
+ }
1148
+ }
1149
+ if (changes.length)
1150
+ event(database, "issue", issueId, actor, "automation_applied", {
1151
+ automation: String(rule.name),
1152
+ changes,
1153
+ });
1154
+ }
1155
+ }
1156
+ function validateActorFields(database, data, method, route) {
1157
+ const existing = {};
1158
+ if (["PATCH", "PUT"].includes(method)) {
1159
+ const issue = route.match(/^\/api\/issues\/([^/]+)$/);
1160
+ if (issue) {
1161
+ const row = one(database, "SELECT id,assignee_id FROM issues WHERE identifier=?", issue[1]);
1162
+ if (row) {
1163
+ existing.assigneeId = new Set(row.assignee_id == null ? [] : [Number(row.assignee_id)]);
1164
+ existing.subscriberIds = new Set(rows(database, "SELECT member_id FROM issue_subscribers WHERE issue_id=?", sql(row.id)).map((item) => Number(item.member_id)));
1165
+ }
1166
+ }
1167
+ const project = route.match(/^\/api\/projects\/(\d+)$/);
1168
+ if (project) {
1169
+ const row = one(database, "SELECT lead_id FROM projects WHERE id=?", Number(project[1]));
1170
+ if (row) {
1171
+ existing.leadId = new Set(row.lead_id == null ? [] : [Number(row.lead_id)]);
1172
+ existing.memberIds = new Set(rows(database, "SELECT member_id FROM project_members WHERE project_id=?", Number(project[1])).map((item) => Number(item.member_id)));
1173
+ }
1174
+ }
1175
+ const team = route.match(/^\/api\/teams\/(\d+)$/);
1176
+ if (team)
1177
+ existing.memberIds = new Set(rows(database, "SELECT member_id FROM team_members WHERE team_id=?", Number(team[1])).map((item) => Number(item.member_id)));
1178
+ }
1179
+ for (const field of ["assigneeId", "leadId", "memberIds", "subscriberIds"]) {
1180
+ if (!(field in data))
1181
+ continue;
1182
+ const values = ["memberIds", "subscriberIds"].includes(field)
1183
+ ? array(data[field])
1184
+ : [data[field]];
1185
+ for (const value of values) {
1186
+ if (value === null && ["assigneeId", "leadId"].includes(field))
1187
+ continue;
1188
+ if (!existing[field]?.has(Number(value)))
1189
+ requireActor(database, value);
1190
+ }
1191
+ }
1192
+ }
1193
+ function validateIssueLinks(database, data, current = {}) {
1194
+ const teamId = Number(data.teamId ?? current.team_id ?? 1);
1195
+ const projectId = data.projectId ?? current.project_id;
1196
+ if (projectId != null &&
1197
+ !one(database, "SELECT 1 FROM project_teams WHERE project_id=? AND team_id=?", sql(projectId), teamId))
1198
+ fail("Add this team to the project before linking its issues");
1199
+ const cycle = data.cycleId ?? current.cycle_id;
1200
+ if (cycle != null) {
1201
+ const row = one(database, "SELECT team_id FROM cycles WHERE id=?", sql(cycle));
1202
+ if (!row || Number(row.team_id) !== teamId)
1203
+ fail("Issue cycle must belong to its team");
1204
+ }
1205
+ const milestone = data.milestoneId ?? current.milestone_id;
1206
+ if (milestone != null) {
1207
+ const row = one(database, "SELECT project_id FROM milestones WHERE id=?", sql(milestone));
1208
+ if (!row || row.project_id !== projectId)
1209
+ fail("Issue milestone must belong to its project");
1210
+ }
1211
+ let parent = data.parentId ?? current.parent_id;
1212
+ const seen = new Set(current.id ? [Number(current.id)] : []);
1213
+ while (parent != null) {
1214
+ if (seen.has(Number(parent)))
1215
+ fail("Issue parent links must not form a cycle");
1216
+ seen.add(Number(parent));
1217
+ const row = one(database, "SELECT parent_id FROM issues WHERE id=?", sql(parent));
1218
+ if (!row)
1219
+ fail("Parent issue not found");
1220
+ parent = row.parent_id;
1221
+ }
1222
+ }
1223
+ function nextIssueNumber(database, teamId, key) {
1224
+ return Number(scalar(database, "SELECT COALESCE(MAX(number),0)+1 FROM issues WHERE team_id=? OR substr(identifier,1,?)=?", teamId, key.length + 1, `${key}-`));
1225
+ }
1226
+ function issueStatus(database, teamId, name) {
1227
+ const row = name === undefined
1228
+ ? one(database, "SELECT id,name FROM issue_statuses WHERE team_id=? AND is_default=1 ORDER BY position,id LIMIT 1", teamId)
1229
+ : one(database, "SELECT id,name FROM issue_statuses WHERE team_id=? AND name=? ORDER BY position,id LIMIT 1", teamId, name);
1230
+ if (!row)
1231
+ fail(name === undefined
1232
+ ? "Team has no default workflow status"
1233
+ : "Unknown workflow status");
1234
+ return row;
1235
+ }
1236
+ function projectRecord(database, project) {
1237
+ const id = Number(project.id);
1238
+ const statuses = rows(database, "SELECT status_id,COUNT(*) AS n FROM issues WHERE project_id=? AND archived=0 GROUP BY status_id", id);
1239
+ const done = new Set(rows(database, "SELECT id FROM issue_statuses WHERE category='Completed'").map((r) => Number(r.id)));
1240
+ const total = statuses.reduce((n, r) => n + Number(r.n), 0);
1241
+ const complete = statuses
1242
+ .filter((r) => done.has(Number(r.status_id)))
1243
+ .reduce((n, r) => n + Number(r.n), 0);
1244
+ const milestoneTotal = Number(scalar(database, "SELECT COUNT(*) FROM milestones WHERE project_id=?", id));
1245
+ const milestoneDone = Number(scalar(database, "SELECT COUNT(*) FROM milestones WHERE project_id=? AND status='Done'", id));
1246
+ const parts = [
1247
+ total ? complete / total : null,
1248
+ milestoneTotal ? milestoneDone / milestoneTotal : null,
1249
+ ].filter((v) => v !== null);
1250
+ return {
1251
+ ...project,
1252
+ priority_name: priorities[Number(project.priority)],
1253
+ lead: project.lead_id
1254
+ ? actorRecord(database, Number(project.lead_id))
1255
+ : null,
1256
+ teams: rows(database, "SELECT t.id,t.name,t.key,t.icon FROM teams t JOIN project_teams pt ON pt.team_id=t.id WHERE pt.project_id=?", id),
1257
+ members: rows(database, "SELECT member_id FROM project_members WHERE project_id=?", id).map((r) => actorRecord(database, Number(r.member_id))),
1258
+ labels: rows(database, "SELECT l.id,l.name,l.color FROM labels l JOIN project_labels pl ON pl.label_id=l.id WHERE pl.project_id=?", id),
1259
+ milestones: rows(database, "SELECT * FROM milestones WHERE project_id=? ORDER BY position,id", id),
1260
+ updates: rows(database, "SELECT u.*,CASE WHEN m.kind='agent' THEN m.name||' (Agent)' ELSE m.name END AS author FROM project_updates u LEFT JOIN actors m ON m.id=u.author_id WHERE u.project_id=? ORDER BY u.created_at DESC,u.id DESC", id),
1261
+ dependencies: rows(database, "SELECT d.dependency_type,p.id,p.name FROM project_dependencies d JOIN projects p ON p.id=d.depends_on_id WHERE d.project_id=?", id),
1262
+ issue_count: total,
1263
+ completed_count: complete,
1264
+ milestone_count: milestoneTotal,
1265
+ completed_milestones: milestoneDone,
1266
+ progress: Math.round((parts.reduce((a, b) => a + b, 0) / (parts.length || 1)) * 100),
1267
+ issues: rows(database, "SELECT i.id,i.identifier,i.title,s.name AS status,i.priority FROM issues i JOIN issue_statuses s ON s.id=i.status_id WHERE i.project_id=? AND i.archived=0 ORDER BY i.updated_at DESC", id),
1268
+ activity: rows(database, "SELECT e.actor_id,e.action,e.payload,e.created_at,CASE WHEN m.kind='agent' THEN m.name||' (Agent)' ELSE m.name END AS actor FROM activity_events e LEFT JOIN actors m ON m.id=e.actor_id WHERE e.entity_type='project' AND e.entity_id=? ORDER BY e.created_at DESC", id),
1269
+ };
1270
+ }
1271
+ function cycleRecord(database, cycle) {
1272
+ const stats = rows(database, "SELECT s.category,COUNT(*) AS total,COALESCE(SUM(COALESCE(i.estimate,0)),0) AS points FROM issues i JOIN issue_statuses s ON s.id=i.status_id WHERE i.cycle_id=? AND i.archived=0 GROUP BY s.category", sql(cycle.id));
1273
+ const count = stats.reduce((n, r) => n + Number(r.total), 0);
1274
+ const complete = stats
1275
+ .filter((r) => r.category === "Completed")
1276
+ .reduce((n, r) => n + Number(r.total), 0);
1277
+ return {
1278
+ ...cycle,
1279
+ issue_count: count,
1280
+ completed_count: complete,
1281
+ estimate_points: stats.reduce((n, r) => n + Number(r.points), 0),
1282
+ progress: count ? Math.round((complete * 100) / count) : 0,
1283
+ issues: rows(database, "SELECT identifier,title FROM issues WHERE cycle_id=? AND archived=0 ORDER BY number", sql(cycle.id)),
1284
+ };
1285
+ }
1286
+ function event(database, type, id, actor, action, payload) {
1287
+ run(database, "INSERT INTO activity_events(entity_type,entity_id,actor_id,action,payload,created_at) VALUES(?,?,?,?,?,?)", type, id, actor, action, JSON.stringify(payload), now());
1288
+ }
1289
+ function updateSetting(database, key, value) {
1290
+ const row = one(database, "SELECT value_json FROM app_settings WHERE key=?", key);
1291
+ const existing = parseJson(row?.value_json, {});
1292
+ if (isObject(existing) && isObject(value))
1293
+ value = { ...existing, ...value };
1294
+ run(database, "INSERT INTO app_settings(key,value_json,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at", key, JSON.stringify(value), now());
1295
+ }
1296
+ function priorityNumber(value) {
1297
+ if (Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 4)
1298
+ return Number(value);
1299
+ if (typeof value === "string" && /^\d$/.test(value) && Number(value) <= 4)
1300
+ return Number(value);
1301
+ const index = priorities.indexOf(value);
1302
+ if (index < 0)
1303
+ fail("Unknown priority");
1304
+ return index;
1305
+ }
1306
+ function recurringNext(value, cadence) {
1307
+ const [year, month, day] = value.split("-").map(Number);
1308
+ const date = new Date(Date.UTC(year, month - 1, day));
1309
+ if (cadence === "Daily")
1310
+ date.setUTCDate(date.getUTCDate() + 1);
1311
+ else if (cadence === "Every 2 weeks")
1312
+ date.setUTCDate(date.getUTCDate() + 14);
1313
+ else if (cadence === "Monthly") {
1314
+ const targetMonth = month === 12 ? 1 : month + 1;
1315
+ const targetYear = month === 12 ? year + 1 : year;
1316
+ const lastDay = new Date(Date.UTC(targetYear, targetMonth, 0)).getUTCDate();
1317
+ date.setTime(Date.UTC(targetYear, targetMonth - 1, Math.min(day, lastDay)));
1318
+ }
1319
+ else
1320
+ date.setUTCDate(date.getUTCDate() + 7);
1321
+ return date.toISOString().slice(0, 10);
1322
+ }
1323
+ function updateFields(database, table, key, id, data, map, transforms = {}) {
1324
+ const changes = {};
1325
+ for (const [source, target] of Object.entries(map))
1326
+ if (source in data)
1327
+ changes[target] = (transforms[source] ?? ((v) => v))(data[source]);
1328
+ updateColumns(database, table, key, id, changes);
1329
+ return Object.keys(changes).length > 0;
1330
+ }
1331
+ function updateColumns(database, table, key, id, changes) {
1332
+ const entries = Object.entries(changes);
1333
+ if (!entries.length)
1334
+ return;
1335
+ database
1336
+ .prepare(`UPDATE ${table} SET ${entries.map(([column]) => `${column}=?`).join(",")} WHERE ${key}=?`)
1337
+ .run(...entries.map(([, value]) => sql(value)), id);
1338
+ }
1339
+ function replaceLinks(database, table, key, id, column, values) {
1340
+ run(database, `DELETE FROM ${table} WHERE ${key}=?`, id);
1341
+ for (const value of values)
1342
+ run(database, `INSERT OR IGNORE INTO ${table}(${key},${column}) VALUES(?,?)`, id, sql(value));
1343
+ }
1344
+ function rows(database, query, ...values) {
1345
+ return database.prepare(query).all(...values);
1346
+ }
1347
+ function one(database, query, ...values) {
1348
+ return database.prepare(query).get(...values);
1349
+ }
1350
+ function scalar(database, query, ...values) {
1351
+ const row = one(database, query, ...values);
1352
+ return row ? Object.values(row)[0] : undefined;
1353
+ }
1354
+ function run(database, query, ...values) {
1355
+ return database.prepare(query).run(...values);
1356
+ }
1357
+ function sql(value) {
1358
+ if (value === undefined)
1359
+ return null;
1360
+ if (typeof value === "boolean")
1361
+ return value ? 1 : 0;
1362
+ return value;
1363
+ }
1364
+ function text(value, message) {
1365
+ const result = String(value ?? "").trim();
1366
+ if (!result)
1367
+ fail(message);
1368
+ return result;
1369
+ }
1370
+ function array(value, fallback = []) {
1371
+ return Array.isArray(value) ? value : fallback;
1372
+ }
1373
+ function parseJson(value, fallback) {
1374
+ if (typeof value !== "string" || !value)
1375
+ return fallback;
1376
+ try {
1377
+ return JSON.parse(value);
1378
+ }
1379
+ catch {
1380
+ return fallback;
1381
+ }
1382
+ }
1383
+ function withoutActor(data) {
1384
+ const { actorId: _, ...rest } = data;
1385
+ return rest;
1386
+ }
1387
+ function isObject(value) {
1388
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1389
+ }
1390
+ function fail(message) {
1391
+ throw new InvalidInputError(message);
1392
+ }
1393
+ //# sourceMappingURL=workspace.js.map