@owlmeans/server-planning 0.1.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/server-planning/SKILL.md +203 -0
- package/build/actions/cards.d.ts +16 -0
- package/build/actions/cards.d.ts.map +1 -0
- package/build/actions/cards.js +32 -0
- package/build/actions/cards.js.map +1 -0
- package/build/actions/commit.d.ts +14 -0
- package/build/actions/commit.d.ts.map +1 -0
- package/build/actions/commit.js +48 -0
- package/build/actions/commit.js.map +1 -0
- package/build/actions/execute.d.ts +13 -0
- package/build/actions/execute.d.ts.map +1 -0
- package/build/actions/execute.js +35 -0
- package/build/actions/execute.js.map +1 -0
- package/build/actions/index.d.ts +9 -0
- package/build/actions/index.d.ts.map +1 -0
- package/build/actions/index.js +9 -0
- package/build/actions/index.js.map +1 -0
- package/build/actions/links.d.ts +5 -0
- package/build/actions/links.d.ts.map +1 -0
- package/build/actions/links.js +8 -0
- package/build/actions/links.js.map +1 -0
- package/build/actions/schemas.d.ts +6 -0
- package/build/actions/schemas.d.ts.map +1 -0
- package/build/actions/schemas.js +9 -0
- package/build/actions/schemas.js.map +1 -0
- package/build/actions/specs.d.ts +10 -0
- package/build/actions/specs.d.ts.map +1 -0
- package/build/actions/specs.js +14 -0
- package/build/actions/specs.js.map +1 -0
- package/build/actions/transitions.d.ts +6 -0
- package/build/actions/transitions.d.ts.map +1 -0
- package/build/actions/transitions.js +8 -0
- package/build/actions/transitions.js.map +1 -0
- package/build/actions/watch.d.ts +12 -0
- package/build/actions/watch.d.ts.map +1 -0
- package/build/actions/watch.js +45 -0
- package/build/actions/watch.js.map +1 -0
- package/build/consts.d.ts +18 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +19 -0
- package/build/consts.js.map +1 -0
- package/build/executor/changes.d.ts +31 -0
- package/build/executor/changes.d.ts.map +1 -0
- package/build/executor/changes.js +55 -0
- package/build/executor/changes.js.map +1 -0
- package/build/executor/code.d.ts +15 -0
- package/build/executor/code.d.ts.map +1 -0
- package/build/executor/code.js +59 -0
- package/build/executor/code.js.map +1 -0
- package/build/executor/receipt.d.ts +12 -0
- package/build/executor/receipt.d.ts.map +1 -0
- package/build/executor/receipt.js +56 -0
- package/build/executor/receipt.js.map +1 -0
- package/build/executor/resolve.d.ts +35 -0
- package/build/executor/resolve.d.ts.map +1 -0
- package/build/executor/resolve.js +136 -0
- package/build/executor/resolve.js.map +1 -0
- package/build/executor/validate.d.ts +13 -0
- package/build/executor/validate.d.ts.map +1 -0
- package/build/executor/validate.js +222 -0
- package/build/executor/validate.js.map +1 -0
- package/build/executor.d.ts +15 -0
- package/build/executor.d.ts.map +1 -0
- package/build/executor.js +113 -0
- package/build/executor.js.map +1 -0
- package/build/facade.d.ts +12 -0
- package/build/facade.d.ts.map +1 -0
- package/build/facade.js +131 -0
- package/build/facade.js.map +1 -0
- package/build/helper.d.ts +18 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +32 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/projection.d.ts +28 -0
- package/build/projection.d.ts.map +1 -0
- package/build/projection.js +40 -0
- package/build/projection.js.map +1 -0
- package/build/registry.d.ts +30 -0
- package/build/registry.d.ts.map +1 -0
- package/build/registry.js +103 -0
- package/build/registry.js.map +1 -0
- package/build/service.d.ts +40 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +109 -0
- package/build/service.js.map +1 -0
- package/build/store/commits.d.ts +15 -0
- package/build/store/commits.d.ts.map +1 -0
- package/build/store/commits.js +147 -0
- package/build/store/commits.js.map +1 -0
- package/build/store/composite.d.ts +12 -0
- package/build/store/composite.d.ts.map +1 -0
- package/build/store/composite.js +150 -0
- package/build/store/composite.js.map +1 -0
- package/build/store/fold.d.ts +39 -0
- package/build/store/fold.d.ts.map +1 -0
- package/build/store/fold.js +220 -0
- package/build/store/fold.js.map +1 -0
- package/build/store/index.d.ts +6 -0
- package/build/store/index.d.ts.map +1 -0
- package/build/store/index.js +5 -0
- package/build/store/index.js.map +1 -0
- package/build/store/memory.d.ts +19 -0
- package/build/store/memory.d.ts.map +1 -0
- package/build/store/memory.js +0 -0
- package/build/store/memory.js.map +1 -0
- package/build/store/types.d.ts +82 -0
- package/build/store/types.d.ts.map +1 -0
- package/build/store/types.js +2 -0
- package/build/store/types.js.map +1 -0
- package/build/types.d.ts +46 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/guard.d.ts +19 -0
- package/build/utils/guard.d.ts.map +1 -0
- package/build/utils/guard.js +35 -0
- package/build/utils/guard.js.map +1 -0
- package/build/utils/index.d.ts +3 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +3 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/scope.d.ts +20 -0
- package/build/utils/scope.d.ts.map +1 -0
- package/build/utils/scope.js +36 -0
- package/build/utils/scope.js.map +1 -0
- package/package.json +66 -0
- package/src/actions/cards.ts +52 -0
- package/src/actions/commit.ts +55 -0
- package/src/actions/execute.ts +47 -0
- package/src/actions/index.ts +8 -0
- package/src/actions/links.ts +14 -0
- package/src/actions/schemas.ts +15 -0
- package/src/actions/specs.ts +23 -0
- package/src/actions/transitions.ts +14 -0
- package/src/actions/watch.ts +52 -0
- package/src/consts.ts +22 -0
- package/src/executor/changes.ts +78 -0
- package/src/executor/code.ts +83 -0
- package/src/executor/receipt.ts +66 -0
- package/src/executor/resolve.ts +174 -0
- package/src/executor/validate.ts +253 -0
- package/src/executor.ts +149 -0
- package/src/facade.ts +162 -0
- package/src/helper.ts +44 -0
- package/src/index.ts +13 -0
- package/src/projection.ts +65 -0
- package/src/registry.ts +143 -0
- package/src/service.ts +157 -0
- package/src/store/commits.ts +174 -0
- package/src/store/composite.ts +171 -0
- package/src/store/fold.ts +257 -0
- package/src/store/index.ts +6 -0
- package/src/store/memory.ts +0 -0
- package/src/store/types.ts +94 -0
- package/src/types.ts +48 -0
- package/src/utils/guard.ts +40 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/scope.ts +51 -0
- package/tests/commits.spec.ts +78 -0
- package/tests/context.ts +198 -0
- package/tests/entrypoints.spec.ts +72 -0
- package/tests/executor.spec.ts +69 -0
- package/tests/memory-store.spec.ts +84 -0
- package/tests/plugins.spec.ts +86 -0
- package/tests/specifications.spec.ts +61 -0
- package/tests/tsconfig.json +17 -0
- package/tests/validation.spec.ts +62 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import { decodeRelationshipQuery } from '@owlmeans/planning'
|
|
3
|
+
import type { PlanningProtocols } from '@owlmeans/planning'
|
|
4
|
+
import type { Context, PlanningHandlerOptions } from '../types.js'
|
|
5
|
+
import { concealed, handlerFacade } from '../utils/index.js'
|
|
6
|
+
|
|
7
|
+
export const listLinks = (
|
|
8
|
+
protocol: PlanningProtocols['link']['list'], opts?: PlanningHandlerOptions
|
|
9
|
+
): ReturnType<ReturnType<typeof handlers<Context>>['request']> =>
|
|
10
|
+
handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
|
|
11
|
+
const facade = await handlerFacade(ctx, req, opts)
|
|
12
|
+
|
|
13
|
+
return await facade.relationships.list(decodeRelationshipQuery(req.query))
|
|
14
|
+
}))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import type { PlanningProtocols } from '@owlmeans/planning'
|
|
3
|
+
import type { Context, PlanningHandlerOptions } from '../types.js'
|
|
4
|
+
import { planningServiceOf, scopeOf } from '../utils/index.js'
|
|
5
|
+
|
|
6
|
+
/** The schema bundle the service holds — what a client loads to answer `can()` with no round trip. */
|
|
7
|
+
export const listSchemas = (
|
|
8
|
+
protocol: PlanningProtocols['schema']['list'], opts?: PlanningHandlerOptions
|
|
9
|
+
): ReturnType<ReturnType<typeof handlers<Context>>['request']> =>
|
|
10
|
+
handlers<Context>().request(protocol, async (req, ctx) => {
|
|
11
|
+
// Scoped like every other leaf: no organization, no bundle.
|
|
12
|
+
scopeOf(req)
|
|
13
|
+
|
|
14
|
+
return planningServiceOf(ctx, opts).schemas.bundle()
|
|
15
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import type { PlanningProtocols } from '@owlmeans/planning'
|
|
3
|
+
import type { Context, PlanningHandlerOptions } from '../types.js'
|
|
4
|
+
import { concealed, handlerFacade } from '../utils/index.js'
|
|
5
|
+
|
|
6
|
+
type RequestHandler = ReturnType<ReturnType<typeof handlers<Context>>['request']>
|
|
7
|
+
|
|
8
|
+
/** @throws {WorkcardNotFound} */
|
|
9
|
+
export const getSpecification = (protocol: PlanningProtocols['spec']['get'], opts?: PlanningHandlerOptions): RequestHandler =>
|
|
10
|
+
handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
|
|
11
|
+
const facade = await handlerFacade(ctx, req, opts)
|
|
12
|
+
|
|
13
|
+
return await facade.specifications.get(`${req.params.id}`)
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
/** A document's history, newest first, replayed from its log. */
|
|
17
|
+
export const listSpecificationRevisions = (protocol: PlanningProtocols['spec']['revisions'], opts?: PlanningHandlerOptions): RequestHandler =>
|
|
18
|
+
handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
|
|
19
|
+
const facade = await handlerFacade(ctx, req, opts)
|
|
20
|
+
const limit = req.query?.limit == null ? undefined : Number(req.query.limit)
|
|
21
|
+
|
|
22
|
+
return { items: await facade.specifications.revisions(`${req.params.id}`, Number.isFinite(limit) ? limit : undefined) }
|
|
23
|
+
}))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { handlers } from '@owlmeans/server-api'
|
|
2
|
+
import type { PlanningProtocols } from '@owlmeans/planning'
|
|
3
|
+
import type { Context, PlanningHandlerOptions } from '../types.js'
|
|
4
|
+
import { concealed, handlerFacade } from '../utils/index.js'
|
|
5
|
+
|
|
6
|
+
/** @throws {WorkcardNotFound} for an absent transition and for another entity's alike */
|
|
7
|
+
export const getTransition = (
|
|
8
|
+
protocol: PlanningProtocols['transition']['get'], opts?: PlanningHandlerOptions
|
|
9
|
+
): ReturnType<ReturnType<typeof handlers<Context>>['request']> =>
|
|
10
|
+
handlers<Context>().request(protocol, async (req, ctx) => concealed(async () => {
|
|
11
|
+
const facade = await handlerFacade(ctx, req, opts)
|
|
12
|
+
|
|
13
|
+
return await facade.transitions.get(`${req.params.transition}`)
|
|
14
|
+
}))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { PLANNING_COMMIT_EVENT } from '@owlmeans/planning'
|
|
2
|
+
import type { CommitEvent, CommitFeedQuery, PlanningProtocols } from '@owlmeans/planning'
|
|
3
|
+
import { connection } from '@owlmeans/server-socket'
|
|
4
|
+
import type { EventMessage } from '@owlmeans/socket'
|
|
5
|
+
import { MessageType } from '@owlmeans/socket'
|
|
6
|
+
import type { Context, PlanningHandlerOptions } from '../types.js'
|
|
7
|
+
import { handlerFacade } from '../utils/index.js'
|
|
8
|
+
|
|
9
|
+
const text = (value: unknown): string | undefined =>
|
|
10
|
+
value == null || value === '' ? undefined : `${value}`
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The notify tool: push the caller's entity's commits down a socket as `planning-commit` frames.
|
|
14
|
+
*
|
|
15
|
+
* The subscription is filtered by the authenticated entity (never a query value) and optionally by
|
|
16
|
+
* `project`/`card`. A frame without `record` — what a cross-process bus delivers — gets the card as
|
|
17
|
+
* it reads now (`null` once deleted), so a subscriber never re-reads per frame.
|
|
18
|
+
*/
|
|
19
|
+
export const watchCommits = (
|
|
20
|
+
protocol: PlanningProtocols['commit']['events'], opts?: PlanningHandlerOptions
|
|
21
|
+
): ReturnType<typeof connection> => connection<typeof protocol, Context>(protocol, async (conn, ctx, req) => {
|
|
22
|
+
const facade = await handlerFacade(ctx, req, opts)
|
|
23
|
+
const query = (req.query ?? {}) as CommitFeedQuery
|
|
24
|
+
const frame = opts?.event ?? PLANNING_COMMIT_EVENT
|
|
25
|
+
const filter = {
|
|
26
|
+
...(text(query.project) != null ? { project: text(query.project) } : {}),
|
|
27
|
+
...(text(query.card) != null ? { card: text(query.card) } : {}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const unsubscribe = await facade.commits.subscribe(async (event: CommitEvent) => {
|
|
31
|
+
try {
|
|
32
|
+
const enriched = event.record !== undefined ? event : { ...event, record: await facade.cards.load(event.card) }
|
|
33
|
+
await conn.notify(frame, enriched)
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error('Planning commit notify error:', e)
|
|
36
|
+
}
|
|
37
|
+
}, filter)
|
|
38
|
+
|
|
39
|
+
conn.listen(async message => {
|
|
40
|
+
if (typeof message !== 'object') {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const msg = message as EventMessage<void>
|
|
44
|
+
if (msg.type === MessageType.System && msg.event === 'close') {
|
|
45
|
+
try {
|
|
46
|
+
unsubscribe()
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error('Planning commit unsubscribe error:', e)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
})
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PLANNING_SERVICE } from '@owlmeans/planning'
|
|
2
|
+
|
|
3
|
+
/** The alias `appendPlanningService` registers the host under — the same one a client uses. */
|
|
4
|
+
export const DEFAULT_ALIAS = PLANNING_SERVICE
|
|
5
|
+
|
|
6
|
+
/** The alias a memory store answers `PlanningStore.alias` with. */
|
|
7
|
+
export const MEMORY_STORE_ALIAS = 'planning-memory'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How many settled commits a hub remembers after their transition row may be gone.
|
|
11
|
+
*
|
|
12
|
+
* A project `delete` purges the project's own log, so without this a waiter that polls a moment
|
|
13
|
+
* after the fold would be told the transition never existed.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_COMMIT_MEMORY = 1000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The poll ladder `wait` climbs after its first immediate poll, in milliseconds. The last step
|
|
19
|
+
* repeats. The subscription answers first whenever the fold publishes where the hub hears it; the
|
|
20
|
+
* ladder is what answers when it folded somewhere the hub does not.
|
|
21
|
+
*/
|
|
22
|
+
export const COMMIT_POLL_LADDER: readonly number[] = Object.freeze([250, 1000, 2000])
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CommitState, computeChanges, isEmptyChange, TransitionAction } from '@owlmeans/planning'
|
|
2
|
+
import type {
|
|
3
|
+
ChangeSet, PlanningSchemaRegistry, PlanningScope, RelationshipDraft, Transition, TransitionActor,
|
|
4
|
+
TransitionExecution, WorkcardDraft,
|
|
5
|
+
} from '@owlmeans/planning'
|
|
6
|
+
import type { Resolved } from './resolve.js'
|
|
7
|
+
|
|
8
|
+
const clean = <T extends object>(record: T): T =>
|
|
9
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Step 9: the recorded `changes`/`unset`, and whether an update wrote nothing.
|
|
13
|
+
*
|
|
14
|
+
* @throws {IllegalTransition | PlanningError}
|
|
15
|
+
*/
|
|
16
|
+
export const changeSetOf = (
|
|
17
|
+
exec: TransitionExecution, resolved: Resolved, schemas: PlanningSchemaRegistry, at: string
|
|
18
|
+
): { set: ChangeSet, empty: boolean } => {
|
|
19
|
+
const set = computeChanges(resolved.card, exec, resolved.type, schemas, at, { slot: resolved.slot })
|
|
20
|
+
const empty = exec.action === TransitionAction.Update && isEmptyChange(set)
|
|
21
|
+
|
|
22
|
+
return { set, empty }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Who wrote it — the SCOPE's identity, never the execution's.
|
|
27
|
+
*
|
|
28
|
+
* `profileId`, `userId`, `service` and `channel` come only from the scope (the server builds it
|
|
29
|
+
* from the authenticated request); the descriptive `agent`/`runId` an in-process caller passes on
|
|
30
|
+
* the execution are kept unless the scope's own actor names them.
|
|
31
|
+
*/
|
|
32
|
+
export const actorOf = (scope: PlanningScope, exec: TransitionExecution): TransitionActor => clean({
|
|
33
|
+
agent: exec.actor?.agent,
|
|
34
|
+
runId: exec.actor?.runId,
|
|
35
|
+
...clean({ profileId: scope.profileId, userId: scope.userId, service: scope.service }),
|
|
36
|
+
...clean(scope.actor ?? {}),
|
|
37
|
+
channel: scope.channel ?? scope.actor?.channel,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const withFrom = (link: RelationshipDraft, card: string): RelationshipDraft => ({ ...link, from: link.from ?? card })
|
|
41
|
+
|
|
42
|
+
/** Step 11's record: the event as it is appended, `pending`. */
|
|
43
|
+
export const transitionOf = (params: {
|
|
44
|
+
exec: TransitionExecution
|
|
45
|
+
resolved: Resolved
|
|
46
|
+
scope: PlanningScope
|
|
47
|
+
set: ChangeSet
|
|
48
|
+
cardId: string
|
|
49
|
+
seq: number
|
|
50
|
+
project?: string
|
|
51
|
+
at: string
|
|
52
|
+
}): Transition => {
|
|
53
|
+
const { exec, resolved, scope, set, cardId, seq, project, at } = params
|
|
54
|
+
const kind = resolved.create ? (exec.card as WorkcardDraft).kind : resolved.card!.kind
|
|
55
|
+
|
|
56
|
+
return clean({
|
|
57
|
+
entityId: scope.entityId,
|
|
58
|
+
card: cardId,
|
|
59
|
+
kind,
|
|
60
|
+
type: resolved.type.type,
|
|
61
|
+
project,
|
|
62
|
+
seq,
|
|
63
|
+
action: exec.action,
|
|
64
|
+
flow: set.flow,
|
|
65
|
+
transition: exec.action === TransitionAction.Transit ? exec.transition : undefined,
|
|
66
|
+
from: set.from,
|
|
67
|
+
to: set.to,
|
|
68
|
+
changes: set.changes,
|
|
69
|
+
unset: set.unset.length > 0 ? set.unset : undefined,
|
|
70
|
+
link: exec.link != null && exec.action !== TransitionAction.Create ? withFrom(exec.link, cardId) : undefined,
|
|
71
|
+
links: resolved.create && (exec.links?.length ?? 0) > 0 ? exec.links!.map(link => withFrom(link, cardId)) : undefined,
|
|
72
|
+
actor: actorOf(scope, exec),
|
|
73
|
+
cause: exec.cause,
|
|
74
|
+
key: exec.key,
|
|
75
|
+
at,
|
|
76
|
+
commit: { state: CommitState.Pending },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CODE_MINT_ATTEMPTS, CodeScope, CodeStyle, CodeTaken, mintCode, TransitionAction,
|
|
3
|
+
} from '@owlmeans/planning'
|
|
4
|
+
import type {
|
|
5
|
+
CodePolicy, PlanningExecContext, PlanningPlugin, PlanningScope, TransitionExecution, WorkcardDraft,
|
|
6
|
+
} from '@owlmeans/planning'
|
|
7
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
8
|
+
import type { Workcard } from '@owlmeans/planning'
|
|
9
|
+
import type { PluginRegistry } from '../registry.js'
|
|
10
|
+
import type { Resolved } from './resolve.js'
|
|
11
|
+
|
|
12
|
+
/** The criteria a code must be unique within — the policy's scope, `parent` when there is none. */
|
|
13
|
+
const scopeCriteria = (
|
|
14
|
+
scope: PlanningScope, type: string, parent: string | undefined, policy?: CodePolicy
|
|
15
|
+
): Criteria<Workcard> => ({
|
|
16
|
+
entityId: scope.entityId,
|
|
17
|
+
type,
|
|
18
|
+
...((policy?.uniqueWithin ?? CodeScope.Parent) === CodeScope.Parent ? { parent: parent ?? null } : {}),
|
|
19
|
+
}) as Criteria<Workcard>
|
|
20
|
+
|
|
21
|
+
export const takenProbe = (resolved: Resolved, scope: PlanningScope, parent: string | undefined) => {
|
|
22
|
+
const base = scopeCriteria(scope, resolved.type.type, parent, resolved.type.code)
|
|
23
|
+
|
|
24
|
+
return async (code: string): Promise<boolean> =>
|
|
25
|
+
await resolved.store.cards.count({ ...base, code } as Criteria<Workcard>) > 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Step 8: the code a new card gets, or the check a changed code passes.
|
|
30
|
+
*
|
|
31
|
+
* A caller-supplied code is checked for uniqueness; otherwise the plugins' `mintCode` chain answers
|
|
32
|
+
* (first answer wins, and is checked too); otherwise the type's policy mints one — a slug policy
|
|
33
|
+
* derives it from the title. A type with no policy and no supplied code gets none.
|
|
34
|
+
*
|
|
35
|
+
* @throws {CodeTaken}
|
|
36
|
+
*/
|
|
37
|
+
export const assignCode = async (
|
|
38
|
+
exec: TransitionExecution,
|
|
39
|
+
resolved: Resolved,
|
|
40
|
+
scope: PlanningScope,
|
|
41
|
+
registry: PluginRegistry,
|
|
42
|
+
contextOf: (plugin: PlanningPlugin) => PlanningExecContext
|
|
43
|
+
): Promise<TransitionExecution> => {
|
|
44
|
+
const policy = resolved.type.code
|
|
45
|
+
|
|
46
|
+
if (exec.action !== TransitionAction.Create) {
|
|
47
|
+
const code = exec.changes?.code
|
|
48
|
+
if (code != null && code !== resolved.card?.code
|
|
49
|
+
&& await takenProbe(resolved, scope, exec.changes?.parent ?? resolved.card?.parent)(code)) {
|
|
50
|
+
throw new CodeTaken(code)
|
|
51
|
+
}
|
|
52
|
+
return exec
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const draft = exec.card as WorkcardDraft
|
|
56
|
+
const taken = takenProbe(resolved, scope, draft.parent)
|
|
57
|
+
if (draft.code != null && draft.code !== '') {
|
|
58
|
+
if (await taken(draft.code)) {
|
|
59
|
+
throw new CodeTaken(draft.code)
|
|
60
|
+
}
|
|
61
|
+
return exec
|
|
62
|
+
}
|
|
63
|
+
if (policy == null) {
|
|
64
|
+
return exec
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let code = await registry.mintCode(draft, taken, contextOf)
|
|
68
|
+
if (code != null) {
|
|
69
|
+
if (await taken(code)) {
|
|
70
|
+
throw new CodeTaken(code)
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
const count = policy.style === CodeStyle.Sequential
|
|
74
|
+
? await resolved.store.cards.count(scopeCriteria(scope, draft.type, draft.parent, policy))
|
|
75
|
+
: undefined
|
|
76
|
+
code = await mintCode(policy, taken, CODE_MINT_ATTEMPTS, {
|
|
77
|
+
...(policy.style === CodeStyle.Slug ? { seed: draft.title } : {}),
|
|
78
|
+
...(count != null ? { count } : {}),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { ...exec, card: { ...draft, code } }
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CommitState, DEFAULT_COMMIT_TIMEOUT, TransitionAction } from '@owlmeans/planning'
|
|
2
|
+
import type {
|
|
3
|
+
CommitStatus, ExecuteOptions, PlanningStore, Transition, TransitionReceipt, TransitionReceiptView, Workcard,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
|
|
6
|
+
const clean = <T extends object>(record: T): T =>
|
|
7
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
8
|
+
|
|
9
|
+
const statusOf = async (store: PlanningStore, transition: Transition): Promise<CommitStatus | null> => {
|
|
10
|
+
if (store.commits == null || transition.id == null) {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return await store.commits.status(transition.id)
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The transition as its latest commit status reports it, and the card when that commit landed. */
|
|
21
|
+
const viewOf = async (store: PlanningStore, transition: Transition): Promise<TransitionReceiptView> => {
|
|
22
|
+
const status = await statusOf(store, transition)
|
|
23
|
+
if (status == null) {
|
|
24
|
+
return { transition }
|
|
25
|
+
}
|
|
26
|
+
const view: TransitionReceiptView = {
|
|
27
|
+
transition: { ...transition, commit: clean({ state: status.state, at: status.at, error: status.error }) },
|
|
28
|
+
}
|
|
29
|
+
if (status.state === CommitState.Committed) {
|
|
30
|
+
view.card = status.card !== undefined
|
|
31
|
+
? status.card
|
|
32
|
+
: transition.action === TransitionAction.Delete
|
|
33
|
+
? null
|
|
34
|
+
: await store.cards.get(transition.card, transition.entityId)
|
|
35
|
+
}
|
|
36
|
+
return view
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A receipt for an appended (or idempotently found) transition.
|
|
41
|
+
*
|
|
42
|
+
* `committed()` waits on the store's commit source; a store without one (a foreign provider that
|
|
43
|
+
* applies writes itself) answers the card as it reads now. With `wait: true` the receipt is
|
|
44
|
+
* returned only once the commit landed — `CommitFailed` / `CommitTimeout` otherwise.
|
|
45
|
+
*
|
|
46
|
+
* @throws {CommitFailed | CommitTimeout} in wait mode
|
|
47
|
+
*/
|
|
48
|
+
export const makeReceipt = async (
|
|
49
|
+
store: PlanningStore, transition: Transition, opts?: ExecuteOptions
|
|
50
|
+
): Promise<TransitionReceipt> => {
|
|
51
|
+
const committed = async (waitOpts?: { timeout?: number }): Promise<Workcard | null> => {
|
|
52
|
+
if (store.commits == null || transition.id == null) {
|
|
53
|
+
return await store.cards.get(transition.card, transition.entityId)
|
|
54
|
+
}
|
|
55
|
+
return await store.commits.wait(transition.id, { timeout: waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let view = await viewOf(store, transition)
|
|
59
|
+
if (opts?.wait === true && view.transition.commit.state !== CommitState.Committed) {
|
|
60
|
+
const card = await committed({ timeout: opts.timeout })
|
|
61
|
+
view = await viewOf(store, transition)
|
|
62
|
+
view.card = card
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ...view, committed }
|
|
66
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CardTypeNotAllowed, isProject, normalizeParents, ParentNotFound, PlanningError, PlanningScopeMismatch,
|
|
3
|
+
primaryFlowOf, projectOf, slotOf, TransitionAction, UnknownStatusFlow, WorkcardKind, WorkcardNotFound,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import type {
|
|
6
|
+
AnyTypeSchema, PlanningFacade, PlanningStore, ProjectTypeSchema, Specification, SpecificationSlot,
|
|
7
|
+
StatusFlowSchema, TransitionExecution, Workcard, WorkcardDraft,
|
|
8
|
+
} from '@owlmeans/planning'
|
|
9
|
+
import type { PlanningRuntime } from '../service.js'
|
|
10
|
+
|
|
11
|
+
/** Everything the steps after resolution read about an execution. */
|
|
12
|
+
export interface Resolved {
|
|
13
|
+
create: boolean
|
|
14
|
+
/** The existing card (not a create). */
|
|
15
|
+
card?: Workcard
|
|
16
|
+
type: AnyTypeSchema
|
|
17
|
+
flowId: string
|
|
18
|
+
flow: StatusFlowSchema
|
|
19
|
+
/** The primary parent, when it resolves. */
|
|
20
|
+
parent?: Workcard
|
|
21
|
+
/** The slot a specification belongs to. */
|
|
22
|
+
slot?: SpecificationSlot
|
|
23
|
+
/** The store the type is written to. */
|
|
24
|
+
store: PlanningStore
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const trimmed = (value: unknown): unknown => typeof value === 'string' ? value.trim() : value
|
|
28
|
+
|
|
29
|
+
const TRIMMED = ['title', 'description', 'code'] as const
|
|
30
|
+
|
|
31
|
+
const trim = <T extends object>(record: T | undefined): T | undefined => {
|
|
32
|
+
if (record == null) {
|
|
33
|
+
return record
|
|
34
|
+
}
|
|
35
|
+
const copy = { ...record } as Record<string, unknown>
|
|
36
|
+
for (const key of TRIMMED) {
|
|
37
|
+
if (typeof copy[key] === 'string') {
|
|
38
|
+
copy[key] = trimmed(copy[key])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return copy as T
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Step 1: a deep copy with trimmed text, `parents ∋ parent` on a draft, and no duplicate `unset`
|
|
46
|
+
* paths. The caller's object is never touched.
|
|
47
|
+
*/
|
|
48
|
+
export const normalizeExecution = (input: TransitionExecution): TransitionExecution => {
|
|
49
|
+
const exec = structuredClone(input) as TransitionExecution
|
|
50
|
+
if (exec.card != null && typeof exec.card === 'object') {
|
|
51
|
+
const draft = trim(exec.card) as WorkcardDraft
|
|
52
|
+
const parents = normalizeParents(draft.parent, draft.parents)
|
|
53
|
+
exec.card = { ...draft, ...(parents.length > 0 ? { parents, parent: draft.parent ?? parents[0] } : {}) }
|
|
54
|
+
}
|
|
55
|
+
if (exec.changes != null) {
|
|
56
|
+
exec.changes = trim(exec.changes)
|
|
57
|
+
}
|
|
58
|
+
if (exec.unset != null) {
|
|
59
|
+
exec.unset = [...new Set(exec.unset)]
|
|
60
|
+
}
|
|
61
|
+
return exec
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The parent type's rule for a child of `kind`/`type`. @throws {CardTypeNotAllowed} */
|
|
65
|
+
export const assertChildAllowed = (
|
|
66
|
+
runtime: PlanningRuntime, parent: Workcard, kind: WorkcardKind, type: string
|
|
67
|
+
): void => {
|
|
68
|
+
if (kind === WorkcardKind.Specification) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (!isProject(parent)) {
|
|
72
|
+
throw new CardTypeNotAllowed(`${parent.type}:${type}`)
|
|
73
|
+
}
|
|
74
|
+
const parentType = runtime.service().schemas.type(parent.type) as ProjectTypeSchema
|
|
75
|
+
const allowed = kind === WorkcardKind.Project ? parentType.projectTypes ?? [] : parentType.cardTypes ?? []
|
|
76
|
+
if (!allowed.includes(type)) {
|
|
77
|
+
throw new CardTypeNotAllowed(`${parent.type}:${type}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Step 4: the card, its type and flow, its parent and — for a specification — its slot.
|
|
83
|
+
*
|
|
84
|
+
* @throws {WorkcardNotFound | PlanningScopeMismatch | UnknownWorkcardType | UnknownStatusFlow}
|
|
85
|
+
* @throws {ParentNotFound | CardTypeNotAllowed | PlanningError}
|
|
86
|
+
*/
|
|
87
|
+
export const resolveExecution = async (
|
|
88
|
+
runtime: PlanningRuntime, facade: PlanningFacade, exec: TransitionExecution
|
|
89
|
+
): Promise<Resolved> => {
|
|
90
|
+
const service = runtime.service()
|
|
91
|
+
const schemas = service.schemas
|
|
92
|
+
const entityId = facade.scope.entityId
|
|
93
|
+
const create = exec.action === TransitionAction.Create
|
|
94
|
+
|
|
95
|
+
let card: Workcard | undefined
|
|
96
|
+
let type: AnyTypeSchema
|
|
97
|
+
let parent: Workcard | undefined
|
|
98
|
+
|
|
99
|
+
if (create) {
|
|
100
|
+
if (exec.card == null || typeof exec.card !== 'object') {
|
|
101
|
+
throw new PlanningError('malformed:create-without-draft')
|
|
102
|
+
}
|
|
103
|
+
const draft = exec.card
|
|
104
|
+
type = schemas.type(draft.type)
|
|
105
|
+
if (type.kind !== draft.kind) {
|
|
106
|
+
throw new PlanningError(`malformed:kind:${draft.type}:${draft.kind}`)
|
|
107
|
+
}
|
|
108
|
+
// Normalized: the primary parent is first. It must accept the child; so must every project
|
|
109
|
+
// among the secondary parents.
|
|
110
|
+
for (const [index, id] of normalizeParents(draft.parent, draft.parents).entries()) {
|
|
111
|
+
const found = await facade.cards.load(id)
|
|
112
|
+
if (found == null) {
|
|
113
|
+
throw new ParentNotFound(id)
|
|
114
|
+
}
|
|
115
|
+
if (index === 0) {
|
|
116
|
+
parent = found
|
|
117
|
+
}
|
|
118
|
+
if (index === 0 || isProject(found)) {
|
|
119
|
+
assertChildAllowed(runtime, found, draft.kind, draft.type)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (draft.kind === WorkcardKind.Specification && parent == null) {
|
|
123
|
+
throw new PlanningError('malformed:specification-without-parent')
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
if (typeof exec.card !== 'string' || exec.card === '') {
|
|
127
|
+
throw new PlanningError(`malformed:${exec.action}-without-card`)
|
|
128
|
+
}
|
|
129
|
+
const found = await runtime.reader().cards.get(exec.card, entityId)
|
|
130
|
+
if (found == null) {
|
|
131
|
+
throw new WorkcardNotFound(exec.card)
|
|
132
|
+
}
|
|
133
|
+
if (found.entityId !== entityId) {
|
|
134
|
+
throw new PlanningScopeMismatch(exec.card)
|
|
135
|
+
}
|
|
136
|
+
card = found
|
|
137
|
+
type = schemas.type(card.type)
|
|
138
|
+
parent = card.parent != null ? await facade.cards.load(card.parent) ?? undefined : undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const flowId = exec.flow ?? primaryFlowOf(type)
|
|
142
|
+
if (!type.flows.includes(flowId)) {
|
|
143
|
+
throw new UnknownStatusFlow(`${type.type}:${flowId}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const kind = create ? (exec.card as WorkcardDraft).kind : card!.kind
|
|
147
|
+
const category = create ? (exec.card as WorkcardDraft).category : (card as Specification | undefined)?.category
|
|
148
|
+
const slot = kind === WorkcardKind.Specification && parent != null && category != null
|
|
149
|
+
? slotOf(schemas.type(parent.type), category)
|
|
150
|
+
: undefined
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
create,
|
|
154
|
+
...(card != null ? { card } : {}),
|
|
155
|
+
type,
|
|
156
|
+
flowId,
|
|
157
|
+
flow: schemas.flow(flowId),
|
|
158
|
+
...(parent != null ? { parent } : {}),
|
|
159
|
+
...(slot != null ? { slot } : {}),
|
|
160
|
+
store: service.store(type.type),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The project a transition is filed under: a project's own id, else its parent's project. */
|
|
165
|
+
export const projectFor = (resolved: Resolved, exec: TransitionExecution, cardId: string): string | undefined => {
|
|
166
|
+
if (resolved.card != null) {
|
|
167
|
+
return isProject(resolved.card) ? resolved.card.id : projectOf(resolved.card, resolved.parent)
|
|
168
|
+
}
|
|
169
|
+
const draft = exec.card as WorkcardDraft
|
|
170
|
+
if (draft.kind === WorkcardKind.Project) {
|
|
171
|
+
return cardId
|
|
172
|
+
}
|
|
173
|
+
return projectOf({ kind: draft.kind, parent: draft.parent, parents: draft.parents ?? [] } as unknown as Workcard, resolved.parent)
|
|
174
|
+
}
|