@@ -1,4 +1,185 @@
|
||||
import { Context, Effect, type Fiber, HashMap, Layer, type Option, Ref, type Scope } from "effect"
|
||||
import { type Cause, Context, DateTime, type Duration, Effect, Equal, Exit, HashMap, Layer, Option, Scope, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
|
||||
|
||||
export interface ScopeRegistryService {
|
||||
readonly ref: SubscriptionRef.SubscriptionRef<HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>>
|
||||
|
||||
register(
|
||||
key: ScopeRegistryService.Key,
|
||||
options: ScopeRegistryService.RegisterOptions,
|
||||
): Effect.Effect<ScopeRegistryService.Entry>
|
||||
commit(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError>
|
||||
release(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError>
|
||||
|
||||
readonly run: Effect.Effect<void>
|
||||
readonly dispose: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export declare namespace ScopeRegistryService {
|
||||
export type Key = object
|
||||
|
||||
export interface RegisterOptions {
|
||||
readonly finalizerExecutionStrategy: "sequential" | "parallel"
|
||||
readonly finalizerExecutionDebounce: Duration.Input
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly leases: number
|
||||
readonly expiresAt: Option.Option<DateTime.Utc>
|
||||
readonly finalizerExecutionDebounce: Duration.Input
|
||||
}
|
||||
}
|
||||
|
||||
export const makeKey = (): ScopeRegistryService.Key => Equal.byReference({})
|
||||
|
||||
|
||||
export class ScopeRegistryServiceImpl implements ScopeRegistryService {
|
||||
constructor(
|
||||
readonly ref: SubscriptionRef.SubscriptionRef<HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>>,
|
||||
readonly runSemaphore: Semaphore.Semaphore,
|
||||
) {}
|
||||
|
||||
|
||||
register(
|
||||
key: ScopeRegistryService.Key,
|
||||
options: ScopeRegistryService.RegisterOptions,
|
||||
): Effect.Effect<ScopeRegistryService.Entry> {
|
||||
return Effect.gen({ self: this }, function*() {
|
||||
const now = yield* DateTime.now
|
||||
const entry = Equal.byReference<ScopeRegistryService.Entry>({
|
||||
scope: yield* Scope.make(options.finalizerExecutionStrategy),
|
||||
leases: 0,
|
||||
expiresAt: Option.some(DateTime.addDuration(now, options.finalizerExecutionDebounce)),
|
||||
finalizerExecutionDebounce: options.finalizerExecutionDebounce,
|
||||
})
|
||||
|
||||
yield* SubscriptionRef.update(this.ref, HashMap.set(key, entry))
|
||||
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
commit(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
|
||||
return this.updateEntry(key, entry => Equal.byReference({
|
||||
...entry,
|
||||
leases: entry.leases + 1,
|
||||
expiresAt: Option.none(),
|
||||
}))
|
||||
}
|
||||
|
||||
release(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
|
||||
return Effect.flatMap(DateTime.now, now => this.updateEntry(key, entry => {
|
||||
if (entry.leases === 0)
|
||||
return entry
|
||||
|
||||
const leases = entry.leases - 1
|
||||
return Equal.byReference({
|
||||
...entry,
|
||||
leases,
|
||||
expiresAt: leases === 0
|
||||
? Option.some(DateTime.addDuration(now, entry.finalizerExecutionDebounce))
|
||||
: Option.none(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
get run(): Effect.Effect<void> {
|
||||
return this.runSemaphore.withPermits(1)(SubscriptionRef.changes(this.ref).pipe(
|
||||
Stream.switchMap(entries => Option.match(this.getNextExpiration(entries), {
|
||||
onNone: () => Stream.never,
|
||||
onSome: expiresAt => Stream.fromEffect(DateTime.now.pipe(
|
||||
Effect.flatMap(now => DateTime.isLessThan(now, expiresAt)
|
||||
? Effect.sleep(DateTime.distance(now, expiresAt))
|
||||
: Effect.void),
|
||||
Effect.andThen(Effect.uninterruptible(this.closeExpired)),
|
||||
)),
|
||||
})),
|
||||
Stream.runDrain,
|
||||
))
|
||||
}
|
||||
|
||||
get dispose(): Effect.Effect<void> {
|
||||
return SubscriptionRef.getAndSet(
|
||||
this.ref,
|
||||
HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>(),
|
||||
).pipe(
|
||||
Effect.flatMap(entries => Effect.forEach(
|
||||
HashMap.values(entries),
|
||||
entry => Scope.close(entry.scope, Exit.void),
|
||||
)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
updateEntry(
|
||||
key: ScopeRegistryService.Key,
|
||||
f: (entry: ScopeRegistryService.Entry) => ScopeRegistryService.Entry,
|
||||
): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
|
||||
return SubscriptionRef.modify(this.ref, entries => {
|
||||
const current = HashMap.get(entries, key)
|
||||
|
||||
if (current._tag === "None")
|
||||
return [Option.none(), entries]
|
||||
|
||||
const entry = f(current.value)
|
||||
return [Option.some(entry), HashMap.set(entries, key, entry)]
|
||||
}).pipe(Effect.flatMap(Effect.fromOption))
|
||||
}
|
||||
|
||||
get closeExpired(): Effect.Effect<void> {
|
||||
return Effect.flatMap(DateTime.now, now => SubscriptionRef.modify(
|
||||
this.ref,
|
||||
entries => {
|
||||
const expired: ScopeRegistryService.Entry[] = []
|
||||
const remaining = HashMap.filter(entries, entry => {
|
||||
const shouldClose = entry.leases === 0 && Option.exists(
|
||||
entry.expiresAt,
|
||||
expiresAt => DateTime.isLessThanOrEqualTo(expiresAt, now),
|
||||
)
|
||||
|
||||
if (shouldClose)
|
||||
expired.push(entry)
|
||||
|
||||
return !shouldClose
|
||||
})
|
||||
|
||||
return [expired, remaining]
|
||||
},
|
||||
)).pipe(
|
||||
Effect.flatMap(entries => Effect.forEach(
|
||||
entries,
|
||||
entry => Scope.close(entry.scope, Exit.void),
|
||||
)),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
|
||||
getNextExpiration(
|
||||
entries: HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>,
|
||||
): Option.Option<DateTime.Utc> {
|
||||
let earliest: DateTime.Utc | undefined
|
||||
|
||||
for (const entry of HashMap.values(entries)) {
|
||||
if (
|
||||
entry.leases === 0 &&
|
||||
entry.expiresAt._tag === "Some" &&
|
||||
(!earliest || DateTime.isLessThan(entry.expiresAt.value, earliest))
|
||||
)
|
||||
earliest = entry.expiresAt.value
|
||||
}
|
||||
|
||||
return Option.fromNullishOr(earliest)
|
||||
}
|
||||
}
|
||||
|
||||
export const make: Effect.Effect<ScopeRegistryService> = Effect.gen(function*() {
|
||||
return new ScopeRegistryServiceImpl(
|
||||
yield* SubscriptionRef.make(HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>()),
|
||||
yield* Semaphore.make(1),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
@@ -7,20 +188,14 @@ import { Context, Effect, type Fiber, HashMap, Layer, type Option, Ref, type Sco
|
||||
* This service is used internally by the `Component.useScope` hook to manage the lifecycle of component scopes,
|
||||
* including tracking active scopes and coordinating their cleanup when components unmount or dependencies change.
|
||||
*/
|
||||
export class ScopeRegistry extends Context.Service<ScopeRegistry, {
|
||||
readonly ref: Ref.Ref<HashMap.HashMap<object, ScopeRegistry.Entry>>
|
||||
}>()(
|
||||
"@effect-fc/ScopeRegistry/ScopeRegistry"
|
||||
) {
|
||||
static readonly layer = Layer.effect(ScopeRegistry, Effect.map(
|
||||
Ref.make(HashMap.empty<object, ScopeRegistry.Entry>()),
|
||||
ref => ({ ref }),
|
||||
))
|
||||
}
|
||||
export class ScopeRegistry extends Context.Service<ScopeRegistry, ScopeRegistryService>()(
|
||||
"@effect-view/ScopeRegistry/ScopeRegistry"
|
||||
) {}
|
||||
|
||||
export declare namespace ScopeRegistry {
|
||||
export interface Entry {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly closeFiber: Option.Option<Fiber.Fiber<void>>
|
||||
}
|
||||
}
|
||||
export const layer = Layer.effect(ScopeRegistry, Effect.tap(
|
||||
make,
|
||||
registry => Effect.andThen(
|
||||
Effect.addFinalizer(() => registry.dispose),
|
||||
Effect.forkScoped(registry.run),
|
||||
),
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user