@kernhq/module-tracker 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.
Files changed (65) hide show
  1. package/dist/contract/models.d.ts +1185 -136
  2. package/dist/contract/models.d.ts.map +1 -1
  3. package/dist/contract/models.js +131 -29
  4. package/dist/contract/models.js.map +1 -1
  5. package/dist/contract/router.d.ts +1098 -356
  6. package/dist/contract/router.d.ts.map +1 -1
  7. package/dist/contract/router.js +6 -25
  8. package/dist/contract/router.js.map +1 -1
  9. package/dist/kql/fields.d.ts.map +1 -1
  10. package/dist/kql/fields.js +4 -1
  11. package/dist/kql/fields.js.map +1 -1
  12. package/dist/server/index.d.ts.map +1 -1
  13. package/dist/server/index.js +39 -6
  14. package/dist/server/index.js.map +1 -1
  15. package/dist/server/kql/compile.d.ts.map +1 -1
  16. package/dist/server/kql/compile.js +9 -1
  17. package/dist/server/kql/compile.js.map +1 -1
  18. package/dist/server/router.d.ts +1104 -384
  19. package/dist/server/router.d.ts.map +1 -1
  20. package/dist/server/router.js +3 -15
  21. package/dist/server/router.js.map +1 -1
  22. package/dist/server/schema.d.ts +1 -136
  23. package/dist/server/schema.d.ts.map +1 -1
  24. package/dist/server/schema.js +3 -13
  25. package/dist/server/schema.js.map +1 -1
  26. package/dist/server/services/config.d.ts +1 -11
  27. package/dist/server/services/config.d.ts.map +1 -1
  28. package/dist/server/services/config.js +28 -64
  29. package/dist/server/services/config.js.map +1 -1
  30. package/dist/server/services/db.d.ts +2 -4
  31. package/dist/server/services/db.d.ts.map +1 -1
  32. package/dist/server/services/db.js +4 -12
  33. package/dist/server/services/db.js.map +1 -1
  34. package/dist/server/services/imports.d.ts.map +1 -1
  35. package/dist/server/services/imports.js +50 -4
  36. package/dist/server/services/imports.js.map +1 -1
  37. package/dist/server/services/index.d.ts +2 -0
  38. package/dist/server/services/index.d.ts.map +1 -1
  39. package/dist/server/services/index.js +3 -0
  40. package/dist/server/services/index.js.map +1 -1
  41. package/dist/server/services/issues.d.ts +7 -18
  42. package/dist/server/services/issues.d.ts.map +1 -1
  43. package/dist/server/services/issues.js +34 -18
  44. package/dist/server/services/issues.js.map +1 -1
  45. package/dist/server/services/layout.d.ts +25 -0
  46. package/dist/server/services/layout.d.ts.map +1 -0
  47. package/dist/server/services/layout.js +97 -0
  48. package/dist/server/services/layout.js.map +1 -0
  49. package/dist/server/services/projects.d.ts.map +1 -1
  50. package/dist/server/services/projects.js +0 -1
  51. package/dist/server/services/projects.js.map +1 -1
  52. package/dist/server/services/transitions.d.ts.map +1 -1
  53. package/dist/server/services/transitions.js +36 -0
  54. package/dist/server/services/transitions.js.map +1 -1
  55. package/dist/server/services/values.d.ts +37 -0
  56. package/dist/server/services/values.d.ts.map +1 -0
  57. package/dist/server/services/values.js +172 -0
  58. package/dist/server/services/values.js.map +1 -0
  59. package/migrations/0002_field_keys_unique.sql +35 -0
  60. package/migrations/0003_drop_field_schemes.sql +9 -0
  61. package/migrations/meta/_journal.json +14 -0
  62. package/package.json +1 -1
  63. package/src/contract/models.ts +157 -33
  64. package/src/contract/router.ts +6 -27
  65. package/src/kql/fields.ts +5 -1
@@ -124,7 +124,6 @@ export const Project = z.object({
124
124
  defaultAssignee: z.enum(['unassigned', 'lead']),
125
125
  workflowSchemeId: Id.nullable(),
126
126
  typeSchemeId: Id.nullable(),
127
- fieldSchemeId: Id.nullable(),
128
127
  settings: ProjectSettings,
129
128
  /** public intake form token (null = intake disabled) */
130
129
  intakeToken: z.string().nullable(),
@@ -141,6 +140,14 @@ export const Project = z.object({
141
140
  })
142
141
  export type Project = z.infer<typeof Project>
143
142
 
143
+ /**
144
+ * The built-in project templates. `software`, `support`, `marketing` and `simple` are the four
145
+ * team shapes the tracker ships with; `kanban` and `blank` are kept because existing projects were
146
+ * created from them.
147
+ */
148
+ export const ProjectTemplateId = z.enum(['software', 'support', 'marketing', 'simple', 'kanban', 'blank'])
149
+ export type ProjectTemplateId = z.infer<typeof ProjectTemplateId>
150
+
144
151
  export const CreateProject = z.object({
145
152
  key: ProjectKey,
146
153
  name: z.string().min(1).max(120),
@@ -150,8 +157,8 @@ export const CreateProject = z.object({
150
157
  leadId: UserId.optional(),
151
158
  visibility: ProjectVisibility.default('workspace'),
152
159
  defaultAssignee: z.enum(['unassigned', 'lead']).default('unassigned'),
153
- /** seed types/workflow from a built-in template */
154
- template: z.enum(['software', 'kanban', 'simple', 'blank']).default('software'),
160
+ /** seed types/workflow/fields from a built-in template */
161
+ template: ProjectTemplateId.default('software'),
155
162
  /** or from a saved project template (overrides `template`) */
156
163
  templateId: Id.optional(),
157
164
  settings: ProjectSettings.partial().optional(),
@@ -169,7 +176,6 @@ export const UpdateProject = z.object({
169
176
  defaultAssignee: z.enum(['unassigned', 'lead']).optional(),
170
177
  workflowSchemeId: Id.nullable().optional(),
171
178
  typeSchemeId: Id.nullable().optional(),
172
- fieldSchemeId: Id.nullable().optional(),
173
179
  settings: ProjectSettings.partial().optional(),
174
180
  })
175
181
  export type UpdateProject = z.infer<typeof UpdateProject>
@@ -186,21 +192,6 @@ export const ProjectMember = z.object({
186
192
  })
187
193
  export type ProjectMember = z.infer<typeof ProjectMember>
188
194
 
189
- /** Reusable project blueprint (types, workflow, fields, labels, sample views). */
190
- export const ProjectTemplate = z.object({
191
- id: Id,
192
- workspaceId: WorkspaceId.nullable(),
193
- key: MachineKey,
194
- name: z.string().min(1).max(120),
195
- description: z.string().max(1000).nullable(),
196
- icon: z.string().max(64).nullable(),
197
- /** a full ProjectTemplateBody JSON */
198
- body: z.record(z.string(), z.unknown()),
199
- builtin: z.boolean(),
200
- createdAt: Timestamp,
201
- })
202
- export type ProjectTemplate = z.infer<typeof ProjectTemplate>
203
-
204
195
  // =====================================================================================
205
196
  // work item types & hierarchy
206
197
  // =====================================================================================
@@ -209,16 +200,66 @@ export type ProjectTemplate = z.infer<typeof ProjectTemplate>
209
200
  export const HierarchyLevel = z.number().int().min(-1).max(2)
210
201
  export type HierarchyLevel = z.infer<typeof HierarchyLevel>
211
202
 
203
+ /**
204
+ * The system fields a work item type may lay out, in their default order.
205
+ *
206
+ * `pinned` fields are always visible: an issue without a title, a status or a type is not an
207
+ * issue. The settings editor does not offer the control, and the resolver ignores a stored
208
+ * instruction that tries to hide one.
209
+ */
210
+ export const SYSTEM_LAYOUT_FIELDS = [
211
+ { id: 'title', section: 'main', pinned: true },
212
+ { id: 'description', section: 'main', pinned: false },
213
+ { id: 'status', section: 'sidebar', pinned: true },
214
+ { id: 'type', section: 'sidebar', pinned: true },
215
+ { id: 'assignees', section: 'sidebar', pinned: false },
216
+ { id: 'priority', section: 'sidebar', pinned: false },
217
+ { id: 'labels', section: 'sidebar', pinned: false },
218
+ { id: 'components', section: 'sidebar', pinned: false },
219
+ { id: 'versions', section: 'sidebar', pinned: false },
220
+ { id: 'estimate', section: 'sidebar', pinned: false },
221
+ { id: 'startDate', section: 'sidebar', pinned: false },
222
+ { id: 'dueDate', section: 'sidebar', pinned: false },
223
+ { id: 'cycle', section: 'sidebar', pinned: false },
224
+ { id: 'milestone', section: 'sidebar', pinned: false },
225
+ { id: 'parent', section: 'sidebar', pinned: false },
226
+ { id: 'reporter', section: 'sidebar', pinned: false },
227
+ ] as const satisfies ReadonlyArray<{
228
+ id: string
229
+ section: 'main' | 'sidebar'
230
+ pinned: boolean
231
+ }>
232
+
233
+ export const SystemFieldId = z.enum(SYSTEM_LAYOUT_FIELDS.map((f) => f.id) as [string, ...string[]])
234
+ export type SystemFieldId = (typeof SYSTEM_LAYOUT_FIELDS)[number]['id']
235
+
236
+ /** System field ids that may never be hidden, whatever a stored layout says. */
237
+ export const PINNED_FIELD_IDS: readonly string[] = SYSTEM_LAYOUT_FIELDS.filter((f) => f.pinned).map(
238
+ (f) => f.id,
239
+ )
240
+
241
+ /**
242
+ * A layout entry's `fieldId` is a *namespaced* name: a system field id (`priority`, `dueDate`) or
243
+ * `cf.<key>` for a custom field. That is the same string KQL, `ViewDisplay.columns` and workflow
244
+ * post-functions already use, so one resolver serves all four.
245
+ */
246
+ export const LayoutFieldId = z
247
+ .string()
248
+ .min(1)
249
+ .max(80)
250
+ .regex(/^(?:[a-zA-Z][a-zA-Z0-9_]*|cf\.[a-z][a-z0-9_]*)$/, 'Expected a system field id or `cf.<key>`')
251
+ export type LayoutFieldId = z.infer<typeof LayoutFieldId>
252
+
212
253
  export const FieldLayoutItem = z.object({
213
- /** system field name (`priority`, `dueDate`…) or custom field id */
214
- fieldId: z.string().min(1),
254
+ /** system field id (`priority`, `dueDate`…) or `cf.<key>` for a custom field */
255
+ fieldId: LayoutFieldId,
215
256
  section: z.enum(['main', 'sidebar', 'hidden']).default('sidebar'),
257
+ /** required on this type, over and above the field definition's own `required` */
216
258
  required: z.boolean().default(false),
217
259
  hidden: z.boolean().default(false),
218
260
  order: z.number().int().default(0),
219
261
  })
220
262
  export type FieldLayoutItem = z.infer<typeof FieldLayoutItem>
221
-
222
263
  export const WorkItemType = z.object({
223
264
  id: Id,
224
265
  workspaceId: WorkspaceId,
@@ -377,15 +418,37 @@ export const UpsertFieldDef = z.object({
377
418
  })
378
419
  export type UpsertFieldDef = z.infer<typeof UpsertFieldDef>
379
420
 
380
- /** Field scheme: which custom fields a project exposes (null = all applicable). */
381
- export const FieldScheme = z.object({
382
- id: Id,
383
- workspaceId: WorkspaceId,
384
- name: z.string().min(1).max(120),
385
- fieldIds: z.array(Id),
386
- createdAt: Timestamp,
421
+ /** One field as an interface should render it, system and custom fields alike. */
422
+ export const ResolvedField = z.object({
423
+ fieldId: LayoutFieldId,
424
+ /** `system` fields are rendered by a built-in component, `custom` ones by their field type */
425
+ kind: z.enum(['system', 'custom']),
426
+ label: z.string().min(1).max(120),
427
+ section: z.enum(['main', 'sidebar']),
428
+ order: z.number().int(),
429
+ required: z.boolean(),
430
+ pinned: z.boolean(),
431
+ showInCards: z.boolean(),
432
+ /** present when `kind` is `custom` — everything needed to render and validate the value */
433
+ field: FieldDef.nullable(),
434
+ })
435
+ export type ResolvedField = z.infer<typeof ResolvedField>
436
+
437
+ /**
438
+ * The fields of one work item type in one project, already ordered and merged.
439
+ *
440
+ * An **empty stored layout means the default layout** — everything visible. A field the stored
441
+ * layout does not name appends to `sidebar` rather than disappearing, so a newly created field
442
+ * shows up instead of looking broken.
443
+ */
444
+ export const ResolvedLayout = z.object({
445
+ typeId: Id,
446
+ projectId: Id.nullable(),
447
+ main: z.array(ResolvedField),
448
+ sidebar: z.array(ResolvedField),
449
+ hidden: z.array(ResolvedField),
387
450
  })
388
- export type FieldScheme = z.infer<typeof FieldScheme>
451
+ export type ResolvedLayout = z.infer<typeof ResolvedLayout>
389
452
 
390
453
  // =====================================================================================
391
454
  // workflows
@@ -1053,6 +1116,13 @@ export const GroupBy = z.enum([
1053
1116
  ])
1054
1117
  export type GroupBy = z.infer<typeof GroupBy>
1055
1118
 
1119
+ /**
1120
+ * A group key that may also name a custom field. `GroupBy` stays as it was so nothing that accepts
1121
+ * only the built-in keys has to change; views and `issues.query` accept this wider one.
1122
+ */
1123
+ export const GroupByValue = z.union([GroupBy, z.string().regex(/^cf\.[a-z][a-z0-9_]*$/)])
1124
+ export type GroupByValue = z.infer<typeof GroupByValue>
1125
+
1056
1126
  export const OrderBy = z.object({
1057
1127
  /** KQL field name (`priority`, `updated`, `rank`, `cf.severity`…) */
1058
1128
  field: z.string().min(1),
@@ -1071,7 +1141,7 @@ export const BoardColumn = z.object({
1071
1141
  export type BoardColumn = z.infer<typeof BoardColumn>
1072
1142
 
1073
1143
  export const ViewDisplay = z.object({
1074
- groupBy: GroupBy.default('none'),
1144
+ groupBy: GroupByValue.default('none'),
1075
1145
  subGroupBy: GroupBy.optional(),
1076
1146
  orderBy: z.array(OrderBy).default([{ field: 'rank', dir: 'asc' }]),
1077
1147
  /** visible columns (list/spreadsheet): system field names or `cf.<key>` */
@@ -1084,8 +1154,13 @@ export const ViewDisplay = z.object({
1084
1154
  /** board columns (null → one column per status of the project's workflows) */
1085
1155
  boardColumns: z.array(BoardColumn).nullable().default(null),
1086
1156
  wipLimits: z.record(z.string(), z.number().int().positive()).default({}),
1087
- /** calendar: which date field positions issues */
1088
- calendarField: z.enum(['dueDate', 'startDate', 'createdAt']).default('dueDate'),
1157
+ /** calendar: which date field positions issues — a system date field or `cf.<key>` */
1158
+ calendarField: z
1159
+ .union([
1160
+ z.enum(['dueDate', 'startDate', 'createdAt', 'updatedAt', 'resolvedAt']),
1161
+ z.string().regex(/^cf\.[a-z][a-z0-9_]*$/),
1162
+ ])
1163
+ .default('dueDate'),
1089
1164
  /** timeline: show dependency arrows */
1090
1165
  showDependencies: z.boolean().default(true),
1091
1166
  density: z.enum(['compact', 'comfortable']).default('comfortable'),
@@ -1420,3 +1495,52 @@ export const IssueApproval = z.object({
1420
1495
  updatedAt: Timestamp,
1421
1496
  })
1422
1497
  export type IssueApproval = z.infer<typeof IssueApproval>
1498
+
1499
+ // =====================================================================================
1500
+ // project templates
1501
+ // =====================================================================================
1502
+
1503
+ /**
1504
+ * Everything a template seeds into a new project. This is the *only* description of a template's
1505
+ * contents: the built-in four are values of this type in code, and a template saved from an
1506
+ * existing project snapshots into the same shape, so one applier serves both.
1507
+ *
1508
+ * Ids inside a body are template-local. Workflows are named by index, types name their workflow by
1509
+ * that index, and layouts name fields by `cf.<key>` — nothing here refers to a database id, which
1510
+ * is what lets a body created in one workspace apply in another.
1511
+ */
1512
+ export const ProjectTemplateBody = z.object({
1513
+ version: z.literal(1).default(1),
1514
+ settings: ProjectSettings.partial().optional(),
1515
+ workflows: z
1516
+ .array(z.object({ name: z.string().min(1).max(120), definition: WorkflowDefinition }))
1517
+ .default([]),
1518
+ fields: z.array(UpsertFieldDef).default([]),
1519
+ types: z
1520
+ .array(
1521
+ UpsertWorkItemType.omit({ workflowId: true }).extend({
1522
+ /** index into `workflows`; null → the project's default workflow */
1523
+ workflowIndex: z.number().int().nonnegative().nullable().default(null),
1524
+ }),
1525
+ )
1526
+ .default([]),
1527
+ labels: z
1528
+ .array(z.object({ name: z.string().min(1).max(60), color: Color.nullable().default(null) }))
1529
+ .default([]),
1530
+ views: z.array(UpsertView).default([]),
1531
+ })
1532
+ export type ProjectTemplateBody = z.infer<typeof ProjectTemplateBody>
1533
+
1534
+ /** Reusable project blueprint (types, workflow, fields, labels, sample views). */
1535
+ export const ProjectTemplate = z.object({
1536
+ id: Id,
1537
+ workspaceId: WorkspaceId.nullable(),
1538
+ key: MachineKey,
1539
+ name: z.string().min(1).max(120),
1540
+ description: z.string().max(1000).nullable(),
1541
+ icon: z.string().max(64).nullable(),
1542
+ body: ProjectTemplateBody,
1543
+ builtin: z.boolean(),
1544
+ createdAt: Timestamp,
1545
+ })
1546
+ export type ProjectTemplate = z.infer<typeof ProjectTemplate>
@@ -16,7 +16,6 @@ import {
16
16
  Cycle,
17
17
  DateOnly,
18
18
  FieldDef,
19
- FieldScheme,
20
19
  HierarchyRules,
21
20
  ImportJob,
22
21
  ImportSource,
@@ -42,6 +41,7 @@ import {
42
41
  RecurringIssue,
43
42
  RelationType,
44
43
  RelationView,
44
+ ResolvedLayout,
45
45
  RichDoc,
46
46
  StatusHistoryEntry,
47
47
  StatusInfo,
@@ -173,6 +173,11 @@ export const trackerContract = {
173
173
  .route({ method: 'POST', path: '/types/{id}/archive', ...t('types') })
174
174
  .input(ws.extend({ id: Id, archived: z.boolean().default(true) }))
175
175
  .output(WorkItemType),
176
+ /** the fields of one type in one project, ordered and merged — what a form should render */
177
+ layout: baseContract
178
+ .route({ method: 'GET', path: '/types/{id}/layout', ...t('types') })
179
+ .input(ws.extend({ id: Id, projectId: Id.nullable().optional() }))
180
+ .output(ResolvedLayout),
176
181
  hierarchyRules: baseContract
177
182
  .route({ method: 'GET', path: '/types/hierarchy-rules', ...t('types') })
178
183
  .input(ws)
@@ -238,32 +243,6 @@ export const trackerContract = {
238
243
  .route({ method: 'DELETE', path: '/fields/{id}', ...t('fields') })
239
244
  .input(ws.extend({ id: Id }))
240
245
  .output(Ok),
241
- schemes: {
242
- list: baseContract
243
- .route({ method: 'GET', path: '/field-schemes', ...t('fields') })
244
- .input(ws)
245
- .output(z.array(FieldScheme)),
246
- create: baseContract
247
- .route({ method: 'POST', path: '/field-schemes', ...t('fields') })
248
- .input(ws.extend({ name: z.string().min(1).max(120), fieldIds: z.array(Id) }))
249
- .output(FieldScheme),
250
- update: baseContract
251
- .route({ method: 'PATCH', path: '/field-schemes/{id}', ...t('fields') })
252
- .input(
253
- ws.extend({
254
- id: Id,
255
- patch: z.object({
256
- name: z.string().min(1).max(120).optional(),
257
- fieldIds: z.array(Id).optional(),
258
- }),
259
- }),
260
- )
261
- .output(FieldScheme),
262
- delete: baseContract
263
- .route({ method: 'DELETE', path: '/field-schemes/{id}', ...t('fields') })
264
- .input(ws.extend({ id: Id }))
265
- .output(Ok),
266
- },
267
246
  },
268
247
 
269
248
  // ------------------------------------------------------------------ workflows
package/src/kql/fields.ts CHANGED
@@ -143,7 +143,11 @@ export function customKqlField(key: string, fieldType: FieldType, label: string)
143
143
  name: `cf.${key}`,
144
144
  kind,
145
145
  label,
146
- array: fieldType === 'multiselect' || fieldType === 'multiuser' || fieldType === 'label',
146
+ array:
147
+ fieldType === 'multiselect' ||
148
+ fieldType === 'multiuser' ||
149
+ fieldType === 'label' ||
150
+ fieldType === 'relation',
147
151
  sortable: kind === 'number' || kind === 'date' || kind === 'datetime' || kind === 'text',
148
152
  custom: { key, fieldType },
149
153
  }