@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
package/src/registry.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { ResilientError } from '@owlmeans/error'
|
|
3
|
+
import { DEFAULT_PLUGIN_ORDER, PlanningRefused } from '@owlmeans/planning'
|
|
4
|
+
import type {
|
|
5
|
+
CommitEvent, PlanningExecContext, PlanningHookContext, PlanningPlugin, PlanningSchemaRegistry,
|
|
6
|
+
PlanningStore, TransitionExecution, WorkcardDraft,
|
|
7
|
+
} from '@owlmeans/planning'
|
|
8
|
+
import type { StoreRoute } from './store/types.js'
|
|
9
|
+
|
|
10
|
+
export interface PluginRegistry {
|
|
11
|
+
/** Register, or replace the plugin of the same `name`. Its schemas are contributed now. */
|
|
12
|
+
use: (plugin: PlanningPlugin) => void
|
|
13
|
+
/** Every plugin, `order` ascending (registration order among equals). */
|
|
14
|
+
plugins: () => PlanningPlugin[]
|
|
15
|
+
/** The owning plugins that supply a store, in plugin order, each store resolved once. */
|
|
16
|
+
routes: (ctx?: BasicContext<BasicConfig>) => StoreRoute[]
|
|
17
|
+
/** The first owning plugin's store for a type, else `fallback`. */
|
|
18
|
+
storeFor: (type: string | undefined, fallback: PlanningStore, ctx?: BasicContext<BasicConfig>) => PlanningStore
|
|
19
|
+
/** The first plugin answering a code wins. */
|
|
20
|
+
mintCode: (
|
|
21
|
+
draft: WorkcardDraft, taken: (code: string) => Promise<boolean>, contextOf: (plugin: PlanningPlugin) => PlanningExecContext
|
|
22
|
+
) => Promise<string | undefined>
|
|
23
|
+
/** The `before` chain; answers the execution the chain left. */
|
|
24
|
+
before: (
|
|
25
|
+
exec: TransitionExecution, contextOf: (plugin: PlanningPlugin) => PlanningExecContext
|
|
26
|
+
) => Promise<TransitionExecution>
|
|
27
|
+
/** The `after` chain; a failing hook is logged and the chain goes on. */
|
|
28
|
+
after: (event: CommitEvent, contextOf: (plugin: PlanningPlugin) => PlanningHookContext) => Promise<void>
|
|
29
|
+
/** Bumps on every `use` — what a cached composite store is keyed by. */
|
|
30
|
+
version: () => number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const messageOf = (error: unknown): string => error instanceof Error ? error.message : `${error}`
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The plugin chain of one planning service.
|
|
37
|
+
*
|
|
38
|
+
* Ordering is `order ?? DEFAULT_PLUGIN_ORDER` ascending, lower first; a plugin registered under a
|
|
39
|
+
* name already present replaces it in place. A store factory is resolved once per plugin object and
|
|
40
|
+
* handed to `onStore` (the service binds its `committed` there).
|
|
41
|
+
*/
|
|
42
|
+
export const makePluginRegistry = (
|
|
43
|
+
schemas: PlanningSchemaRegistry, onStore?: (store: PlanningStore) => void
|
|
44
|
+
): PluginRegistry => {
|
|
45
|
+
const registered: PlanningPlugin[] = []
|
|
46
|
+
const stores = new WeakMap<PlanningPlugin, PlanningStore>()
|
|
47
|
+
let version = 0
|
|
48
|
+
|
|
49
|
+
const ordered = (): PlanningPlugin[] => registered
|
|
50
|
+
.map((plugin, index) => ({ plugin, index }))
|
|
51
|
+
.sort((left, right) => (left.plugin.order ?? DEFAULT_PLUGIN_ORDER) - (right.plugin.order ?? DEFAULT_PLUGIN_ORDER)
|
|
52
|
+
|| left.index - right.index)
|
|
53
|
+
.map(entry => entry.plugin)
|
|
54
|
+
|
|
55
|
+
const storeOf = (plugin: PlanningPlugin, ctx?: BasicContext<BasicConfig>): PlanningStore | undefined => {
|
|
56
|
+
if (plugin.store == null) {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
let store = stores.get(plugin)
|
|
60
|
+
if (store == null) {
|
|
61
|
+
store = typeof plugin.store === 'function' ? plugin.store(ctx as BasicContext<BasicConfig>) : plugin.store
|
|
62
|
+
stores.set(plugin, store)
|
|
63
|
+
onStore?.(store)
|
|
64
|
+
}
|
|
65
|
+
return store
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const registry: PluginRegistry = {
|
|
69
|
+
use: plugin => {
|
|
70
|
+
const at = registered.findIndex(entry => entry.name === plugin.name)
|
|
71
|
+
if (at < 0) {
|
|
72
|
+
registered.push(plugin)
|
|
73
|
+
} else {
|
|
74
|
+
registered[at] = plugin
|
|
75
|
+
}
|
|
76
|
+
plugin.schemas?.flows?.forEach(schemas.registerFlow)
|
|
77
|
+
plugin.schemas?.types?.forEach(schemas.registerType)
|
|
78
|
+
version++
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
plugins: ordered,
|
|
82
|
+
|
|
83
|
+
routes: ctx => ordered()
|
|
84
|
+
.filter(plugin => plugin.owns != null && plugin.store != null)
|
|
85
|
+
.map(plugin => ({ owns: plugin.owns!, store: storeOf(plugin, ctx)! })),
|
|
86
|
+
|
|
87
|
+
storeFor: (type, fallback, ctx) => {
|
|
88
|
+
if (type == null) {
|
|
89
|
+
return fallback
|
|
90
|
+
}
|
|
91
|
+
const owner = ordered().find(plugin => plugin.store != null && plugin.owns?.(type) === true)
|
|
92
|
+
return owner == null ? fallback : storeOf(owner, ctx)!
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
mintCode: async (draft, taken, contextOf) => {
|
|
96
|
+
for (const plugin of ordered()) {
|
|
97
|
+
if (plugin.mintCode == null) {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const code = await plugin.mintCode(draft, taken, contextOf(plugin))
|
|
101
|
+
if (code != null) {
|
|
102
|
+
return code
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
before: async (exec, contextOf) => {
|
|
109
|
+
let current = exec
|
|
110
|
+
for (const plugin of ordered()) {
|
|
111
|
+
if (plugin.before == null) {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
current = await plugin.before(current, contextOf(plugin)) ?? current
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof ResilientError) {
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
throw new PlanningRefused(`${plugin.name}:${messageOf(error)}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return current
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
after: async (event, contextOf) => {
|
|
127
|
+
for (const plugin of ordered()) {
|
|
128
|
+
if (plugin.after == null) {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await plugin.after(event, contextOf(plugin))
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(`planning: after hook of ${plugin.name} failed on ${event.transition}:`, error)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
version: () => version,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return registry
|
|
143
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createLazyService } from '@owlmeans/context'
|
|
2
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
3
|
+
import { makeSchemaRegistry, PlanningError } from '@owlmeans/planning'
|
|
4
|
+
import type { PlanningService, PlanningStore, Transition, WithPlanningService } from '@owlmeans/planning'
|
|
5
|
+
import { DEFAULT_ALIAS } from './consts.js'
|
|
6
|
+
import { makeStoreFacade } from './facade.js'
|
|
7
|
+
import { makePluginRegistry } from './registry.js'
|
|
8
|
+
import type { PluginRegistry } from './registry.js'
|
|
9
|
+
import { makeCompositeStore } from './store/composite.js'
|
|
10
|
+
import { makeMemoryPlanningStore } from './store/memory.js'
|
|
11
|
+
import type { BindablePlanningStore } from './store/types.js'
|
|
12
|
+
import type { PlanningHostService, PlanningServiceOptions } from './types.js'
|
|
13
|
+
|
|
14
|
+
export type PlanningServiceApi = Pick<PlanningService, 'use' | 'plugins' | 'schemas' | 'store' | 'for' | 'committed'>
|
|
15
|
+
|
|
16
|
+
/** What the facade and the executor reach the service through. */
|
|
17
|
+
export interface PlanningRuntime {
|
|
18
|
+
service: () => PlanningService
|
|
19
|
+
registry: PluginRegistry
|
|
20
|
+
options: PlanningServiceOptions
|
|
21
|
+
context: () => BasicContext<BasicConfig> | undefined
|
|
22
|
+
/** The composite store reads and commits go through. */
|
|
23
|
+
reader: () => PlanningStore
|
|
24
|
+
/** Every distinct store: the default one first, then each plugin's. */
|
|
25
|
+
stores: () => PlanningStore[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const contextOf = (service: PlanningService): BasicContext<BasicConfig> | undefined =>
|
|
29
|
+
(service as Partial<PlanningHostService>).ctx as BasicContext<BasicConfig> | undefined
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The service body, without context registration — spread it into a specialised service; `self`
|
|
33
|
+
* is late-bound for exactly that case.
|
|
34
|
+
*
|
|
35
|
+
* A store receives the service's `committed` through its `bind` the first time the service resolves
|
|
36
|
+
* it, so the store that folds runs the `after` chain in its own process.
|
|
37
|
+
*/
|
|
38
|
+
export const planningServiceApi = (
|
|
39
|
+
options: PlanningServiceOptions, self: () => PlanningService
|
|
40
|
+
): PlanningServiceApi & { runtime: PlanningRuntime } => {
|
|
41
|
+
const schemas = makeSchemaRegistry(options.schemas)
|
|
42
|
+
const bound = new WeakSet<PlanningStore>()
|
|
43
|
+
const bind = (store: PlanningStore): void => {
|
|
44
|
+
if (bound.has(store)) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
bound.add(store)
|
|
48
|
+
;(store as BindablePlanningStore).bind?.(async event => { await self().committed(event) })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const registry = makePluginRegistry(schemas, bind)
|
|
52
|
+
const fallback = options.store ?? makeMemoryPlanningStore({ ids: options.ids, now: options.now })
|
|
53
|
+
bind(fallback)
|
|
54
|
+
|
|
55
|
+
let reader: { version: number, store: PlanningStore } | undefined
|
|
56
|
+
|
|
57
|
+
const runtime: PlanningRuntime = {
|
|
58
|
+
service: self,
|
|
59
|
+
registry,
|
|
60
|
+
options,
|
|
61
|
+
context: () => contextOf(self()),
|
|
62
|
+
reader: () => {
|
|
63
|
+
if (reader == null || reader.version !== registry.version()) {
|
|
64
|
+
reader = { version: registry.version(), store: makeCompositeStore(registry.routes(runtime.context()), fallback) }
|
|
65
|
+
}
|
|
66
|
+
return reader.store
|
|
67
|
+
},
|
|
68
|
+
stores: () => [...new Set([fallback, ...registry.routes(runtime.context()).map(route => route.store)])],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
options.plugins?.forEach(registry.use)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
runtime,
|
|
75
|
+
|
|
76
|
+
schemas,
|
|
77
|
+
|
|
78
|
+
use: plugin => registry.use(plugin),
|
|
79
|
+
|
|
80
|
+
plugins: () => registry.plugins(),
|
|
81
|
+
|
|
82
|
+
store: type => registry.storeFor(type, fallback, runtime.context()),
|
|
83
|
+
|
|
84
|
+
for: scope => {
|
|
85
|
+
if (scope?.entityId == null || scope.entityId === '') {
|
|
86
|
+
throw new PlanningError('malformed:scope-without-entity')
|
|
87
|
+
}
|
|
88
|
+
return makeStoreFacade(runtime, scope)
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
committed: async event => {
|
|
92
|
+
if (options.hooks === false || !registry.plugins().some(plugin => plugin.after != null)) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const store = self().store(event.type)
|
|
96
|
+
let transition: Transition | undefined
|
|
97
|
+
try {
|
|
98
|
+
transition = await store.transitions?.get(event.transition) ?? undefined
|
|
99
|
+
} catch {
|
|
100
|
+
transition = undefined
|
|
101
|
+
}
|
|
102
|
+
const context = runtime.context() as BasicContext<BasicConfig>
|
|
103
|
+
// The hook acts as this process's service, never as whoever wrote the transition.
|
|
104
|
+
const facade = self().for({
|
|
105
|
+
entityId: event.entityId,
|
|
106
|
+
...(context?.cfg?.service != null ? { service: context.cfg.service } : {}),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
await registry.after(event, plugin => ({
|
|
110
|
+
context, schemas, store, plugin, facade, ...(transition != null ? { transition } : {}),
|
|
111
|
+
}))
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const makePlanningService = (
|
|
117
|
+
options: PlanningServiceOptions = {}, alias: string = DEFAULT_ALIAS
|
|
118
|
+
): PlanningHostService => {
|
|
119
|
+
const service: PlanningHostService = createLazyService<PlanningHostService>(
|
|
120
|
+
alias, planningServiceApi(options, () => service) as unknown as Partial<PlanningHostService>
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return service
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Register the planning service and `context.planning()`.
|
|
128
|
+
*
|
|
129
|
+
* The default store's commits and every plugin store's run this service's `after` chain in the
|
|
130
|
+
* process that folds them; `hooks: false` makes this process fold without running it.
|
|
131
|
+
*/
|
|
132
|
+
export const appendPlanningService = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
133
|
+
ctx: T, options: PlanningServiceOptions = {}, alias: string = DEFAULT_ALIAS
|
|
134
|
+
): T & WithPlanningService => {
|
|
135
|
+
const context = ctx as T & WithPlanningService
|
|
136
|
+
|
|
137
|
+
context.registerService(makePlanningService(options, alias))
|
|
138
|
+
context.planning = () => context.service<PlanningHostService>(alias)
|
|
139
|
+
|
|
140
|
+
return context
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The planning service on a context, registering one with defaults when there is none — the
|
|
145
|
+
* `ensureRouterService` shape. A plugin package calls it before `use`, from `makeContext`.
|
|
146
|
+
*/
|
|
147
|
+
export const ensurePlanningService = (
|
|
148
|
+
ctx: BasicContext<any>, alias: string = DEFAULT_ALIAS
|
|
149
|
+
): PlanningService => {
|
|
150
|
+
if (!ctx.hasService(alias)) {
|
|
151
|
+
appendPlanningService(ctx, {}, alias)
|
|
152
|
+
} else if ((ctx as Partial<WithPlanningService>).planning == null && alias === DEFAULT_ALIAS) {
|
|
153
|
+
(ctx as unknown as WithPlanningService).planning = () => ctx.service<PlanningHostService>(alias)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return ctx.service<PlanningHostService>(alias)
|
|
157
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { CommitFailed, CommitState, CommitTimeout, DEFAULT_COMMIT_TIMEOUT, WorkcardNotFound } from '@owlmeans/planning'
|
|
2
|
+
import type { CommitEvent, CommitFilter, CommitStatus, Unsubscribe, Workcard } from '@owlmeans/planning'
|
|
3
|
+
import { COMMIT_POLL_LADDER, DEFAULT_COMMIT_MEMORY } from '../consts.js'
|
|
4
|
+
import type { CommitHub, CommitHubOptions } from './types.js'
|
|
5
|
+
|
|
6
|
+
type Listener = (event: CommitEvent) => void | Promise<void>
|
|
7
|
+
|
|
8
|
+
const matches = (event: CommitEvent, filter?: CommitFilter): boolean =>
|
|
9
|
+
filter == null || (
|
|
10
|
+
(filter.entityId == null || filter.entityId === event.entityId)
|
|
11
|
+
&& (filter.project == null || filter.project === event.project)
|
|
12
|
+
&& (filter.card == null || filter.card === event.card)
|
|
13
|
+
&& (filter.kind == null || filter.kind === event.kind)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const settled = (state: CommitState): boolean => state !== CommitState.Pending
|
|
17
|
+
|
|
18
|
+
const failureOf = (transition: string, error?: string): CommitFailed =>
|
|
19
|
+
new CommitFailed(`${transition}:${error ?? 'unknown'}`)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An in-process commit fan-out with a status lookup.
|
|
23
|
+
*
|
|
24
|
+
* It is the `CommitSource` a store answers with: the memory store publishes into it from its fold,
|
|
25
|
+
* and a durable store feeds it from its own bus (a Redis subscription) while answering `status`
|
|
26
|
+
* from its transition rows. It never decides anything about a commit — it only delivers events and
|
|
27
|
+
* waits for them.
|
|
28
|
+
*
|
|
29
|
+
* `wait` subscribes FIRST and polls second, so a commit that lands between the two is never
|
|
30
|
+
* missed; after one immediate poll it climbs `ladder`, which is what answers when the fold
|
|
31
|
+
* published somewhere this hub does not hear.
|
|
32
|
+
*/
|
|
33
|
+
export const makeCommitHub = (options: CommitHubOptions): CommitHub => {
|
|
34
|
+
const listeners = new Set<{ listener: Listener, filter?: CommitFilter }>()
|
|
35
|
+
const memory = new Map<string, CommitEvent>()
|
|
36
|
+
const remember = options.remember ?? DEFAULT_COMMIT_MEMORY
|
|
37
|
+
const ladder = options.ladder != null && options.ladder.length > 0 ? options.ladder : COMMIT_POLL_LADDER
|
|
38
|
+
|
|
39
|
+
const remembered = (event: CommitEvent): void => {
|
|
40
|
+
if (!settled(event.state) || remember <= 0) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
memory.delete(event.transition)
|
|
44
|
+
memory.set(event.transition, event)
|
|
45
|
+
while (memory.size > remember) {
|
|
46
|
+
const oldest = memory.keys().next().value
|
|
47
|
+
if (oldest == null) {
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
memory.delete(oldest)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hub: CommitHub = {
|
|
55
|
+
publish: async event => {
|
|
56
|
+
remembered(event)
|
|
57
|
+
const targets = [...listeners].filter(entry => matches(event, entry.filter))
|
|
58
|
+
const results = await Promise.allSettled(targets.map(async entry => await entry.listener(event)))
|
|
59
|
+
results.forEach(result => {
|
|
60
|
+
if (result.status === 'rejected') {
|
|
61
|
+
console.error('planning: commit listener failed:', result.reason)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
recall: transition => memory.get(transition),
|
|
67
|
+
|
|
68
|
+
listeners: () => listeners.size,
|
|
69
|
+
|
|
70
|
+
subscribe: (listener, filter) => {
|
|
71
|
+
const entry = { listener, filter }
|
|
72
|
+
listeners.add(entry)
|
|
73
|
+
|
|
74
|
+
const unsubscribe: Unsubscribe = () => { listeners.delete(entry) }
|
|
75
|
+
return unsubscribe
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
status: async transition => {
|
|
79
|
+
const found = await options.status(transition)
|
|
80
|
+
if (found != null) {
|
|
81
|
+
return found
|
|
82
|
+
}
|
|
83
|
+
const event = memory.get(transition)
|
|
84
|
+
if (event != null) {
|
|
85
|
+
const status: CommitStatus = { transition, state: event.state, at: event.at }
|
|
86
|
+
if (event.error != null) {
|
|
87
|
+
status.error = event.error
|
|
88
|
+
}
|
|
89
|
+
if (event.record !== undefined) {
|
|
90
|
+
status.card = event.record
|
|
91
|
+
}
|
|
92
|
+
return status
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new WorkcardNotFound(`transition:${transition}`)
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
wait: async (transition, opts) => {
|
|
99
|
+
const timeout = opts?.timeout ?? DEFAULT_COMMIT_TIMEOUT
|
|
100
|
+
|
|
101
|
+
return await new Promise<Workcard | null>((resolve, reject) => {
|
|
102
|
+
let done = false
|
|
103
|
+
let step = 0
|
|
104
|
+
let poller: ReturnType<typeof setTimeout> | undefined
|
|
105
|
+
let unsubscribe: Unsubscribe = () => undefined
|
|
106
|
+
|
|
107
|
+
const finish = (settle: () => void): void => {
|
|
108
|
+
if (done) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
done = true
|
|
112
|
+
clearTimeout(deadline)
|
|
113
|
+
clearTimeout(poller)
|
|
114
|
+
unsubscribe()
|
|
115
|
+
settle()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const committedCard = async (record?: Workcard | null): Promise<Workcard | null> => {
|
|
119
|
+
if (record !== undefined) {
|
|
120
|
+
return record
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return (await hub.status(transition)).card ?? null
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const answer = async (state: CommitState, error?: string, record?: Workcard | null): Promise<boolean> => {
|
|
130
|
+
if (state === CommitState.Committed) {
|
|
131
|
+
const card = await committedCard(record)
|
|
132
|
+
finish(() => resolve(card))
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
if (state === CommitState.Failed) {
|
|
136
|
+
finish(() => reject(failureOf(transition, error)))
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const deadline = setTimeout(() => finish(() => reject(new CommitTimeout(transition))), timeout)
|
|
143
|
+
|
|
144
|
+
unsubscribe = hub.subscribe(async event => {
|
|
145
|
+
if (event.transition === transition) {
|
|
146
|
+
await answer(event.state, event.error, event.record)
|
|
147
|
+
}
|
|
148
|
+
}) as Unsubscribe
|
|
149
|
+
|
|
150
|
+
const poll = async (): Promise<void> => {
|
|
151
|
+
if (done) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const status = await hub.status(transition)
|
|
156
|
+
if (await answer(status.state, status.error, status.card)) {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
finish(() => reject(error))
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (!done) {
|
|
164
|
+
poller = setTimeout(() => { void poll() }, ladder[Math.min(step++, ladder.length - 1)])
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
void poll()
|
|
169
|
+
})
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return hub
|
|
174
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { WorkcardNotFound } from '@owlmeans/planning'
|
|
2
|
+
import type {
|
|
3
|
+
CommitSource, PlanningStore, ProjectionStore, Relationship, RelationshipStore, SpecificationStore,
|
|
4
|
+
TransitionStore, Unsubscribe, Workcard,
|
|
5
|
+
} from '@owlmeans/planning'
|
|
6
|
+
import { applyQuery } from '@owlmeans/resource'
|
|
7
|
+
import type { Criteria, ListResult } from '@owlmeans/resource'
|
|
8
|
+
import type { CommitHub, StoreRoute } from './types.js'
|
|
9
|
+
|
|
10
|
+
const firstOf = async <S, T>(
|
|
11
|
+
stores: S[], read: (store: S) => Promise<T | null | undefined> | undefined
|
|
12
|
+
): Promise<T | null> => {
|
|
13
|
+
for (const store of stores) {
|
|
14
|
+
const found = await read(store)
|
|
15
|
+
if (found != null) {
|
|
16
|
+
return found
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sum = async <S>(stores: S[], run: (store: S) => Promise<number>): Promise<number> =>
|
|
23
|
+
(await Promise.all(stores.map(run))).reduce((total, count) => total + count, 0)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One `PlanningStore` over a default store and the stores plugins OWN.
|
|
27
|
+
*
|
|
28
|
+
* Writes that name a type go to the owning store; a read by id asks the default store first and
|
|
29
|
+
* then each owner; a list whose criteria names one owned type (or several owned by one store) goes
|
|
30
|
+
* there, and every other list is the default store's — a cross-type query is answered by the
|
|
31
|
+
* default store alone, never merged. Relationship lists and commit subscriptions span every store.
|
|
32
|
+
*/
|
|
33
|
+
export const makeCompositeStore = (routes: StoreRoute[], fallback: PlanningStore): PlanningStore => {
|
|
34
|
+
const all = [...new Set([fallback, ...routes.map(route => route.store)])]
|
|
35
|
+
if (all.length === 1) {
|
|
36
|
+
return fallback
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const byType = (type?: string): PlanningStore =>
|
|
40
|
+
type == null ? fallback : routes.find(route => route.owns(type))?.store ?? fallback
|
|
41
|
+
|
|
42
|
+
const byCriteria = (where?: Criteria<Workcard>): PlanningStore => {
|
|
43
|
+
const type = (where as Record<string, unknown> | undefined)?.type
|
|
44
|
+
if (typeof type === 'string') {
|
|
45
|
+
return byType(type)
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(type) && type.length > 0) {
|
|
48
|
+
const targets = new Set(type.map(entry => byType(`${entry}`)))
|
|
49
|
+
return targets.size === 1 ? [...targets][0] : fallback
|
|
50
|
+
}
|
|
51
|
+
return fallback
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const logged = all.filter(store => store.transitions != null)
|
|
55
|
+
const byCard = async (card: string): Promise<PlanningStore> => {
|
|
56
|
+
for (const store of logged) {
|
|
57
|
+
if (await store.transitions!.head(card) > 0) {
|
|
58
|
+
return store
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const cards: ProjectionStore = {
|
|
65
|
+
get: (id, entityId) => firstOf(all, store => store.cards.get(id, entityId)),
|
|
66
|
+
list: (where, opts) => byCriteria(where).cards.list(where, opts),
|
|
67
|
+
count: where => byCriteria(where).cards.count(where),
|
|
68
|
+
summary: (parents, where) => byCriteria(where).cards.summary(parents, where),
|
|
69
|
+
put: card => byType(card.type).cards.put(card),
|
|
70
|
+
drop: async (id, entityId) => { await Promise.all(all.map(store => store.cards.drop(id, entityId))) },
|
|
71
|
+
project: async (card, hint) => { await (await byCard(card)).cards.project(card, hint) },
|
|
72
|
+
purge: (project, entityId) => sum(all, store => store.cards.purge(project, entityId)),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const transitions: TransitionStore | undefined = logged.length === 0 ? undefined : {
|
|
76
|
+
append: transition => (byType(transition.type).transitions ?? fallback.transitions!).append(transition),
|
|
77
|
+
get: id => firstOf(logged, store => store.transitions!.get(id)),
|
|
78
|
+
byKey: (entityId, key) => firstOf(logged, store => store.transitions!.byKey(entityId, key)),
|
|
79
|
+
list: async (where, opts) => {
|
|
80
|
+
const store = typeof where.card === 'string' ? await byCard(where.card) : fallback
|
|
81
|
+
return await (store.transitions ?? logged[0].transitions!).list(where, opts)
|
|
82
|
+
},
|
|
83
|
+
nextSeq: async (card, expect) => (await byCard(card)).transitions!.nextSeq(card, expect),
|
|
84
|
+
head: async card => (await byCard(card)).transitions?.head(card) ?? 0,
|
|
85
|
+
commit: async (id, commit) => {
|
|
86
|
+
for (const store of logged) {
|
|
87
|
+
if (await store.transitions!.get(id) != null) {
|
|
88
|
+
await store.transitions!.commit(id, commit)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
purge: where => sum(logged, store => store.transitions!.purge(where)),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const specified = all.filter(store => store.specs != null)
|
|
97
|
+
const specs: SpecificationStore | undefined = specified.length === 0 ? undefined : {
|
|
98
|
+
current: (parent, category, entityId) => firstOf(specified, store => store.specs!.current(parent, category, entityId)),
|
|
99
|
+
list: async (parent, entityId, query) => {
|
|
100
|
+
for (const store of specified) {
|
|
101
|
+
const result = await store.specs!.list(parent, entityId, query)
|
|
102
|
+
if (result.total > 0) {
|
|
103
|
+
return result
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { items: [], total: 0 }
|
|
107
|
+
},
|
|
108
|
+
revisions: async (id, entityId, limit) => {
|
|
109
|
+
for (const store of specified) {
|
|
110
|
+
const result = await store.specs!.revisions(id, entityId, limit)
|
|
111
|
+
if (result.length > 0) {
|
|
112
|
+
return result
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return []
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const linked = all.filter(store => store.links != null)
|
|
120
|
+
const links: RelationshipStore | undefined = linked.length === 0 ? undefined : {
|
|
121
|
+
list: async (where, opts) => {
|
|
122
|
+
const results = await Promise.all(linked.map(store => store.links!.list(where, { size: 0 })))
|
|
123
|
+
const items = results.flatMap((result: ListResult<Relationship>) => result.items)
|
|
124
|
+
return applyQuery(items, undefined, opts)
|
|
125
|
+
},
|
|
126
|
+
put: link => (fallback.links ?? linked[0].links!).put(link),
|
|
127
|
+
drop: where => sum(linked, store => store.links!.drop(where)),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const committing = all.filter(store => store.commits != null)
|
|
131
|
+
const statusStore = async (transition: string): Promise<CommitSource> => {
|
|
132
|
+
for (const store of committing) {
|
|
133
|
+
try {
|
|
134
|
+
await store.commits!.status(transition)
|
|
135
|
+
return store.commits!
|
|
136
|
+
} catch {
|
|
137
|
+
// not this store's transition
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new WorkcardNotFound(`transition:${transition}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const commits: (CommitSource & Pick<CommitHub, 'recall'>) | undefined = committing.length === 0 ? undefined : {
|
|
144
|
+
status: async transition => await (await statusStore(transition)).status(transition),
|
|
145
|
+
wait: async (transition, opts) => await (await statusStore(transition)).wait(transition, opts),
|
|
146
|
+
subscribe: async (listener, filter) => {
|
|
147
|
+
const releases = await Promise.all(committing.map(store => store.commits!.subscribe(listener, filter)))
|
|
148
|
+
const unsubscribe: Unsubscribe = () => releases.forEach(release => release())
|
|
149
|
+
return unsubscribe
|
|
150
|
+
},
|
|
151
|
+
recall: transition => {
|
|
152
|
+
for (const store of committing) {
|
|
153
|
+
const event = (store.commits as Partial<CommitHub>).recall?.(transition)
|
|
154
|
+
if (event != null) {
|
|
155
|
+
return event
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
alias: `composite:${fallback.alias ?? 'default'}`,
|
|
164
|
+
newId: fallback.newId,
|
|
165
|
+
cards,
|
|
166
|
+
...(transitions != null ? { transitions } : {}),
|
|
167
|
+
...(specs != null ? { specs } : {}),
|
|
168
|
+
...(links != null ? { links } : {}),
|
|
169
|
+
...(commits != null ? { commits } : {}),
|
|
170
|
+
}
|
|
171
|
+
}
|