Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add prototype tapable code (#4) #6

Merged
merged 1 commit into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dotenv",
"dupl",
"errcheck",
"Errorf",
"exportloopref",
"extendio",
"fieldalignment",
Expand Down Expand Up @@ -57,6 +58,7 @@
"structcheck",
"stylecheck",
"tapable",
"tapcore",
"Taskfile",
"thelper",
"tparallel",
Expand Down
7 changes: 7 additions & 0 deletions cycle/cycle-defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ package cycle

// eg beforeOptions
// afterOptions

type Role uint32

const (
RoleUndefined Role = iota
RoleDirectoryReader
)
19 changes: 13 additions & 6 deletions enums/traverse-en.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const (
SubscribeFiles // invoke callback for files only
)

// Role represents the role of an application entity (like a plugin role) The
// InternalRole represents the role of an application entity (like a plugin role) The
// key element of a role is that there should be just a single entity that can take up
// the role which is bound to a service.
//
Expand All @@ -22,13 +22,13 @@ const (
// to provide a particular service.
//
// The mediator knows about Roles and manage registration requests
type Role uint
type InternalRole uint

const (
RoleUndefined Role = iota
RoleLogger // WithLogger
RoleSampler // WithSampler (need a specific sampler interface)
RoleResume // this is not an option; so might not be a valid role
InternalRoleRoleUndefined InternalRole = iota
InternalRoleLogger // WithLogger
InternalRoleSampler // WithSampler (need a specific sampler interface)
InternalRoleResume // this is not an option; so might not be a valid role
)

// do we need to distinguish between internal and external entities. It looks
Expand All @@ -40,3 +40,10 @@ const (
//
// --> internal
// * resume

type Role uint32

const (
RoleUndefined Role = iota
RoleDirectoryReader
)
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/onsi/gomega v1.33.0
github.com/samber/lo v1.39.0
github.com/snivilised/extendio v0.6.1
golang.org/x/exp v0.0.0-20230321023759-10a507213a29
)

require (
Expand All @@ -18,7 +19,6 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/nicksnyder/go-i18n/v2 v2.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
Expand Down
127 changes: 127 additions & 0 deletions resources/doc/DESIGN-NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# 🪅 Design Notes

<!-- MD013/Line Length -->
<!-- MarkDownLint-disable MD013 -->

<!-- MD014/commands-show-output: Dollar signs used before commands without showing output mark down lint -->
<!-- MarkDownLint-disable MD014 -->

<!-- MD033/no-inline-html: Inline HTML -->
<!-- MarkDownLint-disable MD033 -->

<!-- MD040/fenced-code-language: Fenced code blocks should have a language specified -->
<!-- MarkDownLint-disable MD040 -->

<!-- MD028/no-blanks-blockquote: Blank line inside blockquote -->
<!-- MarkDownLint-disable MD028 -->

<!-- MD010/no-hard-tabs: Hard tabs -->
<!-- MarkDownLint-disable MD010 -->

## Tapable

Given an interface definition:

```go
type (
// Activity represents an entity that performs an action that is tapable. The type
// F should be a function signature, defined by the tapable, so F is not really
// any, but it can't be enforced to be a func, so do not try to instantiate with
// a type that is not a func.
Activity[F any] interface {
On(name string, handler F)
}

// Role is similar to Activity, but it allows the tapable entity to request
// that the client specify which specific behaviour needs to be tapped. This is
// useful when the entity has multi behaviours that clients may wish to tap.
// To enable a client to augment the core action, Tap returns a func of type F which
// represents the core behaviour. If required, the client should store this and then
// invoke it as appropriate when it is called back.
Role[F any, E constraints.Integer] interface {
Tap(name string, role E, fn F) F
}
)
```

a component can declare that it wants to expose a hook to enable external customisation of a behaviour. That piece of behaviour is the unit which become tab-able. So we have a Component, it's action and a Client that want to tap into this behaviour, by providing a hook.

```go
// Widget is some domain abstraction
type Widget struct {
Name string
Amount int
}

// FuncSimpleWithWidgetAndError is the action func; this function
// can be hooked/is tapable. In fact, this function can either enable core
// functionality to be overridden, decorated with auxiliary behaviour or
// simply be a life-cycle event; ie we have come to a significant point in
// the component's workflow and the some external entity's function needs
// to be invoked.
type FuncSimpleWithWidgetAndError func(name string, amount int) (*Widget, error)

type ActionHook struct {
name string
action FuncSimpleWithWidgetAndError
}

// Tap invoked by client to enable registration of the hook
func (h *ActionHook) Tap(_ string, fn FuncSimpleWithWidgetAndError) {
h.action = fn
}

// Component contains tapable behaviour
type Component struct {
hook ActionHook
}

func (c *Component) DoWork() {
if _, err := c.hook.action("work", 0); err != nil {
panic(fmt.Errorf("work failed: '%v'", err))
}
}

type Client struct {
}

func (c *Client) WithComponent(component *Component) {
component.hook.Tap("client", func(name string, amount int) (*Widget, error) {
widget := &Widget{
Name: name,
Amount: amount,
}
return widget, nil
})
}

```

So we have 3 scenarios to consider: override core behaviour, supplement core behaviour or life cycle.

### Override

In extendio, we have the following hook-able actions:

```go
// TraverseHooks defines the suite of items that can be customised by the client
type TraverseHooks struct {
QueryStatus QueryStatusHookFn
ReadDirectory ReadDirectoryHookFn
FolderSubPath SubPathHookFn
FileSubPath SubPathHookFn
InitFilters FilterInitHookFn
Sort SortEntriesHookFn
Extend ExtendHookFn
}
```

These are all core functions that can be overridden by the client. Furthermore, only a single instance is invoked; ie for each one, just a single action is invoked, its neither a notification or broadcast mechanism.

It may also be pertinent to use the tapable.Role, with the roles being defined as QueryStatus, ReadDirectory, FolderSubPath, ... using a new enum definition. This would serve as an indication to the client that only 1 external entity is able to tap these hook-able actions.

### Supplement

This is a piggy-back on top of the Override scenario, where we would like to augment the core functionality. This then makes the hook, a synchronous notification mechanism, which allows the client to control how core functionality is invoked. The client may choose to invoke custom behaviour before/after the core behaviour or completely override it altogether. The client should be able to invoke the core behaviour, which implies that the Tap should return the default func for this action.

### Life Cycle
169 changes: 169 additions & 0 deletions tapable/legacy-defs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package tapable

import (
"fmt"

"golang.org/x/exp/constraints"
)

// tapable: enables entities to expose hooks

type (
// ActivityL represents an entity that performs an action that is tapable. The type
// F should be a function signature, defined by the tapable, so F is not really
// any, but it can't be enforced to be a func, so do not try to instantiate with
// a type that is not a func.
// To enable a client to augment the core action, Tap returns a func of type F which
// represents the core behaviour. If required, the client should store this and then
// invoke it as appropriate when it is called back.
ActivityL[F any] interface {
Tap(name string, fn F) (F, error)
}

// RoleL is similar to Activity, but it allows the tapable entity to request
// that the client specify which specific behaviour needs to be tapped. This is
// useful when the entity has multiple behaviours that clients may wish to tap.
RoleL[F any, E constraints.Integer] interface {
Tap(name string, role E, fn F) error
}

// NotifyL used for life cycle events. There can be multiple subscribers to
// life cycle events
NotifyL[F any, E constraints.Integer] interface {
On(name string, event E, handler F) error
}
)

// we should have MonoHook (1 client allowed);
// or MultiHook (multiple clients allowed)

// scratch ...

// type FuncSimpleWithError func() error

// type SimpleHookFunc Activity[FuncSimpleWithError]

// Widget is some domain abstraction
type Widget struct {
Name string
Amount int
}

// FuncSimpleWithWidgetAndError is the action func; invoke this function
// that can be hooked/tapable. In fact, this function can either enable core
// functionality to be overridden, decorated with auxiliary behaviour or
// simply as a life-cycle event; ie we have come to a significant point in
// the component's workflow and the some external entity's function needs
// to be invoked.
type FuncSimpleWithWidgetAndError func(name string, amount int) (*Widget, error)

type ActionHook struct {
name string
action FuncSimpleWithWidgetAndError
}

// Tap invoked by client to enable registration of the hook
func (h *ActionHook) Tap(_ string, fn FuncSimpleWithWidgetAndError) {
h.action = fn
}

type NotificationHook struct {
name string
action FuncSimpleWithWidgetAndError
}

// Tap invoked by client to enable registration of the hook
func (h *NotificationHook) On(_ string, fn FuncSimpleWithWidgetAndError) {
h.action = fn
}

// Component contains tapable behaviour
type Component struct {
hook ActionHook
}

func (c *Component) DoWork() {
if _, err := c.hook.action("work", 0); err != nil {
panic(fmt.Errorf("work failed: '%v'", err))
}
}

type Client struct {
}

func (c *Client) WithComponent(from *Component) {
from.hook.Tap("client", func(name string, amount int) (*Widget, error) {
widget := &Widget{
Name: name,
Amount: amount,
}
return widget, nil
})
}

// Receiver contains tapable behaviour received as a notification
type Receiver struct {
hook NotificationHook
}

func (r *Receiver) When(from *Component) {
from.hook.Tap("client", func(name string, amount int) (*Widget, error) {
widget := &Widget{
Name: name,
Amount: amount,
}
return widget, nil
})
}

// The above is still confused. Let's start again we have these scenarios:
//
// --> internal (during bootstrap): ==> actually, this is di, not tap
// * 1 component needs to custom another
//
// --> external (via options):
// * notification of life-cycle events (broadcast) | [On/Notify] (eg, OnBegin/On(enums.cycle.begin))
// * customise core behaviour, by role (targeted) | [Tap/Role] (eg, ReadDirectory, role=directory-reader)
// *

// Since we now have finer grain control; ie there are more but smaller packages
// organised as features, each feature can expose its own set of hooks. Having
// said this, I can still only think of nav as needing to expose hooks, but others
// may emerge.
//
// For a component, we have the following situations
// - broadcast, multiple callbacks
// - targeted, single callback
//
// - may expose multiple hooks with different signatures
// the problem this poses is that we can't have a collection of different
// items. This means we need to define a hook container struct that contains the hooks.
// The component aggregates this hook container with a member called hooks.
// For example, in extendio, the options contains a hooks struct TraverseHooks:
//
// type TraverseHooks struct {
// QueryStatus QueryStatusHookFn
// ReadDirectory ReadDirectoryHookFn
// FolderSubPath SubPathHookFn
// FileSubPath SubPathHookFn
// InitFilters FilterInitHookFn
// Sort SortEntriesHookFn
// Extend ExtendHookFn
// }
//
// But we need to ba able to tap these,
//
// if hooks is of type TraverseHooks, in object Component
// component.hooks.ReadDirectory.tap("name", hookFn)
// therefore ReadDirectoryHookFn, can't be the function, there must be a
// level of indirection in-between,
//
// in TraverseHooks, ReadDirectory must be of type ReadDirectoryHook,
// which is an instantiation of a generic type:
// HookFunc[F any], where HookFunc contains the Tap function
//
// type ReadDirectoryHook tapable.HookFunc[ReadDirectoryHookFn]
//
// type TraverseHooks struct {
// ReadDirectory ReadDirectoryHook
// }
Loading
Loading