@kernhq/module-tracker 0.1.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.
Files changed (158) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +265 -0
  3. package/dist/contract/events.d.ts +140 -0
  4. package/dist/contract/events.d.ts.map +1 -0
  5. package/dist/contract/events.js +51 -0
  6. package/dist/contract/events.js.map +1 -0
  7. package/dist/contract/index.d.ts +6 -0
  8. package/dist/contract/index.d.ts.map +1 -0
  9. package/dist/contract/index.js +6 -0
  10. package/dist/contract/index.js.map +1 -0
  11. package/dist/contract/models.d.ts +2547 -0
  12. package/dist/contract/models.d.ts.map +1 -0
  13. package/dist/contract/models.js +1214 -0
  14. package/dist/contract/models.js.map +1 -0
  15. package/dist/contract/notifications.d.ts +4 -0
  16. package/dist/contract/notifications.d.ts.map +1 -0
  17. package/dist/contract/notifications.js +46 -0
  18. package/dist/contract/notifications.js.map +1 -0
  19. package/dist/contract/permissions.d.ts +161 -0
  20. package/dist/contract/permissions.d.ts.map +1 -0
  21. package/dist/contract/permissions.js +192 -0
  22. package/dist/contract/permissions.js.map +1 -0
  23. package/dist/contract/router.d.ts +9659 -0
  24. package/dist/contract/router.d.ts.map +1 -0
  25. package/dist/contract/router.js +732 -0
  26. package/dist/contract/router.js.map +1 -0
  27. package/dist/kql/ast.d.ts +90 -0
  28. package/dist/kql/ast.d.ts.map +1 -0
  29. package/dist/kql/ast.js +80 -0
  30. package/dist/kql/ast.js.map +1 -0
  31. package/dist/kql/dates.d.ts +16 -0
  32. package/dist/kql/dates.d.ts.map +1 -0
  33. package/dist/kql/dates.js +54 -0
  34. package/dist/kql/dates.js.map +1 -0
  35. package/dist/kql/fields.d.ts +61 -0
  36. package/dist/kql/fields.d.ts.map +1 -0
  37. package/dist/kql/fields.js +106 -0
  38. package/dist/kql/fields.js.map +1 -0
  39. package/dist/kql/index.d.ts +16 -0
  40. package/dist/kql/index.d.ts.map +1 -0
  41. package/dist/kql/index.js +16 -0
  42. package/dist/kql/index.js.map +1 -0
  43. package/dist/kql/lexer.d.ts +28 -0
  44. package/dist/kql/lexer.d.ts.map +1 -0
  45. package/dist/kql/lexer.js +127 -0
  46. package/dist/kql/lexer.js.map +1 -0
  47. package/dist/kql/parser.d.ts +14 -0
  48. package/dist/kql/parser.d.ts.map +1 -0
  49. package/dist/kql/parser.js +290 -0
  50. package/dist/kql/parser.js.map +1 -0
  51. package/dist/kql/suggest.d.ts +8 -0
  52. package/dist/kql/suggest.d.ts.map +1 -0
  53. package/dist/kql/suggest.js +96 -0
  54. package/dist/kql/suggest.js.map +1 -0
  55. package/dist/kql/validate.d.ts +10 -0
  56. package/dist/kql/validate.d.ts.map +1 -0
  57. package/dist/kql/validate.js +84 -0
  58. package/dist/kql/validate.js.map +1 -0
  59. package/dist/server/index.d.ts +17 -0
  60. package/dist/server/index.d.ts.map +1 -0
  61. package/dist/server/index.js +583 -0
  62. package/dist/server/index.js.map +1 -0
  63. package/dist/server/kql/compile.d.ts +49 -0
  64. package/dist/server/kql/compile.d.ts.map +1 -0
  65. package/dist/server/kql/compile.js +372 -0
  66. package/dist/server/kql/compile.js.map +1 -0
  67. package/dist/server/rank.d.ts +23 -0
  68. package/dist/server/rank.d.ts.map +1 -0
  69. package/dist/server/rank.js +66 -0
  70. package/dist/server/rank.js.map +1 -0
  71. package/dist/server/rich.d.ts +30 -0
  72. package/dist/server/rich.d.ts.map +1 -0
  73. package/dist/server/rich.js +97 -0
  74. package/dist/server/rich.js.map +1 -0
  75. package/dist/server/router.d.ts +10589 -0
  76. package/dist/server/router.d.ts.map +1 -0
  77. package/dist/server/router.js +431 -0
  78. package/dist/server/router.js.map +1 -0
  79. package/dist/server/schema.d.ts +6622 -0
  80. package/dist/server/schema.d.ts.map +1 -0
  81. package/dist/server/schema.js +581 -0
  82. package/dist/server/schema.js.map +1 -0
  83. package/dist/server/services/access.d.ts +53 -0
  84. package/dist/server/services/access.d.ts.map +1 -0
  85. package/dist/server/services/access.js +146 -0
  86. package/dist/server/services/access.js.map +1 -0
  87. package/dist/server/services/comments.d.ts +36 -0
  88. package/dist/server/services/comments.d.ts.map +1 -0
  89. package/dist/server/services/comments.js +231 -0
  90. package/dist/server/services/comments.js.map +1 -0
  91. package/dist/server/services/config.d.ts +130 -0
  92. package/dist/server/services/config.d.ts.map +1 -0
  93. package/dist/server/services/config.js +797 -0
  94. package/dist/server/services/config.js.map +1 -0
  95. package/dist/server/services/db.d.ts +93 -0
  96. package/dist/server/services/db.d.ts.map +1 -0
  97. package/dist/server/services/db.js +519 -0
  98. package/dist/server/services/db.js.map +1 -0
  99. package/dist/server/services/imports.d.ts +37 -0
  100. package/dist/server/services/imports.d.ts.map +1 -0
  101. package/dist/server/services/imports.js +324 -0
  102. package/dist/server/services/imports.js.map +1 -0
  103. package/dist/server/services/index.d.ts +36 -0
  104. package/dist/server/services/index.d.ts.map +1 -0
  105. package/dist/server/services/index.js +57 -0
  106. package/dist/server/services/index.js.map +1 -0
  107. package/dist/server/services/intake.d.ts +47 -0
  108. package/dist/server/services/intake.d.ts.map +1 -0
  109. package/dist/server/services/intake.js +268 -0
  110. package/dist/server/services/intake.js.map +1 -0
  111. package/dist/server/services/issues.d.ts +763 -0
  112. package/dist/server/services/issues.d.ts.map +1 -0
  113. package/dist/server/services/issues.js +1381 -0
  114. package/dist/server/services/issues.js.map +1 -0
  115. package/dist/server/services/notify.d.ts +51 -0
  116. package/dist/server/services/notify.d.ts.map +1 -0
  117. package/dist/server/services/notify.js +90 -0
  118. package/dist/server/services/notify.js.map +1 -0
  119. package/dist/server/services/planning.d.ts +62 -0
  120. package/dist/server/services/planning.d.ts.map +1 -0
  121. package/dist/server/services/planning.js +614 -0
  122. package/dist/server/services/planning.js.map +1 -0
  123. package/dist/server/services/projects.d.ts +43 -0
  124. package/dist/server/services/projects.d.ts.map +1 -0
  125. package/dist/server/services/projects.js +375 -0
  126. package/dist/server/services/projects.js.map +1 -0
  127. package/dist/server/services/query.d.ts +24 -0
  128. package/dist/server/services/query.d.ts.map +1 -0
  129. package/dist/server/services/query.js +312 -0
  130. package/dist/server/services/query.js.map +1 -0
  131. package/dist/server/services/reports.d.ts +30 -0
  132. package/dist/server/services/reports.d.ts.map +1 -0
  133. package/dist/server/services/reports.js +295 -0
  134. package/dist/server/services/reports.js.map +1 -0
  135. package/dist/server/services/time.d.ts +53 -0
  136. package/dist/server/services/time.d.ts.map +1 -0
  137. package/dist/server/services/time.js +192 -0
  138. package/dist/server/services/time.js.map +1 -0
  139. package/dist/server/services/transitions.d.ts +61 -0
  140. package/dist/server/services/transitions.d.ts.map +1 -0
  141. package/dist/server/services/transitions.js +493 -0
  142. package/dist/server/services/transitions.js.map +1 -0
  143. package/dist/server/services/views.d.ts +33 -0
  144. package/dist/server/services/views.d.ts.map +1 -0
  145. package/dist/server/services/views.js +195 -0
  146. package/dist/server/services/views.js.map +1 -0
  147. package/migrations/0000_init.sql +529 -0
  148. package/migrations/0001_rls.sql +259 -0
  149. package/migrations/meta/0000_snapshot.json +4140 -0
  150. package/migrations/meta/_journal.json +20 -0
  151. package/package.json +82 -0
  152. package/src/client/api.ts +15 -0
  153. package/src/client/format.ts +164 -0
  154. package/src/client/group.ts +150 -0
  155. package/src/client/index.ts +60 -0
  156. package/src/client/kql.ts +492 -0
  157. package/src/client/rank.ts +87 -0
  158. package/src/client/types.ts +57 -0
@@ -0,0 +1,797 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { builtinRegistry, createWorkflowFromTemplate, sortedStatuses, validateDefinition, workflowTemplates, } from '@kernhq/workflow';
3
+ import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
4
+ import { HierarchyRules, } from '../../contract/models.js';
5
+ import { fieldDefs, fieldSchemes, hierarchyRules, issues, projects, typeSchemes, workflowSchemes, workflows, workItemTypes, } from '../schema.js';
6
+ import { toFieldDef, toFieldScheme, toTypeScheme, toWorkflow, toWorkflowScheme, toWorkItemType, } from './db.js';
7
+ const DEFAULT_HIERARCHY = HierarchyRules.parse({});
8
+ let validationRegistry = null;
9
+ /** Rule registry used to check that a definition only references rules the tracker can execute. */
10
+ const registryForValidation = () => {
11
+ validationRegistry ??= builtinRegistry();
12
+ return validationRegistry;
13
+ };
14
+ /** Types seeded into a fresh project, per project template. */
15
+ const TYPE_SEEDS = {
16
+ software: [
17
+ { key: 'epic', name: 'Epic', level: 1, icon: 'zap' },
18
+ { key: 'story', name: 'Story', level: 0, icon: 'bookmark' },
19
+ { key: 'task', name: 'Task', level: 0, icon: 'square-check-big' },
20
+ { key: 'bug', name: 'Bug', level: 0, icon: 'bug' },
21
+ { key: 'sub_task', name: 'Sub-task', level: -1, icon: 'git-branch' },
22
+ ],
23
+ kanban: [
24
+ { key: 'task', name: 'Task', level: 0, icon: 'square-check-big' },
25
+ { key: 'bug', name: 'Bug', level: 0, icon: 'bug' },
26
+ ],
27
+ simple: [{ key: 'task', name: 'Task', level: 0, icon: 'square-check-big' }],
28
+ blank: [{ key: 'task', name: 'Task', level: 0, icon: 'square-check-big' }],
29
+ };
30
+ const DEFAULT_TYPE_KEY = {
31
+ software: 'story',
32
+ kanban: 'task',
33
+ simple: 'task',
34
+ blank: 'task',
35
+ };
36
+ const WORKFLOW_FOR_TEMPLATE = {
37
+ software: 'software',
38
+ kanban: 'kanban',
39
+ simple: 'simple',
40
+ blank: 'simple',
41
+ };
42
+ /** Work item types, custom fields, workflows and the schemes that bind them to projects. */
43
+ export class ConfigService {
44
+ kernel;
45
+ access;
46
+ notify;
47
+ constructor(kernel, access, notify) {
48
+ this.kernel = kernel;
49
+ this.access = access;
50
+ this.notify = notify;
51
+ }
52
+ /** Configuration is cached hard in the client, so every change has to be announced. */
53
+ announce(workspaceId, entity, id, op) {
54
+ return this.notify.change(workspaceId, entity, id, op);
55
+ }
56
+ // ------------------------------------------------------------------ work item types
57
+ async listTypes(tx, workspaceId, opts = {}) {
58
+ const filters = [eq(workItemTypes.workspaceId, workspaceId)];
59
+ if (!opts.includeArchived)
60
+ filters.push(isNull(workItemTypes.archivedAt));
61
+ if (opts.projectId)
62
+ filters.push(or(isNull(workItemTypes.projectId), eq(workItemTypes.projectId, opts.projectId)));
63
+ const rows = await tx
64
+ .select()
65
+ .from(workItemTypes)
66
+ .where(and(...filters))
67
+ .orderBy(asc(workItemTypes.order), asc(workItemTypes.name));
68
+ if (!opts.projectId)
69
+ return rows;
70
+ const scheme = await this.typeSchemeFor(tx, workspaceId, opts.projectId);
71
+ if (!scheme?.typeIds.length)
72
+ return rows;
73
+ const allowed = new Set(scheme.typeIds);
74
+ return rows.filter((r) => allowed.has(r.id) || r.projectId === opts.projectId);
75
+ }
76
+ async typeSchemeFor(tx, workspaceId, projectId) {
77
+ const [project] = await tx
78
+ .select({ typeSchemeId: projects.typeSchemeId })
79
+ .from(projects)
80
+ .where(and(eq(projects.workspaceId, workspaceId), eq(projects.id, projectId)))
81
+ .limit(1);
82
+ if (!project?.typeSchemeId)
83
+ return null;
84
+ const [row] = await tx.select().from(typeSchemes).where(eq(typeSchemes.id, project.typeSchemeId)).limit(1);
85
+ return row ? toTypeScheme(row) : null;
86
+ }
87
+ async getType(tx, workspaceId, id) {
88
+ const [row] = await tx
89
+ .select()
90
+ .from(workItemTypes)
91
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.id, id)))
92
+ .limit(1);
93
+ if (!row)
94
+ throw KernError.notFound('Work item type');
95
+ return row;
96
+ }
97
+ /** The type new issues get when the caller does not name one. */
98
+ async defaultType(tx, workspaceId, projectId) {
99
+ const scheme = await this.typeSchemeFor(tx, workspaceId, projectId);
100
+ const rows = await this.listTypes(tx, workspaceId, { projectId });
101
+ const usable = rows.filter((r) => r.level >= 0);
102
+ const preferred = (scheme?.defaultTypeId ? usable.find((r) => r.id === scheme.defaultTypeId) : undefined) ??
103
+ usable.find((r) => r.isDefault && r.projectId === projectId) ??
104
+ usable.find((r) => r.isDefault) ??
105
+ usable.find((r) => r.projectId === projectId) ??
106
+ usable[0];
107
+ if (!preferred)
108
+ throw KernError.badRequest('This project has no work item types');
109
+ return preferred;
110
+ }
111
+ async createType(tx, workspaceId, input, actorId) {
112
+ const id = uuidv7();
113
+ await this.assertTypeKeyFree(tx, workspaceId, input.projectId ?? null, input.key);
114
+ if (input.isDefault)
115
+ await this.clearDefaultType(tx, workspaceId, input.projectId ?? null);
116
+ const [row] = await tx
117
+ .insert(workItemTypes)
118
+ .values({
119
+ id,
120
+ workspaceId,
121
+ projectId: input.projectId ?? null,
122
+ key: input.key,
123
+ name: input.name,
124
+ description: input.description ?? null,
125
+ icon: input.icon ?? null,
126
+ color: input.color ?? null,
127
+ level: input.level,
128
+ isDefault: input.isDefault ?? false,
129
+ workflowId: input.workflowId ?? null,
130
+ fieldLayout: input.fieldLayout ?? [],
131
+ templateBody: input.templateBody ?? null,
132
+ order: input.order ?? 0,
133
+ })
134
+ .returning();
135
+ void actorId;
136
+ await this.announce(workspaceId, 'type', row.id, 'created');
137
+ return toWorkItemType(row);
138
+ }
139
+ async updateType(tx, workspaceId, id, patch) {
140
+ const current = await this.getType(tx, workspaceId, id);
141
+ if (patch.key && patch.key !== current.key)
142
+ await this.assertTypeKeyFree(tx, workspaceId, patch.projectId ?? current.projectId, patch.key);
143
+ if (patch.isDefault)
144
+ await this.clearDefaultType(tx, workspaceId, current.projectId);
145
+ const [row] = await tx
146
+ .update(workItemTypes)
147
+ .set({
148
+ ...(patch.key === undefined ? {} : { key: patch.key }),
149
+ ...(patch.name === undefined ? {} : { name: patch.name }),
150
+ ...(patch.description === undefined ? {} : { description: patch.description }),
151
+ ...(patch.icon === undefined ? {} : { icon: patch.icon }),
152
+ ...(patch.color === undefined ? {} : { color: patch.color }),
153
+ ...(patch.level === undefined ? {} : { level: patch.level }),
154
+ ...(patch.isDefault === undefined ? {} : { isDefault: patch.isDefault }),
155
+ ...(patch.workflowId === undefined ? {} : { workflowId: patch.workflowId }),
156
+ ...(patch.fieldLayout === undefined ? {} : { fieldLayout: patch.fieldLayout }),
157
+ ...(patch.templateBody === undefined ? {} : { templateBody: patch.templateBody }),
158
+ ...(patch.order === undefined ? {} : { order: patch.order }),
159
+ updatedAt: new Date(),
160
+ })
161
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.id, id)))
162
+ .returning();
163
+ await this.announce(workspaceId, 'type', id, 'updated');
164
+ return toWorkItemType(row);
165
+ }
166
+ async archiveType(tx, workspaceId, id, archived) {
167
+ if (archived) {
168
+ const [used] = await tx
169
+ .select({ n: sql `count(*)::int` })
170
+ .from(issues)
171
+ .where(and(eq(issues.workspaceId, workspaceId), eq(issues.typeId, id), isNull(issues.archivedAt)));
172
+ if ((used?.n ?? 0) > 0)
173
+ throw KernError.conflict(`${used.n} open issues still use this type`, 'tracker.type.in_use');
174
+ }
175
+ const [row] = await tx
176
+ .update(workItemTypes)
177
+ .set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
178
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.id, id)))
179
+ .returning();
180
+ if (!row)
181
+ throw KernError.notFound('Work item type');
182
+ await this.announce(workspaceId, 'type', id, 'updated');
183
+ return toWorkItemType(row);
184
+ }
185
+ async assertTypeKeyFree(tx, workspaceId, projectId, key) {
186
+ const [clash] = await tx
187
+ .select({ id: workItemTypes.id })
188
+ .from(workItemTypes)
189
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.key, key), projectId ? eq(workItemTypes.projectId, projectId) : isNull(workItemTypes.projectId)))
190
+ .limit(1);
191
+ if (clash)
192
+ throw KernError.conflict(`Type key "${key}" is already used`, 'tracker.type.key_taken');
193
+ }
194
+ async clearDefaultType(tx, workspaceId, projectId) {
195
+ await tx
196
+ .update(workItemTypes)
197
+ .set({ isDefault: false })
198
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), projectId ? eq(workItemTypes.projectId, projectId) : isNull(workItemTypes.projectId)));
199
+ }
200
+ async hierarchyRules(tx, workspaceId) {
201
+ const [row] = await tx.select().from(hierarchyRules).where(eq(hierarchyRules.workspaceId, workspaceId));
202
+ return row ? HierarchyRules.parse(row.rules ?? {}) : DEFAULT_HIERARCHY;
203
+ }
204
+ async setHierarchyRules(tx, workspaceId, rules) {
205
+ await tx
206
+ .insert(hierarchyRules)
207
+ .values({ workspaceId, rules })
208
+ .onConflictDoUpdate({ target: hierarchyRules.workspaceId, set: { rules, updatedAt: new Date() } });
209
+ await this.announce(workspaceId, 'hierarchy_rules', workspaceId, 'updated');
210
+ return rules;
211
+ }
212
+ // ------------------------------------------------------------------ type schemes
213
+ async listTypeSchemes(tx, workspaceId) {
214
+ const rows = await tx
215
+ .select()
216
+ .from(typeSchemes)
217
+ .where(eq(typeSchemes.workspaceId, workspaceId))
218
+ .orderBy(asc(typeSchemes.createdAt));
219
+ return rows.map(toTypeScheme);
220
+ }
221
+ async createTypeScheme(tx, workspaceId, input) {
222
+ const [row] = await tx
223
+ .insert(typeSchemes)
224
+ .values({
225
+ id: uuidv7(),
226
+ workspaceId,
227
+ name: input.name,
228
+ typeIds: input.typeIds,
229
+ defaultTypeId: input.defaultTypeId ?? null,
230
+ })
231
+ .returning();
232
+ await this.announce(workspaceId, 'type_scheme', row.id, 'created');
233
+ return toTypeScheme(row);
234
+ }
235
+ async updateTypeScheme(tx, workspaceId, id, patch) {
236
+ const [row] = await tx
237
+ .update(typeSchemes)
238
+ .set({
239
+ ...(patch.name === undefined ? {} : { name: patch.name }),
240
+ ...(patch.typeIds === undefined ? {} : { typeIds: patch.typeIds }),
241
+ ...(patch.defaultTypeId === undefined ? {} : { defaultTypeId: patch.defaultTypeId }),
242
+ })
243
+ .where(and(eq(typeSchemes.workspaceId, workspaceId), eq(typeSchemes.id, id)))
244
+ .returning();
245
+ if (!row)
246
+ throw KernError.notFound('Type scheme');
247
+ await this.announce(workspaceId, 'type_scheme', id, 'updated');
248
+ return toTypeScheme(row);
249
+ }
250
+ async deleteTypeScheme(tx, workspaceId, id) {
251
+ await tx.update(projects).set({ typeSchemeId: null }).where(eq(projects.typeSchemeId, id));
252
+ await tx.delete(typeSchemes).where(and(eq(typeSchemes.workspaceId, workspaceId), eq(typeSchemes.id, id)));
253
+ await this.announce(workspaceId, 'type_scheme', id, 'deleted');
254
+ }
255
+ // ------------------------------------------------------------------ custom fields
256
+ async listFieldRows(tx, workspaceId, opts = {}) {
257
+ const filters = [eq(fieldDefs.workspaceId, workspaceId)];
258
+ if (!opts.includeArchived)
259
+ filters.push(isNull(fieldDefs.archivedAt));
260
+ if (opts.projectId)
261
+ filters.push(or(isNull(fieldDefs.projectId), eq(fieldDefs.projectId, opts.projectId)));
262
+ const rows = await tx
263
+ .select()
264
+ .from(fieldDefs)
265
+ .where(and(...filters))
266
+ .orderBy(asc(fieldDefs.order), asc(fieldDefs.name));
267
+ if (!opts.projectId)
268
+ return rows;
269
+ const [project] = await tx
270
+ .select({ fieldSchemeId: projects.fieldSchemeId })
271
+ .from(projects)
272
+ .where(eq(projects.id, opts.projectId))
273
+ .limit(1);
274
+ if (!project?.fieldSchemeId)
275
+ return rows;
276
+ const [scheme] = await tx.select().from(fieldSchemes).where(eq(fieldSchemes.id, project.fieldSchemeId));
277
+ if (!scheme?.fieldIds?.length)
278
+ return rows;
279
+ const allowed = new Set(scheme.fieldIds);
280
+ return rows.filter((r) => allowed.has(r.id) || r.projectId === opts.projectId);
281
+ }
282
+ async listFields(tx, workspaceId, opts = {}) {
283
+ return (await this.listFieldRows(tx, workspaceId, opts)).map(toFieldDef);
284
+ }
285
+ /** Fields visible across a set of projects (KQL autocomplete over a multi-project view). */
286
+ async fieldsForProjects(tx, workspaceId, projectIds) {
287
+ if (!projectIds.length)
288
+ return this.listFields(tx, workspaceId);
289
+ const seen = new Map();
290
+ for (const projectId of projectIds)
291
+ for (const f of await this.listFields(tx, workspaceId, { projectId }))
292
+ seen.set(f.id, f);
293
+ return [...seen.values()];
294
+ }
295
+ async createField(tx, workspaceId, input) {
296
+ const [clash] = await tx
297
+ .select({ id: fieldDefs.id })
298
+ .from(fieldDefs)
299
+ .where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.key, input.key), input.projectId ? eq(fieldDefs.projectId, input.projectId) : isNull(fieldDefs.projectId)))
300
+ .limit(1);
301
+ if (clash)
302
+ throw KernError.conflict(`Field key "${input.key}" is already used`, 'tracker.field.key_taken');
303
+ const [row] = await tx
304
+ .insert(fieldDefs)
305
+ .values({
306
+ id: uuidv7(),
307
+ workspaceId,
308
+ projectId: input.projectId ?? null,
309
+ key: input.key,
310
+ name: input.name,
311
+ description: input.description ?? null,
312
+ type: input.type,
313
+ options: input.options ?? [],
314
+ defaultValue: input.defaultValue ?? null,
315
+ config: input.config ?? {},
316
+ searchable: input.searchable ?? false,
317
+ required: input.required ?? false,
318
+ showInCards: input.showInCards ?? false,
319
+ order: input.order ?? 0,
320
+ })
321
+ .returning();
322
+ await this.announce(workspaceId, 'field', row.id, 'created');
323
+ return toFieldDef(row);
324
+ }
325
+ async updateField(tx, workspaceId, id, patch) {
326
+ const [row] = await tx
327
+ .update(fieldDefs)
328
+ .set({
329
+ ...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
330
+ ...(patch.name === undefined ? {} : { name: patch.name }),
331
+ ...(patch.description === undefined ? {} : { description: patch.description }),
332
+ ...(patch.options === undefined ? {} : { options: patch.options }),
333
+ ...(patch.defaultValue === undefined ? {} : { defaultValue: patch.defaultValue }),
334
+ ...(patch.config === undefined ? {} : { config: patch.config }),
335
+ ...(patch.searchable === undefined ? {} : { searchable: patch.searchable }),
336
+ ...(patch.required === undefined ? {} : { required: patch.required }),
337
+ ...(patch.showInCards === undefined ? {} : { showInCards: patch.showInCards }),
338
+ ...(patch.order === undefined ? {} : { order: patch.order }),
339
+ updatedAt: new Date(),
340
+ })
341
+ .where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)))
342
+ .returning();
343
+ if (!row)
344
+ throw KernError.notFound('Field');
345
+ await this.announce(workspaceId, 'field', id, 'updated');
346
+ return toFieldDef(row);
347
+ }
348
+ async archiveField(tx, workspaceId, id, archived) {
349
+ const [row] = await tx
350
+ .update(fieldDefs)
351
+ .set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
352
+ .where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)))
353
+ .returning();
354
+ if (!row)
355
+ throw KernError.notFound('Field');
356
+ await this.announce(workspaceId, 'field', id, 'updated');
357
+ return toFieldDef(row);
358
+ }
359
+ /** Deleting a field also strips its values from every issue: the key would otherwise be unreachable. */
360
+ async deleteField(tx, workspaceId, id) {
361
+ const [row] = await tx
362
+ .select()
363
+ .from(fieldDefs)
364
+ .where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)))
365
+ .limit(1);
366
+ if (!row)
367
+ throw KernError.notFound('Field');
368
+ await tx
369
+ .update(issues)
370
+ .set({ custom: sql `${issues.custom} - ${row.key}` })
371
+ .where(and(eq(issues.workspaceId, workspaceId), sql `${issues.custom} ? ${row.key}`));
372
+ await tx.delete(fieldDefs).where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.id, id)));
373
+ await this.announce(workspaceId, 'field', id, 'deleted');
374
+ }
375
+ async listFieldSchemes(tx, workspaceId) {
376
+ const rows = await tx
377
+ .select()
378
+ .from(fieldSchemes)
379
+ .where(eq(fieldSchemes.workspaceId, workspaceId))
380
+ .orderBy(asc(fieldSchemes.createdAt));
381
+ return rows.map(toFieldScheme);
382
+ }
383
+ async createFieldScheme(tx, workspaceId, input) {
384
+ const [row] = await tx
385
+ .insert(fieldSchemes)
386
+ .values({ id: uuidv7(), workspaceId, name: input.name, fieldIds: input.fieldIds })
387
+ .returning();
388
+ await this.announce(workspaceId, 'field_scheme', row.id, 'created');
389
+ return toFieldScheme(row);
390
+ }
391
+ async updateFieldScheme(tx, workspaceId, id, patch) {
392
+ const [row] = await tx
393
+ .update(fieldSchemes)
394
+ .set({
395
+ ...(patch.name === undefined ? {} : { name: patch.name }),
396
+ ...(patch.fieldIds === undefined ? {} : { fieldIds: patch.fieldIds }),
397
+ })
398
+ .where(and(eq(fieldSchemes.workspaceId, workspaceId), eq(fieldSchemes.id, id)))
399
+ .returning();
400
+ if (!row)
401
+ throw KernError.notFound('Field scheme');
402
+ await this.announce(workspaceId, 'field_scheme', id, 'updated');
403
+ return toFieldScheme(row);
404
+ }
405
+ async deleteFieldScheme(tx, workspaceId, id) {
406
+ await tx.update(projects).set({ fieldSchemeId: null }).where(eq(projects.fieldSchemeId, id));
407
+ await tx
408
+ .delete(fieldSchemes)
409
+ .where(and(eq(fieldSchemes.workspaceId, workspaceId), eq(fieldSchemes.id, id)));
410
+ await this.announce(workspaceId, 'field_scheme', id, 'deleted');
411
+ }
412
+ // ------------------------------------------------------------------ workflows
413
+ async listWorkflows(tx, workspaceId, opts = {}) {
414
+ const filters = [eq(workflows.workspaceId, workspaceId)];
415
+ if (!opts.includeArchived)
416
+ filters.push(isNull(workflows.archivedAt));
417
+ if (opts.projectId)
418
+ filters.push(or(isNull(workflows.projectId), eq(workflows.projectId, opts.projectId)));
419
+ const rows = await tx
420
+ .select()
421
+ .from(workflows)
422
+ .where(and(...filters))
423
+ .orderBy(asc(workflows.name));
424
+ const usage = new Map();
425
+ for (const row of rows) {
426
+ const [n] = await tx
427
+ .select({ n: sql `count(*)::int` })
428
+ .from(workItemTypes)
429
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.workflowId, row.id)));
430
+ usage.set(row.id, n?.n ?? 0);
431
+ }
432
+ return rows.map((r) => toWorkflow(r, usage.get(r.id) ?? 0));
433
+ }
434
+ async getWorkflowRow(tx, workspaceId, id) {
435
+ const [row] = await tx
436
+ .select()
437
+ .from(workflows)
438
+ .where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.id, id)))
439
+ .limit(1);
440
+ if (!row)
441
+ throw KernError.notFound('Workflow');
442
+ return row;
443
+ }
444
+ async createWorkflow(tx, workspaceId, input) {
445
+ const validated = validateDefinition(input.definition);
446
+ if (!validated.ok)
447
+ throw KernError.badRequest('Invalid workflow definition', { problems: validated.problems });
448
+ if (input.isDefault)
449
+ await this.clearDefaultWorkflow(tx, workspaceId, input.projectId ?? null);
450
+ const [row] = await tx
451
+ .insert(workflows)
452
+ .values({
453
+ id: uuidv7(),
454
+ workspaceId,
455
+ projectId: input.projectId ?? null,
456
+ name: input.name,
457
+ description: input.description ?? null,
458
+ definition: validated.definition,
459
+ isDefault: input.isDefault ?? false,
460
+ })
461
+ .returning();
462
+ await this.announce(workspaceId, 'workflow', row.id, 'created');
463
+ return toWorkflow(row, 0);
464
+ }
465
+ async updateWorkflow(tx, workspaceId, id, patch) {
466
+ const current = await this.getWorkflowRow(tx, workspaceId, id);
467
+ let definition = current.definition;
468
+ if (patch.definition) {
469
+ const validated = validateDefinition(patch.definition);
470
+ if (!validated.ok)
471
+ throw KernError.badRequest('Invalid workflow definition', { problems: validated.problems });
472
+ definition = validated.definition;
473
+ await this.assertStatusesStillUsed(tx, workspaceId, id, definition);
474
+ }
475
+ if (patch.isDefault)
476
+ await this.clearDefaultWorkflow(tx, workspaceId, current.projectId);
477
+ const [row] = await tx
478
+ .update(workflows)
479
+ .set({
480
+ ...(patch.name === undefined ? {} : { name: patch.name }),
481
+ ...(patch.description === undefined ? {} : { description: patch.description }),
482
+ ...(patch.projectId === undefined ? {} : { projectId: patch.projectId }),
483
+ ...(patch.isDefault === undefined ? {} : { isDefault: patch.isDefault }),
484
+ definition,
485
+ updatedAt: new Date(),
486
+ })
487
+ .where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.id, id)))
488
+ .returning();
489
+ await this.announce(workspaceId, 'workflow', id, 'updated');
490
+ return toWorkflow(row);
491
+ }
492
+ /** Refuse to drop a status that issues still sit in — they would become unreachable. */
493
+ async assertStatusesStillUsed(tx, workspaceId, workflowId, next) {
494
+ const known = new Set(next.statuses.map((s) => s.id));
495
+ const rows = await tx
496
+ .select({ statusId: issues.statusId, n: sql `count(*)::int` })
497
+ .from(issues)
498
+ .where(and(eq(issues.workspaceId, workspaceId), sql `true`))
499
+ .groupBy(issues.statusId);
500
+ const typeRows = await tx
501
+ .select({ id: workItemTypes.id })
502
+ .from(workItemTypes)
503
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.workflowId, workflowId)));
504
+ if (!typeRows.length)
505
+ return;
506
+ const missing = rows.filter((r) => !known.has(r.statusId) && r.n > 0).map((r) => r.statusId);
507
+ if (missing.length)
508
+ throw KernError.conflict(`Statuses still in use: ${missing.join(', ')}`, 'tracker.workflow.status_in_use');
509
+ }
510
+ async clearDefaultWorkflow(tx, workspaceId, projectId) {
511
+ await tx
512
+ .update(workflows)
513
+ .set({ isDefault: false })
514
+ .where(and(eq(workflows.workspaceId, workspaceId), projectId ? eq(workflows.projectId, projectId) : isNull(workflows.projectId)));
515
+ }
516
+ async archiveWorkflow(tx, workspaceId, id, archived) {
517
+ const [row] = await tx
518
+ .update(workflows)
519
+ .set({ archivedAt: archived ? new Date() : null, updatedAt: new Date() })
520
+ .where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.id, id)))
521
+ .returning();
522
+ if (!row)
523
+ throw KernError.notFound('Workflow');
524
+ await this.announce(workspaceId, 'workflow', id, 'updated');
525
+ return toWorkflow(row);
526
+ }
527
+ /** Row → contract shape, for the router. */
528
+ toContractType(row) {
529
+ return toWorkItemType(row);
530
+ }
531
+ toContractWorkflow(row) {
532
+ return toWorkflow(row);
533
+ }
534
+ /** Structural validation of a definition without saving it. */
535
+ validate(definition) {
536
+ const result = validateDefinition(definition, registryForValidation());
537
+ return result.ok ? { ok: true, problems: [] } : { ok: false, problems: result.problems };
538
+ }
539
+ workflowTemplates() {
540
+ return Object.keys(workflowTemplates).map((id) => {
541
+ const definition = createWorkflowFromTemplate(id, { id });
542
+ return { id, name: definition.name, definition };
543
+ });
544
+ }
545
+ // ------------------------------------------------------------------ workflow schemes
546
+ async listWorkflowSchemes(tx, workspaceId) {
547
+ const rows = await tx
548
+ .select()
549
+ .from(workflowSchemes)
550
+ .where(eq(workflowSchemes.workspaceId, workspaceId))
551
+ .orderBy(asc(workflowSchemes.createdAt));
552
+ return rows.map(toWorkflowScheme);
553
+ }
554
+ async createWorkflowScheme(tx, workspaceId, input) {
555
+ const [row] = await tx
556
+ .insert(workflowSchemes)
557
+ .values({
558
+ id: uuidv7(),
559
+ workspaceId,
560
+ name: input.name,
561
+ defaultWorkflowId: input.defaultWorkflowId,
562
+ mappings: input.mappings,
563
+ })
564
+ .returning();
565
+ await this.announce(workspaceId, 'workflow_scheme', row.id, 'created');
566
+ return toWorkflowScheme(row);
567
+ }
568
+ async updateWorkflowScheme(tx, workspaceId, id, patch) {
569
+ const [row] = await tx
570
+ .update(workflowSchemes)
571
+ .set({
572
+ ...(patch.name === undefined ? {} : { name: patch.name }),
573
+ ...(patch.defaultWorkflowId === undefined ? {} : { defaultWorkflowId: patch.defaultWorkflowId }),
574
+ ...(patch.mappings === undefined ? {} : { mappings: patch.mappings }),
575
+ updatedAt: new Date(),
576
+ })
577
+ .where(and(eq(workflowSchemes.workspaceId, workspaceId), eq(workflowSchemes.id, id)))
578
+ .returning();
579
+ if (!row)
580
+ throw KernError.notFound('Workflow scheme');
581
+ await this.announce(workspaceId, 'workflow_scheme', id, 'updated');
582
+ return toWorkflowScheme(row);
583
+ }
584
+ async deleteWorkflowScheme(tx, workspaceId, id) {
585
+ await tx.update(projects).set({ workflowSchemeId: null }).where(eq(projects.workflowSchemeId, id));
586
+ await tx
587
+ .delete(workflowSchemes)
588
+ .where(and(eq(workflowSchemes.workspaceId, workspaceId), eq(workflowSchemes.id, id)));
589
+ await this.announce(workspaceId, 'workflow_scheme', id, 'deleted');
590
+ }
591
+ // ------------------------------------------------------------------ resolution
592
+ /**
593
+ * The workflow an issue of `typeId` follows in `project`:
594
+ * explicit type workflow → project's workflow scheme → project default → workspace default.
595
+ */
596
+ async workflowFor(tx, project, typeId) {
597
+ const workspaceId = project.workspaceId;
598
+ const type = await this.getType(tx, workspaceId, typeId).catch(() => null);
599
+ if (type?.workflowId) {
600
+ const row = await this.getWorkflowRow(tx, workspaceId, type.workflowId).catch(() => null);
601
+ if (row)
602
+ return { workflowId: row.id, definition: row.definition };
603
+ }
604
+ if (project.workflowSchemeId) {
605
+ const [scheme] = await tx
606
+ .select()
607
+ .from(workflowSchemes)
608
+ .where(eq(workflowSchemes.id, project.workflowSchemeId))
609
+ .limit(1);
610
+ if (scheme) {
611
+ const mappings = scheme.mappings ?? [];
612
+ const mapped = mappings.find((m) => m.typeId === typeId)?.workflowId ?? scheme.defaultWorkflowId;
613
+ const row = await this.getWorkflowRow(tx, workspaceId, mapped).catch(() => null);
614
+ if (row)
615
+ return { workflowId: row.id, definition: row.definition };
616
+ }
617
+ }
618
+ const candidates = await tx
619
+ .select()
620
+ .from(workflows)
621
+ .where(and(eq(workflows.workspaceId, workspaceId), isNull(workflows.archivedAt), or(isNull(workflows.projectId), eq(workflows.projectId, project.id))))
622
+ .orderBy(asc(workflows.createdAt));
623
+ const chosen = candidates.find((w) => w.projectId === project.id && w.isDefault) ??
624
+ candidates.find((w) => w.projectId === project.id) ??
625
+ candidates.find((w) => w.isDefault) ??
626
+ candidates[0];
627
+ if (!chosen)
628
+ throw KernError.badRequest('This project has no workflow');
629
+ return { workflowId: chosen.id, definition: chosen.definition };
630
+ }
631
+ /** Every status of every workflow the project can use, deduplicated for board columns and KQL. */
632
+ async statusesForProject(tx, workspaceId, projectId) {
633
+ const filters = [eq(workflows.workspaceId, workspaceId), isNull(workflows.archivedAt)];
634
+ if (projectId)
635
+ filters.push(or(isNull(workflows.projectId), eq(workflows.projectId, projectId)));
636
+ const rows = await tx
637
+ .select()
638
+ .from(workflows)
639
+ .where(and(...filters));
640
+ const seen = new Map();
641
+ for (const row of rows) {
642
+ const definition = row.definition;
643
+ for (const [index, status] of sortedStatuses(definition).entries()) {
644
+ if (seen.has(status.id))
645
+ continue;
646
+ seen.set(status.id, {
647
+ id: status.id,
648
+ name: status.name,
649
+ category: status.category,
650
+ color: status.color ?? null,
651
+ order: status.order ?? index,
652
+ workflowId: row.id,
653
+ });
654
+ }
655
+ }
656
+ return [...seen.values()].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
657
+ }
658
+ // ------------------------------------------------------------------ seeding
659
+ /** Create the workflow and types a brand-new project starts with. */
660
+ async seedProject(tx, workspaceId, projectId, template) {
661
+ const templateId = WORKFLOW_FOR_TEMPLATE[template] ?? 'simple';
662
+ const workflowId = uuidv7();
663
+ const definition = createWorkflowFromTemplate(templateId, { id: workflowId });
664
+ await tx.insert(workflows).values({
665
+ id: workflowId,
666
+ workspaceId,
667
+ projectId,
668
+ name: definition.name,
669
+ description: definition.description ?? null,
670
+ definition,
671
+ isDefault: true,
672
+ });
673
+ const seeds = TYPE_SEEDS[template] ?? TYPE_SEEDS.simple;
674
+ const defaultKey = DEFAULT_TYPE_KEY[template] ?? 'task';
675
+ const typeIds = [];
676
+ let defaultTypeId = '';
677
+ for (const [order, seed] of seeds.entries()) {
678
+ const id = uuidv7();
679
+ typeIds.push(id);
680
+ if (seed.key === defaultKey)
681
+ defaultTypeId = id;
682
+ await tx.insert(workItemTypes).values({
683
+ id,
684
+ workspaceId,
685
+ projectId,
686
+ key: seed.key,
687
+ name: seed.name,
688
+ icon: seed.icon,
689
+ level: seed.level,
690
+ isDefault: seed.key === defaultKey,
691
+ workflowId,
692
+ order,
693
+ });
694
+ }
695
+ return { workflowId, typeIds, defaultTypeId: defaultTypeId || typeIds[0] };
696
+ }
697
+ /** Restore a project blueprint saved with `projects.templates.saveFromProject`. */
698
+ async applyProjectTemplateBody(tx, workspaceId, projectId, body) {
699
+ const definitions = body.workflows ?? [];
700
+ const types = body.types ?? [];
701
+ if (!definitions.length || !types.length)
702
+ return null;
703
+ const workflowIds = [];
704
+ for (const definition of definitions) {
705
+ const id = uuidv7();
706
+ workflowIds.push(id);
707
+ const validated = validateDefinition({ ...definition, id });
708
+ if (!validated.ok)
709
+ continue;
710
+ await tx.insert(workflows).values({
711
+ id,
712
+ workspaceId,
713
+ projectId,
714
+ name: definition.name,
715
+ definition: validated.definition,
716
+ isDefault: workflowIds.length === 1,
717
+ });
718
+ }
719
+ let defaultTypeId = '';
720
+ for (const [order, type] of types.entries()) {
721
+ const id = uuidv7();
722
+ if (type.isDefault || !defaultTypeId)
723
+ defaultTypeId = id;
724
+ await tx.insert(workItemTypes).values({
725
+ id,
726
+ workspaceId,
727
+ projectId,
728
+ key: type.key,
729
+ name: type.name,
730
+ icon: type.icon ?? null,
731
+ color: type.color ?? null,
732
+ level: type.level,
733
+ isDefault: !!type.isDefault,
734
+ workflowId: workflowIds[type.workflowIndex ?? 0] ?? workflowIds[0],
735
+ order,
736
+ });
737
+ }
738
+ return { workflowId: workflowIds[0], defaultTypeId };
739
+ }
740
+ /** Snapshot a project's configuration so another project can be created from it. */
741
+ async snapshotProject(tx, workspaceId, projectId) {
742
+ const typeRows = await tx
743
+ .select()
744
+ .from(workItemTypes)
745
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), eq(workItemTypes.projectId, projectId)))
746
+ .orderBy(asc(workItemTypes.order));
747
+ const workflowRows = await tx
748
+ .select()
749
+ .from(workflows)
750
+ .where(and(eq(workflows.workspaceId, workspaceId), eq(workflows.projectId, projectId)));
751
+ const workflowIndex = new Map(workflowRows.map((w, i) => [w.id, i]));
752
+ const fieldRows = await tx
753
+ .select()
754
+ .from(fieldDefs)
755
+ .where(and(eq(fieldDefs.workspaceId, workspaceId), eq(fieldDefs.projectId, projectId)));
756
+ return {
757
+ workflows: workflowRows.map((w) => w.definition),
758
+ types: typeRows.map((t) => ({
759
+ key: t.key,
760
+ name: t.name,
761
+ level: t.level,
762
+ icon: t.icon,
763
+ color: t.color,
764
+ isDefault: t.isDefault,
765
+ workflowIndex: t.workflowId ? (workflowIndex.get(t.workflowId) ?? 0) : 0,
766
+ })),
767
+ fields: fieldRows.map((f) => ({
768
+ key: f.key,
769
+ name: f.name,
770
+ type: f.type,
771
+ options: f.options,
772
+ config: f.config,
773
+ })),
774
+ };
775
+ }
776
+ /** Ensure a set of type ids all belong to this workspace (scheme validation). */
777
+ async assertTypesExist(tx, workspaceId, ids) {
778
+ if (!ids.length)
779
+ return;
780
+ const rows = await tx
781
+ .select({ id: workItemTypes.id })
782
+ .from(workItemTypes)
783
+ .where(and(eq(workItemTypes.workspaceId, workspaceId), inArray(workItemTypes.id, ids)));
784
+ if (rows.length !== new Set(ids).size)
785
+ throw KernError.badRequest('Unknown work item type in scheme');
786
+ }
787
+ /** Permission gate shared by every configuration mutation. */
788
+ async requireWorkspacePermission(principal, workspaceId, permission) {
789
+ void this.access;
790
+ await this.kernel.authz.require(principal, permission, {
791
+ kind: 'workspace',
792
+ id: workspaceId,
793
+ workspaceId,
794
+ });
795
+ }
796
+ }
797
+ //# sourceMappingURL=config.js.map