@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.
Files changed (174) hide show
  1. package/README.md +150 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/server-planning/SKILL.md +203 -0
  4. package/build/actions/cards.d.ts +16 -0
  5. package/build/actions/cards.d.ts.map +1 -0
  6. package/build/actions/cards.js +32 -0
  7. package/build/actions/cards.js.map +1 -0
  8. package/build/actions/commit.d.ts +14 -0
  9. package/build/actions/commit.d.ts.map +1 -0
  10. package/build/actions/commit.js +48 -0
  11. package/build/actions/commit.js.map +1 -0
  12. package/build/actions/execute.d.ts +13 -0
  13. package/build/actions/execute.d.ts.map +1 -0
  14. package/build/actions/execute.js +35 -0
  15. package/build/actions/execute.js.map +1 -0
  16. package/build/actions/index.d.ts +9 -0
  17. package/build/actions/index.d.ts.map +1 -0
  18. package/build/actions/index.js +9 -0
  19. package/build/actions/index.js.map +1 -0
  20. package/build/actions/links.d.ts +5 -0
  21. package/build/actions/links.d.ts.map +1 -0
  22. package/build/actions/links.js +8 -0
  23. package/build/actions/links.js.map +1 -0
  24. package/build/actions/schemas.d.ts +6 -0
  25. package/build/actions/schemas.d.ts.map +1 -0
  26. package/build/actions/schemas.js +9 -0
  27. package/build/actions/schemas.js.map +1 -0
  28. package/build/actions/specs.d.ts +10 -0
  29. package/build/actions/specs.d.ts.map +1 -0
  30. package/build/actions/specs.js +14 -0
  31. package/build/actions/specs.js.map +1 -0
  32. package/build/actions/transitions.d.ts +6 -0
  33. package/build/actions/transitions.d.ts.map +1 -0
  34. package/build/actions/transitions.js +8 -0
  35. package/build/actions/transitions.js.map +1 -0
  36. package/build/actions/watch.d.ts +12 -0
  37. package/build/actions/watch.d.ts.map +1 -0
  38. package/build/actions/watch.js +45 -0
  39. package/build/actions/watch.js.map +1 -0
  40. package/build/consts.d.ts +18 -0
  41. package/build/consts.d.ts.map +1 -0
  42. package/build/consts.js +19 -0
  43. package/build/consts.js.map +1 -0
  44. package/build/executor/changes.d.ts +31 -0
  45. package/build/executor/changes.d.ts.map +1 -0
  46. package/build/executor/changes.js +55 -0
  47. package/build/executor/changes.js.map +1 -0
  48. package/build/executor/code.d.ts +15 -0
  49. package/build/executor/code.d.ts.map +1 -0
  50. package/build/executor/code.js +59 -0
  51. package/build/executor/code.js.map +1 -0
  52. package/build/executor/receipt.d.ts +12 -0
  53. package/build/executor/receipt.d.ts.map +1 -0
  54. package/build/executor/receipt.js +56 -0
  55. package/build/executor/receipt.js.map +1 -0
  56. package/build/executor/resolve.d.ts +35 -0
  57. package/build/executor/resolve.d.ts.map +1 -0
  58. package/build/executor/resolve.js +136 -0
  59. package/build/executor/resolve.js.map +1 -0
  60. package/build/executor/validate.d.ts +13 -0
  61. package/build/executor/validate.d.ts.map +1 -0
  62. package/build/executor/validate.js +222 -0
  63. package/build/executor/validate.js.map +1 -0
  64. package/build/executor.d.ts +15 -0
  65. package/build/executor.d.ts.map +1 -0
  66. package/build/executor.js +113 -0
  67. package/build/executor.js.map +1 -0
  68. package/build/facade.d.ts +12 -0
  69. package/build/facade.d.ts.map +1 -0
  70. package/build/facade.js +131 -0
  71. package/build/facade.js.map +1 -0
  72. package/build/helper.d.ts +18 -0
  73. package/build/helper.d.ts.map +1 -0
  74. package/build/helper.js +32 -0
  75. package/build/helper.js.map +1 -0
  76. package/build/index.d.ts +13 -0
  77. package/build/index.d.ts.map +1 -0
  78. package/build/index.js +11 -0
  79. package/build/index.js.map +1 -0
  80. package/build/projection.d.ts +28 -0
  81. package/build/projection.d.ts.map +1 -0
  82. package/build/projection.js +40 -0
  83. package/build/projection.js.map +1 -0
  84. package/build/registry.d.ts +30 -0
  85. package/build/registry.d.ts.map +1 -0
  86. package/build/registry.js +103 -0
  87. package/build/registry.js.map +1 -0
  88. package/build/service.d.ts +40 -0
  89. package/build/service.d.ts.map +1 -0
  90. package/build/service.js +109 -0
  91. package/build/service.js.map +1 -0
  92. package/build/store/commits.d.ts +15 -0
  93. package/build/store/commits.d.ts.map +1 -0
  94. package/build/store/commits.js +147 -0
  95. package/build/store/commits.js.map +1 -0
  96. package/build/store/composite.d.ts +12 -0
  97. package/build/store/composite.d.ts.map +1 -0
  98. package/build/store/composite.js +150 -0
  99. package/build/store/composite.js.map +1 -0
  100. package/build/store/fold.d.ts +39 -0
  101. package/build/store/fold.d.ts.map +1 -0
  102. package/build/store/fold.js +220 -0
  103. package/build/store/fold.js.map +1 -0
  104. package/build/store/index.d.ts +6 -0
  105. package/build/store/index.d.ts.map +1 -0
  106. package/build/store/index.js +5 -0
  107. package/build/store/index.js.map +1 -0
  108. package/build/store/memory.d.ts +19 -0
  109. package/build/store/memory.d.ts.map +1 -0
  110. package/build/store/memory.js +0 -0
  111. package/build/store/memory.js.map +1 -0
  112. package/build/store/types.d.ts +82 -0
  113. package/build/store/types.d.ts.map +1 -0
  114. package/build/store/types.js +2 -0
  115. package/build/store/types.js.map +1 -0
  116. package/build/types.d.ts +46 -0
  117. package/build/types.d.ts.map +1 -0
  118. package/build/types.js +2 -0
  119. package/build/types.js.map +1 -0
  120. package/build/utils/guard.d.ts +19 -0
  121. package/build/utils/guard.d.ts.map +1 -0
  122. package/build/utils/guard.js +35 -0
  123. package/build/utils/guard.js.map +1 -0
  124. package/build/utils/index.d.ts +3 -0
  125. package/build/utils/index.d.ts.map +1 -0
  126. package/build/utils/index.js +3 -0
  127. package/build/utils/index.js.map +1 -0
  128. package/build/utils/scope.d.ts +20 -0
  129. package/build/utils/scope.d.ts.map +1 -0
  130. package/build/utils/scope.js +36 -0
  131. package/build/utils/scope.js.map +1 -0
  132. package/package.json +66 -0
  133. package/src/actions/cards.ts +52 -0
  134. package/src/actions/commit.ts +55 -0
  135. package/src/actions/execute.ts +47 -0
  136. package/src/actions/index.ts +8 -0
  137. package/src/actions/links.ts +14 -0
  138. package/src/actions/schemas.ts +15 -0
  139. package/src/actions/specs.ts +23 -0
  140. package/src/actions/transitions.ts +14 -0
  141. package/src/actions/watch.ts +52 -0
  142. package/src/consts.ts +22 -0
  143. package/src/executor/changes.ts +78 -0
  144. package/src/executor/code.ts +83 -0
  145. package/src/executor/receipt.ts +66 -0
  146. package/src/executor/resolve.ts +174 -0
  147. package/src/executor/validate.ts +253 -0
  148. package/src/executor.ts +149 -0
  149. package/src/facade.ts +162 -0
  150. package/src/helper.ts +44 -0
  151. package/src/index.ts +13 -0
  152. package/src/projection.ts +65 -0
  153. package/src/registry.ts +143 -0
  154. package/src/service.ts +157 -0
  155. package/src/store/commits.ts +174 -0
  156. package/src/store/composite.ts +171 -0
  157. package/src/store/fold.ts +257 -0
  158. package/src/store/index.ts +6 -0
  159. package/src/store/memory.ts +0 -0
  160. package/src/store/types.ts +94 -0
  161. package/src/types.ts +48 -0
  162. package/src/utils/guard.ts +40 -0
  163. package/src/utils/index.ts +2 -0
  164. package/src/utils/scope.ts +51 -0
  165. package/tests/commits.spec.ts +78 -0
  166. package/tests/context.ts +198 -0
  167. package/tests/entrypoints.spec.ts +72 -0
  168. package/tests/executor.spec.ts +69 -0
  169. package/tests/memory-store.spec.ts +84 -0
  170. package/tests/plugins.spec.ts +86 -0
  171. package/tests/specifications.spec.ts +61 -0
  172. package/tests/tsconfig.json +17 -0
  173. package/tests/validation.spec.ts +62 -0
  174. package/tsconfig.json +16 -0
@@ -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
+ }