state.ts 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. export * as State from "./state"
  2. import { Effect, Scope, Semaphore } from "effect"
  3. import type { Draft, Objectish } from "immer"
  4. /**
  5. * A replayable contribution applied to an editor during rebuild.
  6. *
  7. * Transforms are intentionally synchronous and mutation-shaped: domain editors
  8. * hide the draft representation while preserving concise plugin/config code.
  9. */
  10. export type Transform<Editor> = (editor: Editor) => void
  11. export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
  12. export interface Options<State extends Objectish, Editor> {
  13. /** Creates the base value for initial state and every scoped-transform rebuild. */
  14. readonly initial: () => State
  15. /** Wraps the mutable draft in a domain-specific editor. */
  16. readonly editor: MakeEditor<State, Editor>
  17. /**
  18. * Completes every committed edit.
  19. *
  20. * For rebuilds, this runs after all active transforms have been replayed and
  21. * before the rebuilt state becomes visible. For direct updates, this runs
  22. * after the current state has already been edited. The optional reason is
  23. * caller-defined metadata for exceptional update origins.
  24. */
  25. readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect<void>
  26. }
  27. export interface Interface<State extends Objectish, Editor> {
  28. readonly get: () => State
  29. /**
  30. * Registers a scoped transform slot and returns the slot updater.
  31. *
  32. * Acquiring the slot has no visible effect until the returned updater is
  33. * called. Each updater call replaces that slot's transform, then rebuilds the
  34. * materialized state from `initial()` by replaying all active transforms in
  35. * registration order. Closing the owning Scope removes the slot and rebuilds.
  36. */
  37. readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
  38. /**
  39. * Mutates the current materialized state directly.
  40. *
  41. * This is not replayable contribution state: a later rebuild starts again
  42. * from `initial()` plus active transforms, so direct edits must be reserved
  43. * for current-state adjustments that are intentionally outside the transform
  44. * fold.
  45. */
  46. readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
  47. }
  48. export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
  49. let state = options.initial()
  50. let transforms: { update: Transform<Editor> }[] = []
  51. const semaphore = Semaphore.makeUnsafe(1)
  52. const commit = Effect.fn("State.commit")(function* (next: State, reason?: string) {
  53. const api = options.editor(next as Draft<State>)
  54. if (options.finalize) yield* options.finalize(api, reason)
  55. state = next
  56. })
  57. const rebuild = Effect.fn("State.rebuild")(function* () {
  58. const next = options.initial()
  59. const api = options.editor(next as Draft<State>)
  60. for (const transform of transforms)
  61. yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
  62. yield* commit(next)
  63. }, semaphore.withPermit)
  64. return {
  65. get: () => state,
  66. transform: Effect.fn("State.transform")(function* () {
  67. const transform = { update: (_editor: Editor) => {} }
  68. transforms = [...transforms, transform]
  69. const scope = yield* Scope.Scope
  70. yield* Scope.addFinalizer(
  71. scope,
  72. Effect.sync(() => {
  73. transforms = transforms.filter((item) => item !== transform)
  74. }).pipe(Effect.andThen(rebuild())),
  75. )
  76. return Effect.fnUntraced(function* (update: Transform<Editor>) {
  77. transform.update = update
  78. yield* rebuild()
  79. })
  80. }),
  81. update: Effect.fn("State.update")(function* (update, reason) {
  82. const api = options.editor(state as Draft<State>)
  83. yield* update(api)
  84. if (options.finalize) yield* options.finalize(api, reason)
  85. }, semaphore.withPermit),
  86. }
  87. }