@owlmeans/planning 0.1.18-rc.21 → 0.1.18-rc.22

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 (99) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/planning/SKILL.md +51 -3
  4. package/build/aliases/types.d.ts +23 -0
  5. package/build/aliases/types.d.ts.map +1 -1
  6. package/build/aliases.d.ts.map +1 -1
  7. package/build/aliases.js +2 -1
  8. package/build/aliases.js.map +1 -1
  9. package/build/consts.d.ts +21 -1
  10. package/build/consts.d.ts.map +1 -1
  11. package/build/consts.js +23 -0
  12. package/build/consts.js.map +1 -1
  13. package/build/entrypoints/types.d.ts +7 -0
  14. package/build/entrypoints/types.d.ts.map +1 -1
  15. package/build/entrypoints.d.ts.map +1 -1
  16. package/build/entrypoints.js +7 -0
  17. package/build/entrypoints.js.map +1 -1
  18. package/build/helpers/apply.d.ts.map +1 -1
  19. package/build/helpers/apply.js +8 -3
  20. package/build/helpers/apply.js.map +1 -1
  21. package/build/helpers/changes/consts.local.d.ts.map +1 -1
  22. package/build/helpers/changes/consts.local.js +1 -1
  23. package/build/helpers/changes/consts.local.js.map +1 -1
  24. package/build/helpers/changes.d.ts.map +1 -1
  25. package/build/helpers/changes.js +3 -0
  26. package/build/helpers/changes.js.map +1 -1
  27. package/build/helpers/query.js +1 -1
  28. package/build/helpers/query.js.map +1 -1
  29. package/build/helpers/scoped/types.d.ts +4 -2
  30. package/build/helpers/scoped/types.d.ts.map +1 -1
  31. package/build/helpers/scoped.d.ts +3 -2
  32. package/build/helpers/scoped.d.ts.map +1 -1
  33. package/build/helpers/scoped.js +29 -8
  34. package/build/helpers/scoped.js.map +1 -1
  35. package/build/helpers/validate/types.d.ts +1 -0
  36. package/build/helpers/validate/types.d.ts.map +1 -1
  37. package/build/helpers/validate.d.ts.map +1 -1
  38. package/build/helpers/validate.js +6 -1
  39. package/build/helpers/validate.js.map +1 -1
  40. package/build/helpers/wire.d.ts.map +1 -1
  41. package/build/helpers/wire.js +2 -1
  42. package/build/helpers/wire.js.map +1 -1
  43. package/build/index.d.ts +3 -0
  44. package/build/index.d.ts.map +1 -1
  45. package/build/index.js +1 -0
  46. package/build/index.js.map +1 -1
  47. package/build/registry.d.ts.map +1 -1
  48. package/build/registry.js +26 -0
  49. package/build/registry.js.map +1 -1
  50. package/build/resources/mentions.d.ts +4 -0
  51. package/build/resources/mentions.d.ts.map +1 -0
  52. package/build/resources/mentions.js +15 -0
  53. package/build/resources/mentions.js.map +1 -0
  54. package/build/resources/protocol/types.d.ts +29 -0
  55. package/build/resources/protocol/types.d.ts.map +1 -0
  56. package/build/resources/protocol/types.js +2 -0
  57. package/build/resources/protocol/types.js.map +1 -0
  58. package/build/resources/protocol.d.ts +6 -0
  59. package/build/resources/protocol.d.ts.map +1 -0
  60. package/build/resources/protocol.js +31 -0
  61. package/build/resources/protocol.js.map +1 -0
  62. package/build/resources/types.d.ts +146 -0
  63. package/build/resources/types.d.ts.map +1 -0
  64. package/build/resources/types.js +2 -0
  65. package/build/resources/types.js.map +1 -0
  66. package/build/resources/types.local.d.ts +6 -0
  67. package/build/resources/types.local.d.ts.map +1 -0
  68. package/build/resources/types.local.js +2 -0
  69. package/build/resources/types.local.js.map +1 -0
  70. package/build/schemas.d.ts +6 -0
  71. package/build/schemas.d.ts.map +1 -1
  72. package/build/schemas.js +59 -3
  73. package/build/schemas.js.map +1 -1
  74. package/build/types.d.ts +55 -3
  75. package/build/types.d.ts.map +1 -1
  76. package/package.json +1 -1
  77. package/src/aliases/types.ts +3 -0
  78. package/src/aliases.ts +5 -2
  79. package/src/consts.ts +24 -0
  80. package/src/entrypoints/types.ts +7 -0
  81. package/src/entrypoints.ts +8 -0
  82. package/src/helpers/apply.ts +8 -3
  83. package/src/helpers/changes/consts.local.ts +1 -1
  84. package/src/helpers/changes.ts +3 -0
  85. package/src/helpers/query.ts +1 -1
  86. package/src/helpers/scoped/types.ts +4 -2
  87. package/src/helpers/scoped.ts +32 -13
  88. package/src/helpers/validate/types.ts +1 -0
  89. package/src/helpers/validate.ts +6 -1
  90. package/src/helpers/wire.ts +2 -1
  91. package/src/index.ts +4 -0
  92. package/src/registry.ts +26 -0
  93. package/src/resources/mentions.ts +11 -0
  94. package/src/resources/protocol/types.ts +20 -0
  95. package/src/resources/protocol.ts +35 -0
  96. package/src/resources/types.local.ts +5 -0
  97. package/src/resources/types.ts +148 -0
  98. package/src/schemas.ts +61 -3
  99. package/src/types.ts +50 -4
@@ -1,3 +1,5 @@
1
+ import type { AssigneeTypeSchema } from '../resources/types.js'
2
+ import { AssigneeTypeSchemaSchema } from '../schemas.js'
1
3
  import { memoHelper } from '@owlmeans/context'
2
4
  import { PlanningSchemaKind, SchemaOrigin, WorkcardKind } from '../consts.js'
3
5
  import { PlanningUnsupported, SchemaInvalid, SchemaSealed } from '../errors.js'
@@ -16,20 +18,25 @@ const messageOf = (error: unknown): string => error instanceof Error ? error.mes
16
18
  export const createScopedSchemaHelper = (): ScopedSchemaHelper => {
17
19
  const checkers = memoHelper.once(() => {
18
20
  const ajv = makeAjv()
19
- return { flow: ajv.compile(StatusFlowSchemaSchema), type: ajv.compile(WorkcardTypeSchemaSchema) }
21
+ return { flow: ajv.compile(StatusFlowSchemaSchema), type: ajv.compile(WorkcardTypeSchemaSchema), assignee: ajv.compile(AssigneeTypeSchemaSchema) }
20
22
  })
21
23
 
22
24
  const schemaRecordKey = (record: Pick<ScopedSchemaRecord, 'entityId' | 'project' | 'kind' | 'key'>): string =>
23
25
  `${record.entityId}\u0000${record.project ?? ''}\u0000${record.kind}\u0000${record.key}`
24
26
 
25
- const schemaKeyOf = (kind: PlanningSchemaKind, definition: WorkcardTypeSchema | StatusFlowSchema): string =>
26
- kind === PlanningSchemaKind.Type ? (definition as WorkcardTypeSchema).type : (definition as StatusFlowSchema).id
27
+ const schemaKeyOf = (kind: PlanningSchemaKind, definition: WorkcardTypeSchema | StatusFlowSchema | AssigneeTypeSchema): string =>
28
+ kind !== PlanningSchemaKind.Flow ? (definition as WorkcardTypeSchema).type : (definition as StatusFlowSchema).id
27
29
 
28
30
  const openType = (type: AnyTypeSchema): boolean => type.kind === WorkcardKind.Card && type.overridable === true
29
31
 
30
32
  const assertOverridable = (
31
- code: Pick<PlanningSchemaRegistry, 'has' | 'type' | 'flows'>, kind: PlanningSchemaKind, key: string
33
+ code: Pick<PlanningSchemaRegistry, 'has' | 'type' | 'flows' | 'assigneeTypes'>, kind: PlanningSchemaKind, key: string
32
34
  ): void => {
35
+ if (kind === PlanningSchemaKind.AssigneeType) {
36
+ const schema = code.assigneeTypes().find(schema => schema.type === key)
37
+ if (schema != null && schema.overridable !== true) throw new SchemaSealed(`assignee:${key}`)
38
+ return
39
+ }
33
40
  if (kind === PlanningSchemaKind.Type) {
34
41
  if (code.has(key) && !openType(code.type(key))) {
35
42
  throw new SchemaSealed(`type:${key}`)
@@ -42,6 +49,12 @@ export const createScopedSchemaHelper = (): ScopedSchemaHelper => {
42
49
  }
43
50
  }
44
51
 
52
+ const assertAssigneeTypeSchema = (schema: AssigneeTypeSchema): void => {
53
+ const validate = checkers().assignee
54
+ if (!validate(schema)) throw new SchemaInvalid(`assignee:${(schema as Partial<AssigneeTypeSchema>)?.type}:${validateHelper.ajvErrorText(validate.errors)}`)
55
+ try { makeAjv().compile(schema.fields) } catch (error) { throw new SchemaInvalid(`assignee:${schema.type}:fields:${messageOf(error)}`) }
56
+ }
57
+
45
58
  const assertFlowSchema = (flow: StatusFlowSchema): void => {
46
59
  const validate = checkers().flow
47
60
  if (!validate(flow)) {
@@ -100,7 +113,7 @@ export const createScopedSchemaHelper = (): ScopedSchemaHelper => {
100
113
  }
101
114
  }
102
115
 
103
- const resolveKind = <T extends WorkcardTypeSchema | StatusFlowSchema | AnyTypeSchema>(
116
+ const resolveKind = <T extends WorkcardTypeSchema | StatusFlowSchema | AssigneeTypeSchema | AnyTypeSchema>(
104
117
  kind: PlanningSchemaKind,
105
118
  code: readonly T[],
106
119
  keyOf: (definition: T) => string,
@@ -171,14 +184,19 @@ export const createScopedSchemaHelper = (): ScopedSchemaHelper => {
171
184
  flow => flow != null && typeof flow.id === 'string', layers,
172
185
  )
173
186
 
187
+ const assignees = resolveKind<AssigneeTypeSchema>(
188
+ PlanningSchemaKind.AssigneeType, code.assigneeTypes ?? [], schema => schema.type, schema => schema.overridable === true,
189
+ schema => schema != null && typeof schema.type === 'string', { entity: layers.entity, project: [] },
190
+ )
174
191
  return structuredClone({
192
+ assigneeTypes: assignees.definitions,
175
193
  version: code.version,
176
194
  types: types.definitions,
177
195
  flows: flows.definitions,
178
196
  scope: scope.project == null ? { entityId: scope.entityId } : { entityId: scope.entityId, project: scope.project },
179
197
  revision: scope.revision ?? 0,
180
- origins: { types: types.origins, flows: flows.origins },
181
- retired: { types: types.retired, flows: flows.retired },
198
+ origins: { types: types.origins, flows: flows.origins, assigneeTypes: assignees.origins },
199
+ retired: { types: types.retired, flows: flows.retired, ...(assignees.retired.length > 0 ? { assigneeTypes: assignees.retired } : {}) },
182
200
  })
183
201
  }
184
202
 
@@ -189,19 +207,20 @@ export const createScopedSchemaHelper = (): ScopedSchemaHelper => {
189
207
  throw new PlanningUnsupported('schemas:read-only')
190
208
  }
191
209
  const originsOf = (kind: PlanningSchemaKind) =>
192
- kind === PlanningSchemaKind.Type ? kept.origins?.types : kept.origins?.flows
210
+ kind === PlanningSchemaKind.AssigneeType ? kept.origins?.assigneeTypes : kind === PlanningSchemaKind.Type ? kept.origins?.types : kept.origins?.flows
193
211
  const retiredOf = (kind: PlanningSchemaKind) =>
194
- kind === PlanningSchemaKind.Type ? kept.retired?.types : kept.retired?.flows
212
+ kind === PlanningSchemaKind.AssigneeType ? kept.retired?.assigneeTypes : kind === PlanningSchemaKind.Type ? kept.retired?.types : kept.retired?.flows
195
213
 
196
214
  return {
197
215
  ...base,
216
+ registerAssigneeType: refuse,
198
217
  registerType: refuse,
199
218
  registerFlow: refuse,
200
219
  load: refuse,
201
220
  scope: kept.scope ?? { entityId: '' },
202
221
  revision: kept.revision ?? 0,
203
222
  originOf: (kind, key) => originsOf(kind)?.[key]
204
- ?? ((kind === PlanningSchemaKind.Type ? base.has(key) : base.flows().some(flow => flow.id === key))
223
+ ?? ((kind === PlanningSchemaKind.AssigneeType ? base.assigneeTypes().some(schema => schema.type === key) : kind === PlanningSchemaKind.Type ? base.has(key) : base.flows().some(flow => flow.id === key))
205
224
  ? SchemaOrigin.Code
206
225
  : undefined),
207
226
  isRetired: (kind, key) => retiredOf(kind)?.includes(key) === true,
@@ -218,7 +237,7 @@ export const createScopedSchemaHelper = (): ScopedSchemaHelper => {
218
237
  }
219
238
 
220
239
  return {
221
- schemaRecordKey, schemaKeyOf, assertOverridable, assertFlowSchema, assertTypeSchema, resolveScopedBundle,
240
+ schemaRecordKey, schemaKeyOf, assertOverridable, assertAssigneeTypeSchema, assertFlowSchema, assertTypeSchema, resolveScopedBundle,
222
241
  scopedRegistryOf, flowInUse,
223
242
  }
224
243
  }
@@ -230,12 +249,12 @@ export const schemaRecordKey = (record: Pick<ScopedSchemaRecord, 'entityId' | 'p
230
249
  scopedSchemaHelper.schemaRecordKey(record)
231
250
 
232
251
  /** @deprecated compat:factory-refactor — use `scopedSchemaHelper.schemaKeyOf(…)` */
233
- export const schemaKeyOf = (kind: PlanningSchemaKind, definition: WorkcardTypeSchema | StatusFlowSchema): string =>
252
+ export const schemaKeyOf = (kind: PlanningSchemaKind, definition: WorkcardTypeSchema | StatusFlowSchema | AssigneeTypeSchema): string =>
234
253
  scopedSchemaHelper.schemaKeyOf(kind, definition)
235
254
 
236
255
  /** @deprecated compat:factory-refactor — use `scopedSchemaHelper.assertOverridable(…)` */
237
256
  export const assertOverridable = (
238
- code: Pick<PlanningSchemaRegistry, 'has' | 'type' | 'flows'>, kind: PlanningSchemaKind, key: string
257
+ code: Pick<PlanningSchemaRegistry, 'has' | 'type' | 'flows' | 'assigneeTypes'>, kind: PlanningSchemaKind, key: string
239
258
  ): void => scopedSchemaHelper.assertOverridable(code, kind, key)
240
259
 
241
260
  /** @deprecated compat:factory-refactor — use `scopedSchemaHelper.assertFlowSchema(…)` */
@@ -3,6 +3,7 @@ import type { PlanningSchemaRegistry, SpecificationSlot, Workcard } from '../../
3
3
 
4
4
  /** A record's shape, its type's `fields` and a document's body, checked against their schemas. */
5
5
  export interface ValidateHelper {
6
+ assertSchema: (schema: object, value: unknown) => void
6
7
  /** One line per ajv error: `/path message`. */
7
8
  ajvErrorText: (errors?: ErrorObject[] | null) => string
8
9
  /** Keys holding `.` or `$` at any depth — a document store cannot hold them. */
@@ -89,7 +89,12 @@ export const createValidateHelper = (): ValidateHelper => {
89
89
  }
90
90
  }
91
91
 
92
- return { ajvErrorText, invalidFieldKeys, validateFields, validateCard, validateSpecificationBody }
92
+ const assertSchema = (schema: object, value: unknown): void => {
93
+ const validate = compile(schema)
94
+ if (!validate(value)) throw new FieldsInvalid(ajvErrorText(validate.errors))
95
+ }
96
+
97
+ return { assertSchema, ajvErrorText, invalidFieldKeys, validateFields, validateCard, validateSpecificationBody }
93
98
  }
94
99
 
95
100
  export const validateHelper = createValidateHelper()
@@ -225,7 +225,7 @@ export const createWireHelper = (): WireHelper => {
225
225
  ...encodeListOptions(query),
226
226
  from: encodeList(query?.from),
227
227
  to: encodeList(query?.to),
228
- type: encodeList(query?.type),
228
+ type: encodeList(query?.type), fromKind: query?.fromKind, toKind: query?.toKind,
229
229
  })
230
230
 
231
231
  const decodeRelationshipQuery = (wire?: RelationshipQueryWire | RelationshipQuery | null): RelationshipQuery => {
@@ -235,6 +235,7 @@ export const createWireHelper = (): WireHelper => {
235
235
  from: decodeOneOrMany(value.from, 'from'),
236
236
  to: decodeOneOrMany(value.to, 'to'),
237
237
  type: decodeOneOrMany(value.type, 'type'),
238
+ fromKind: value.fromKind, toKind: value.toKind,
238
239
  }) as RelationshipQuery
239
240
  }
240
241
 
package/src/index.ts CHANGED
@@ -11,3 +11,7 @@ export * from './registry.js'
11
11
  export * from './helpers/index.js'
12
12
  export * from './models/index.js'
13
13
  export * from './i18n.js'
14
+
15
+ export type * from './resources/types.js'
16
+ export * from './resources/mentions.js'
17
+ export type * from './resources/protocol/types.js'
package/src/registry.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AssigneeTypeSchema } from './resources/types.js'
1
2
  import type { ValidateFunction } from 'ajv'
2
3
  import { PLANNING_SCHEMA_VERSION } from './consts.js'
3
4
  import { UnknownStatusFlow, UnknownWorkcardType } from './errors.js'
@@ -13,11 +14,31 @@ import type { AnyTypeSchema, PlanningSchemaBundle, PlanningSchemaRegistry, Statu
13
14
  */
14
15
  export const makeSchemaRegistry = (bundle?: Partial<PlanningSchemaBundle>): PlanningSchemaRegistry => {
15
16
  const types = new Map<string, AnyTypeSchema>()
17
+ const assigneeTypes = new Map<string, AssigneeTypeSchema>()
18
+ const assigneeValidators = new Map<string, ValidateFunction>()
16
19
  const flows = new Map<string, StatusFlowSchema>()
17
20
  const validators = new Map<string, ValidateFunction>()
18
21
  let ajv = makeAjv()
19
22
 
20
23
  const registry: PlanningSchemaRegistry = {
24
+ registerAssigneeType: schema => {
25
+ assigneeTypes.set(schema.type, schema)
26
+ assigneeValidators.delete(schema.type)
27
+ },
28
+ assigneeTypes: () => [...assigneeTypes.values()],
29
+ assigneeType: type => {
30
+ const schema = assigneeTypes.get(type)
31
+ if (schema == null) throw new UnknownWorkcardType(`assignee:${type}`)
32
+ return schema
33
+ },
34
+ assigneeValidator: type => {
35
+ let validator = assigneeValidators.get(type)
36
+ if (validator == null) {
37
+ validator = ajv.compile(registry.assigneeType(type).fields)
38
+ assigneeValidators.set(type, validator)
39
+ }
40
+ return validator
41
+ },
21
42
  registerType: schema => {
22
43
  types.set(schema.type, schema)
23
44
  validators.delete(schema.type)
@@ -70,19 +91,24 @@ export const makeSchemaRegistry = (bundle?: Partial<PlanningSchemaBundle>): Plan
70
91
  version: PLANNING_SCHEMA_VERSION,
71
92
  types: [...types.values()],
72
93
  flows: [...flows.values()],
94
+ assigneeTypes: [...assigneeTypes.values()],
73
95
  }),
74
96
 
75
97
  load: next => {
98
+ assigneeTypes.clear()
99
+ assigneeValidators.clear()
76
100
  types.clear()
77
101
  flows.clear()
78
102
  validators.clear()
79
103
  // A fresh ajv: a replaced type must not collide with a schema `$id` the old one compiled.
80
104
  ajv = makeAjv()
105
+ next.assigneeTypes?.forEach(registry.registerAssigneeType)
81
106
  next.flows.forEach(registry.registerFlow)
82
107
  next.types.forEach(registry.registerType)
83
108
  },
84
109
  }
85
110
 
111
+ bundle?.assigneeTypes?.forEach(registry.registerAssigneeType)
86
112
  bundle?.flows?.forEach(registry.registerFlow)
87
113
  bundle?.types?.forEach(registry.registerType)
88
114
 
@@ -0,0 +1,11 @@
1
+ import type { MentionHelper } from './types.local.js'
2
+
3
+ /** Stable-id Markdown mentions. Escaped display text cannot change the referenced identity. */
4
+ export const mentionHelper: MentionHelper = {
5
+ encode: (id, nickname) => `[@${nickname.replace(/[\\\[\]]/g, '\\$&')}](assignee:${encodeURIComponent(id)})`,
6
+ parse: body => [...new Set([...body.matchAll(/\[@(?:\\.|[^\]\\])*\]\(assignee:([^\s)]+)\)/g)]
7
+ .flatMap(match => {
8
+ try { return [decodeURIComponent(match[1])] } catch { return [] }
9
+ }))],
10
+ nicknameKey: nickname => nickname.normalize('NFKC').trim().toLocaleLowerCase('en-US'),
11
+ }
@@ -0,0 +1,20 @@
1
+ import type { EntrypointProtocol } from '@owlmeans/entrypoint'
2
+ import type { ListResult } from '@owlmeans/resource'
3
+ import type { AssigneeDraft, TeamDraft, CommentDraft, PlanningRecord } from '../types.js'
4
+
5
+ export interface PlanningResourceQueryWire { query?: string }
6
+ export interface PlanningResourceCommand {
7
+ action: 'create' | 'update' | 'retire' | 'remove' | 'members' | 'projects' | 'assignees' | 'addMember' | 'removeMember' | 'attach' | 'detach' | 'rebuild'
8
+ id?: string
9
+ draft?: Partial<AssigneeDraft & TeamDraft & CommentDraft>
10
+ version?: number
11
+ assignee?: string
12
+ project?: string
13
+ }
14
+ export type PlanningResourceReply = PlanningRecord | PlanningRecord[] | string[] | null
15
+
16
+ export interface PlanningResourceProtocols<R extends PlanningRecord = PlanningRecord> {
17
+ get: EntrypointProtocol<{ params: { id: string } }, R>
18
+ list: EntrypointProtocol<{ query: PlanningResourceQueryWire }, ListResult<R>>
19
+ write: EntrypointProtocol<{ body: PlanningResourceCommand }, PlanningResourceReply>
20
+ }
@@ -0,0 +1,35 @@
1
+ import { planningAliasHelper } from '../aliases.js'
2
+ import { contract, protocol, typed } from '@owlmeans/entrypoint'
3
+ import type { EntrypointProtocol } from '@owlmeans/entrypoint'
4
+ import { backend, route, RouteMethod } from '@owlmeans/route'
5
+ import { AssigneeKind } from '../consts.js'
6
+ import { WorkcardParamsSchema } from '../schemas.js'
7
+ import type { ListResult } from '@owlmeans/resource'
8
+ import type { PlanningRecord } from './types.js'
9
+ import type { PlanningResourceCommand, PlanningResourceProtocols, PlanningResourceQueryWire, PlanningResourceReply } from './protocol/types.js'
10
+
11
+ /** Opt-in auxiliary branches share the planning base's authentication guards. */
12
+ export const makePlanningResourceProtocols = <R extends PlanningRecord>(base: EntrypointProtocol<any, any>, alias: string, name: 'assignees' | 'teams' | 'comments' | 'mentions'): PlanningResourceProtocols<R> => {
13
+ const aliases = planningAliasHelper.planningResourceAliases(alias)[name]
14
+ const commandSchema = {
15
+ type: 'object', properties: {
16
+ action: { type: 'string', enum: ['create', 'update', 'retire', 'remove', 'members', 'projects', 'assignees', 'addMember', 'removeMember', 'attach', 'detach', 'rebuild'] },
17
+ id: { type: 'string', minLength: 1, maxLength: 128, nullable: true },
18
+ version: { type: 'number', minimum: 1, nullable: true },
19
+ assignee: { type: 'string', minLength: 1, maxLength: 128, nullable: true },
20
+ project: { type: 'string', minLength: 1, maxLength: 128, nullable: true },
21
+ draft: { type: 'object', properties: {
22
+ nickname: { type: 'string', nullable: true }, kind: { type: 'string', enum: [...Object.values(AssigneeKind), null], nullable: true },
23
+ type: { type: 'string', nullable: true }, name: { type: 'string', nullable: true }, externalId: { type: 'string', nullable: true },
24
+ card: { type: 'string', nullable: true }, body: { type: 'string', nullable: true },
25
+ fields: { type: 'object', additionalProperties: true, required: [], nullable: true },
26
+ authentication: { type: 'object', properties: { provider: { type: 'string' }, externalId: { type: 'string' } }, required: ['provider', 'externalId'], additionalProperties: false, nullable: true },
27
+ }, required: [], additionalProperties: false, nullable: true },
28
+ }, required: ['action'], additionalProperties: false,
29
+ }
30
+ return Object.freeze({
31
+ get: protocol(route(aliases.get, `/${name}/:id`, backend({ parent: base }, RouteMethod.GET)), contract.request({ params: typed<{ id: string }>(WorkcardParamsSchema) }, typed<R>())),
32
+ list: protocol(route(aliases.list, `/${name}`, backend({ parent: base }, RouteMethod.GET)), contract.request({ query: typed<PlanningResourceQueryWire>({ type: 'object', properties: { query: { type: 'string', maxLength: 16384, nullable: true } }, required: [], additionalProperties: false }) }, typed<ListResult<R>>())),
33
+ write: protocol(route(aliases.write, `/${name}`, backend({ parent: base }, RouteMethod.POST)), contract.request({ body: typed<PlanningResourceCommand>(commandSchema as any) }, typed<PlanningResourceReply>())),
34
+ })
35
+ }
@@ -0,0 +1,5 @@
1
+ export interface MentionHelper {
2
+ encode: (id: string, nickname: string) => string
3
+ parse: (body: string) => string[]
4
+ nicknameKey: (nickname: string) => string
5
+ }
@@ -0,0 +1,148 @@
1
+ import type { AnySchema, ValidateFunction } from 'ajv'
2
+ import type { ListOptions, ListResult, ResourceRecord, Criteria } from '@owlmeans/resource'
3
+ import type { AssigneeKind } from '../consts.js'
4
+
5
+ /** Identity in an external authentication domain; neither email nor nickname is its key. */
6
+ export interface AssigneeAuthentication {
7
+ provider: string
8
+ externalId: string
9
+ }
10
+
11
+ export interface AssigneeTypeSchema {
12
+ type: string
13
+ version: number
14
+ kind: AssigneeKind
15
+ fields: AnySchema
16
+ authentication?: 'required' | 'optional'
17
+ label?: string
18
+ overridable?: boolean
19
+ }
20
+
21
+ export interface PlanningRecord extends ResourceRecord {
22
+ id?: string
23
+ entityId: string
24
+ version: number
25
+ createdAt: string
26
+ updatedAt?: string
27
+ }
28
+
29
+ export interface Assignee extends PlanningRecord {
30
+ nickname: string
31
+ /** Organization-scoped, Unicode-normalized, case-insensitive identity key. */
32
+ nicknameKey: string
33
+ kind: AssigneeKind
34
+ type: string
35
+ fields: Record<string, unknown>
36
+ authentication?: AssigneeAuthentication
37
+ retired?: boolean
38
+ }
39
+
40
+ export interface Team extends PlanningRecord {
41
+ name: string
42
+ externalId?: string
43
+ fields: Record<string, unknown>
44
+ }
45
+
46
+ export interface Comment extends PlanningRecord {
47
+ card: string
48
+ /** Immutable author, resolved by the host from a trusted planning scope. */
49
+ author: string
50
+ body: string
51
+ }
52
+
53
+ /** A derived mention of a stable assignee id; revision is the source comment's version. */
54
+ export interface CommentMention extends PlanningRecord {
55
+ comment: string
56
+ card: string
57
+ assignee: string
58
+ revision: number
59
+ }
60
+
61
+ export interface AssigneeDraft {
62
+ nickname: string
63
+ kind: AssigneeKind
64
+ type: string
65
+ fields?: Record<string, unknown>
66
+ authentication?: AssigneeAuthentication
67
+ }
68
+
69
+ export interface TeamDraft {
70
+ name: string
71
+ externalId?: string
72
+ fields?: Record<string, unknown>
73
+ }
74
+
75
+ export interface CommentDraft {
76
+ card: string
77
+ body: string
78
+ }
79
+
80
+ /** Compare-and-set version is mandatory for updates/removal, optional for retiring an assignee. */
81
+ export interface PlanningWriteOptions { version: number }
82
+
83
+ export interface AssigneeQuery extends ListOptions<Assignee> {
84
+ ids?: string[]
85
+ nickname?: string
86
+ kind?: AssigneeKind
87
+ type?: string
88
+ authentication?: AssigneeAuthentication
89
+ retired?: boolean
90
+ }
91
+ export interface TeamQuery extends ListOptions<Team> { ids?: string[], externalId?: string }
92
+ export interface CommentQuery extends ListOptions<Comment> { card?: string, author?: string }
93
+ export interface MentionQuery extends ListOptions<CommentMention> { comment?: string, card?: string, assignee?: string }
94
+
95
+ /** Durable versioned resource port. put expects stored version + 1, with 1 for creation. */
96
+ export interface PlanningRecordStore<R extends PlanningRecord> {
97
+ get: (id: string, entityId: string) => Promise<R | null>
98
+ list: (where: Criteria<R>, opts?: ListOptions<R>) => Promise<ListResult<R>>
99
+ put: (record: R) => Promise<R>
100
+ drop: (id: string, entityId: string, version: number) => Promise<void>
101
+ }
102
+
103
+ export interface PlanningResourceFacade {
104
+ assignees: {
105
+ get: (id: string) => Promise<Assignee>
106
+ load: (id: string) => Promise<Assignee | null>
107
+ list: (query?: AssigneeQuery) => Promise<ListResult<Assignee>>
108
+ create: (draft: AssigneeDraft) => Promise<Assignee>
109
+ update: (id: string, changes: Partial<AssigneeDraft>, opts: PlanningWriteOptions) => Promise<Assignee>
110
+ retire: (id: string, opts: PlanningWriteOptions) => Promise<Assignee>
111
+ }
112
+ teams: {
113
+ get: (id: string) => Promise<Team>
114
+ load: (id: string) => Promise<Team | null>
115
+ list: (query?: TeamQuery) => Promise<ListResult<Team>>
116
+ create: (draft: TeamDraft) => Promise<Team>
117
+ update: (id: string, changes: Partial<TeamDraft>, opts: PlanningWriteOptions) => Promise<Team>
118
+ remove: (id: string, opts: PlanningWriteOptions) => Promise<void>
119
+ members: (team: string) => Promise<Assignee[]>
120
+ addMember: (team: string, assignee: string) => Promise<void>
121
+ removeMember: (team: string, assignee: string) => Promise<void>
122
+ projects: (team: string) => Promise<string[]>
123
+ attach: (team: string, project: string) => Promise<void>
124
+ detach: (team: string, project: string) => Promise<void>
125
+ assignees: (project: string) => Promise<Assignee[]>
126
+ }
127
+ comments: {
128
+ get: (id: string) => Promise<Comment>
129
+ load: (id: string) => Promise<Comment | null>
130
+ list: (query?: CommentQuery) => Promise<ListResult<Comment>>
131
+ create: (draft: CommentDraft) => Promise<Comment>
132
+ update: (id: string, changes: Pick<CommentDraft, 'body'>, opts: PlanningWriteOptions) => Promise<Comment>
133
+ remove: (id: string, opts: PlanningWriteOptions) => Promise<void>
134
+ }
135
+ mentions: {
136
+ get: (id: string) => Promise<CommentMention>
137
+ load: (id: string) => Promise<CommentMention | null>
138
+ list: (query?: MentionQuery) => Promise<ListResult<CommentMention>>
139
+ rebuild: (comment: string) => Promise<CommentMention[]>
140
+ }
141
+ }
142
+
143
+ export interface AssigneeSchemaRegistry {
144
+ registerAssigneeType: (schema: AssigneeTypeSchema) => void
145
+ assigneeType: (type: string) => AssigneeTypeSchema
146
+ assigneeTypes: () => AssigneeTypeSchema[]
147
+ assigneeValidator: (type: string) => ValidateFunction
148
+ }
package/src/schemas.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { AssigneeTypeSchema, Assignee, Team, Comment, CommentMention } from './resources/types.js'
2
+ import { AssigneeKind, PlanningResourceKind, ProjectMode } from './consts.js'
1
3
  import type { JSONSchemaType } from 'ajv'
2
4
  import { IdValueSchema } from '@owlmeans/auth'
3
5
  import { createListSchema } from '@owlmeans/resource'
@@ -68,6 +70,10 @@ const workcardProperties = {
68
70
  title: { type: 'string', minLength: 1, maxLength: TITLE_MAX },
69
71
  description: { type: 'string', maxLength: DESCRIPTION_MAX, nullable: true },
70
72
  parent: OptionalId,
73
+ parentType: { ...TypeKey, nullable: true },
74
+ reporter: OptionalId,
75
+ assignee: OptionalId,
76
+ mode: { type: 'string', enum: [...Object.values(ProjectMode), null], nullable: true },
71
77
  parents: { type: 'array', maxItems: MAX_PARENTS, items: Id },
72
78
  status: Label,
73
79
  intrinsic: IntrinsicStatusSchema,
@@ -101,7 +107,7 @@ const specificationProperties = {
101
107
  /** The optional form of a schema: `nullable`, and `null` admitted by an enum too. */
102
108
  const nullable = (schema: object): object => {
103
109
  const enumerated = (schema as { enum?: unknown[] }).enum
104
- return { ...schema, nullable: true, ...(enumerated != null ? { enum: [...enumerated, null] } : {}) }
110
+ return { ...schema, nullable: true, ...(enumerated != null ? { enum: [...new Set([...enumerated, null])] } : {}) }
105
111
  }
106
112
 
107
113
  const nullableOf = (properties: Record<string, object>): Record<string, object> =>
@@ -135,6 +141,8 @@ export const RelationshipDraftSchema = cast<RelationshipDraft>({
135
141
  type: Label,
136
142
  from: OptionalId,
137
143
  to: Id,
144
+ fromKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
145
+ toKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
138
146
  fields: OptionalOpenObject,
139
147
  },
140
148
  required: ['type', 'to'],
@@ -149,6 +157,8 @@ export const RelationshipSchema = cast<Relationship>({
149
157
  type: Label,
150
158
  from: Id,
151
159
  to: Id,
160
+ fromKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
161
+ toKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
152
162
  project: OptionalId,
153
163
  fields: OptionalOpenObject,
154
164
  createdAt: IsoDate,
@@ -166,6 +176,10 @@ export const WorkcardChangesSchema = cast<WorkcardChanges>({
166
176
  title: workcardProperties.title,
167
177
  description: workcardProperties.description,
168
178
  parent: workcardProperties.parent,
179
+ parentType: workcardProperties.parentType,
180
+ reporter: workcardProperties.reporter,
181
+ assignee: workcardProperties.assignee,
182
+ mode: workcardProperties.mode,
169
183
  parents: workcardProperties.parents,
170
184
  status: workcardProperties.status,
171
185
  intrinsic: workcardProperties.intrinsic,
@@ -306,6 +320,10 @@ export const RelationshipTypeSchema = cast<RelationshipType>({
306
320
  name: Label,
307
321
  from: { type: 'array', items: TypeKey, nullable: true },
308
322
  to: { type: 'array', items: TypeKey, nullable: true },
323
+ fromKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
324
+ toKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
325
+ field: { ...TypeKey, nullable: true },
326
+ multiple: { type: 'boolean', nullable: true },
309
327
  inverse: { ...Label, nullable: true },
310
328
  single: { type: 'boolean', nullable: true },
311
329
  label: { type: 'string', maxLength: TITLE_MAX, nullable: true },
@@ -321,6 +339,8 @@ const typeProperties = {
321
339
  flows: { type: 'array', minItems: 1, items: TypeKey },
322
340
  intrinsic: nullable(IntrinsicPolicySchema),
323
341
  specifications: { type: 'array', items: SpecificationSlotSchema },
342
+ parents: { type: 'object', properties: { types: { type: 'array', items: TypeKey, nullable: true }, required: { type: 'boolean', nullable: true } }, required: [], additionalProperties: false, nullable: true },
343
+ children: { type: 'object', properties: { types: { type: 'array', items: TypeKey } }, required: ['types'], additionalProperties: false, nullable: true },
324
344
  relationships: { type: 'array', items: RelationshipTypeSchema, nullable: true },
325
345
  labels: { type: 'array', items: Label, nullable: true },
326
346
  code: { ...CodePolicySchema, nullable: true },
@@ -367,12 +387,43 @@ export const AnyTypeSchemaSchema = cast<AnyTypeSchema>({
367
387
  additionalProperties: false,
368
388
  })
369
389
 
390
+ export const AssigneeTypeSchemaSchema = cast<AssigneeTypeSchema>({
391
+ type: 'object', properties: {
392
+ type: TypeKey, version: { type: 'number', minimum: 1 }, kind: { type: 'string', enum: Object.values(AssigneeKind) },
393
+ fields: OpenObject, authentication: { type: 'string', enum: ['required', 'optional', null], nullable: true },
394
+ label: { type: 'string', maxLength: TITLE_MAX, nullable: true }, overridable: { type: 'boolean', nullable: true },
395
+ }, required: ['type', 'version', 'kind', 'fields'], additionalProperties: false,
396
+ })
397
+ const planningRecordProperties = { id: OptionalId, entityId: Id, version: { type: 'number', minimum: 1 }, createdAt: IsoDate, updatedAt: OptionalIsoDate }
398
+ const planningRecordRequired = ['entityId', 'version', 'createdAt']
399
+ export const AssigneeSchema = cast<Assignee>({
400
+ type: 'object', properties: { ...planningRecordProperties,
401
+ nickname: { type: 'string', minLength: 1, maxLength: TYPE_MAX }, nicknameKey: TypeKey,
402
+ kind: { type: 'string', enum: Object.values(AssigneeKind) }, type: TypeKey, fields: OpenObject,
403
+ authentication: { type: 'object', properties: { provider: TypeKey, externalId: { type: 'string', minLength: 1, maxLength: REF_MAX } }, required: ['provider', 'externalId'], additionalProperties: false, nullable: true },
404
+ retired: { type: 'boolean', nullable: true },
405
+ }, required: [...planningRecordRequired, 'nickname', 'nicknameKey', 'kind', 'type', 'fields'], additionalProperties: false,
406
+ })
407
+ export const TeamSchema = cast<Team>({
408
+ type: 'object', properties: { ...planningRecordProperties, name: { type: 'string', minLength: 1, maxLength: TITLE_MAX }, externalId: { type: 'string', minLength: 1, maxLength: REF_MAX, nullable: true }, fields: OpenObject },
409
+ required: [...planningRecordRequired, 'name', 'fields'], additionalProperties: false,
410
+ })
411
+ export const CommentSchema = cast<Comment>({
412
+ type: 'object', properties: { ...planningRecordProperties, card: Id, author: Id, body: { type: 'string', minLength: 1, maxLength: BODY_MAX } },
413
+ required: [...planningRecordRequired, 'card', 'author', 'body'], additionalProperties: false,
414
+ })
415
+ export const CommentMentionSchema = cast<CommentMention>({
416
+ type: 'object', properties: { ...planningRecordProperties, comment: Id, card: Id, assignee: Id, revision: { type: 'number', minimum: 1 } },
417
+ required: [...planningRecordRequired, 'comment', 'card', 'assignee', 'revision'], additionalProperties: false,
418
+ })
419
+
370
420
  export const PlanningSchemaBundleSchema = cast<PlanningSchemaBundle>({
371
421
  type: 'object',
372
422
  properties: {
373
423
  version: { type: 'number', minimum: 1 },
374
424
  types: { type: 'array', items: AnyTypeSchemaSchema },
375
425
  flows: { type: 'array', items: StatusFlowSchemaSchema },
426
+ assigneeTypes: { type: 'array', items: AssigneeTypeSchemaSchema, nullable: true },
376
427
  },
377
428
  required: ['version', 'types', 'flows'],
378
429
  additionalProperties: false,
@@ -421,6 +472,7 @@ export const ScopedSchemaBundleSchema = cast<ScopedSchemaBundle>({
421
472
  version: { type: 'number', minimum: 1 },
422
473
  types: { type: 'array', items: AnyTypeSchemaSchema },
423
474
  flows: { type: 'array', items: StatusFlowSchemaSchema },
475
+ assigneeTypes: { type: 'array', items: AssigneeTypeSchemaSchema, nullable: true },
424
476
  scope: {
425
477
  type: 'object',
426
478
  properties: { entityId: Id, project: OptionalId },
@@ -431,14 +483,14 @@ export const ScopedSchemaBundleSchema = cast<ScopedSchemaBundle>({
431
483
  revision: { type: 'number', minimum: 0, nullable: true },
432
484
  origins: {
433
485
  type: 'object',
434
- properties: { types: originMap(), flows: originMap() },
486
+ properties: { types: originMap(), flows: originMap(), assigneeTypes: { ...originMap(), nullable: true } },
435
487
  required: ['types', 'flows'],
436
488
  additionalProperties: false,
437
489
  nullable: true,
438
490
  },
439
491
  retired: {
440
492
  type: 'object',
441
- properties: { types: keyList(), flows: keyList() },
493
+ properties: { types: keyList(), flows: keyList(), assigneeTypes: { ...keyList(), nullable: true } },
442
494
  required: ['types', 'flows'],
443
495
  additionalProperties: false,
444
496
  nullable: true,
@@ -462,6 +514,7 @@ export const SchemaDefineRequestSchema = cast<SchemaDefineRequest>({
462
514
  mode: nullable(SchemaWriteModeSchema),
463
515
  types: { type: 'array', maxItems: MAX_QUERY_LIST, items: WorkcardTypeSchemaSchema, nullable: true },
464
516
  flows: { type: 'array', maxItems: MAX_QUERY_LIST, items: StatusFlowSchemaSchema, nullable: true },
517
+ assigneeTypes: { type: 'array', maxItems: MAX_QUERY_LIST, items: AssigneeTypeSchemaSchema, nullable: true },
465
518
  retire: { type: 'array', maxItems: MAX_QUERY_LIST, items: SchemaKeySchema, nullable: true },
466
519
  },
467
520
  required: [],
@@ -476,6 +529,9 @@ export const WorkcardDraftSchema = cast<WorkcardDraft>({
476
529
  kind: WorkcardKindSchema,
477
530
  type: TypeKey,
478
531
  parent: OptionalId,
532
+ reporter: OptionalId,
533
+ assignee: OptionalId,
534
+ mode: workcardProperties.mode,
479
535
  parents: { ...workcardProperties.parents, nullable: true },
480
536
  title: workcardProperties.title,
481
537
  description: workcardProperties.description,
@@ -673,6 +729,8 @@ export const SpecificationQuerySchema = cast<SpecificationQueryWire>({
673
729
  export const RelationshipQuerySchema = cast<RelationshipQueryWire>({
674
730
  type: 'object',
675
731
  properties: {
732
+ fromKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
733
+ toKind: { type: 'string', enum: [...Object.values(PlanningResourceKind), null], nullable: true },
676
734
  ...listWireProperties(),
677
735
  from: listValue(),
678
736
  to: listValue(),