| 1 | import { |
| 2 | UiSettingsError, |
| 3 | UiSettingsProfile, |
| 4 | type UiSettingsPatchInput, |
| 5 | type UiSettingsPatchResult, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Context from "effect/Context"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Equal from "effect/Equal"; |
| 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Path from "effect/Path"; |
| 13 | import * as Result from "effect/Result"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import * as Semaphore from "effect/Semaphore"; |
| 16 | import * as Stream from "effect/Stream"; |
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 18 | import * as ServerConfig from "./config.ts"; |
| 19 | |
| 20 | export class UiSettings extends Context.Service< |
| 21 | UiSettings, |
| 22 | { |
| 23 | readonly current: Effect.Effect<UiSettingsProfile, UiSettingsError>; |
| 24 | readonly changes: Stream.Stream<UiSettingsProfile, UiSettingsError>; |
| 25 | readonly patch: ( |
| 26 | input: UiSettingsPatchInput, |
| 27 | ) => Effect.Effect<UiSettingsPatchResult, UiSettingsError>; |
| 28 | } |
| 29 | >()("t3/uiSettings") {} |
| 30 | |
| 31 | const make = Effect.gen(function* () { |
| 32 | const fs = yield* FileSystem.FileSystem; |
| 33 | const path = yield* Path.Path; |
| 34 | const { stateDir } = yield* ServerConfig.ServerConfig; |
| 35 | const profilePath = path.join(stateDir, "ui-settings.json"); |
| 36 | const json = Schema.fromJsonString(UiSettingsProfile); |
| 37 | const empty: UiSettingsProfile = { revision: 0, values: {}, fieldRevisions: {} }; |
| 38 | const initial = yield* fs.readFileString(profilePath).pipe( |
| 39 | Effect.flatMap(Schema.decodeUnknownEffect(json)), |
| 40 | Effect.catchIf( |
| 41 | (error) => "reason" in error && error.reason._tag === "NotFound", |
| 42 | () => Effect.succeed(empty), |
| 43 | ), |
| 44 | Effect.mapError((cause) => new UiSettingsError({ operation: "read", cause })), |
| 45 | Effect.result, |
| 46 | ); |
| 47 | const state = yield* SubscriptionRef.make(initial); |
| 48 | const lock = yield* Semaphore.make(1); |
| 49 | const current = SubscriptionRef.get(state).pipe(Effect.flatMap(Effect.fromResult)); |
| 50 | |
| 51 | const patch = Effect.fn("UiSettings.patch")(function* (input: UiSettingsPatchInput) { |
| 52 | return yield* lock |
| 53 | .withPermits(1)( |
| 54 | Effect.gen(function* () { |
| 55 | const profileBefore = yield* current; |
| 56 | const values = { ...profileBefore.values }; |
| 57 | const fieldRevisions = { ...profileBefore.fieldRevisions }; |
| 58 | const conflicts: string[] = []; |
| 59 | const revision = profileBefore.revision + 1; |
| 60 | let changed = false; |
| 61 | |
| 62 | for (const key of Object.keys(input.patch) as Array<keyof typeof input.patch>) { |
| 63 | const value = input.patch[key]; |
| 64 | if (Equal.equals(profileBefore.values[key], value)) continue; |
| 65 | if (input.baseRevisions[key] !== (profileBefore.fieldRevisions[key] ?? 0)) { |
| 66 | conflicts.push(key); |
| 67 | continue; |
| 68 | } |
| 69 | Object.assign(values, { [key]: value }); |
| 70 | fieldRevisions[key] = revision; |
| 71 | changed = true; |
| 72 | } |
| 73 | |
| 74 | if (!changed) return { profile: profileBefore, conflicts }; |
| 75 | const profile: UiSettingsProfile = { revision, values, fieldRevisions }; |
| 76 | const encoded = yield* Schema.encodeEffect(json)(profile).pipe( |
| 77 | Effect.mapError((cause) => new UiSettingsError({ operation: "write", cause })), |
| 78 | ); |
| 79 | yield* fs.makeDirectory(stateDir, { recursive: true }).pipe( |
| 80 | Effect.andThen(fs.writeFileString(`${profilePath}.tmp`, encoded)), |
| 81 | Effect.andThen(fs.rename(`${profilePath}.tmp`, profilePath)), |
| 82 | Effect.mapError((cause) => new UiSettingsError({ operation: "write", cause })), |
| 83 | ); |
| 84 | yield* SubscriptionRef.set(state, Result.succeed(profile)); |
| 85 | return { profile, conflicts }; |
| 86 | }), |
| 87 | ) |
| 88 | .pipe(Effect.uninterruptible); |
| 89 | }); |
| 90 | |
| 91 | return UiSettings.of({ |
| 92 | current, |
| 93 | changes: SubscriptionRef.changes(state).pipe(Stream.mapEffect(Effect.fromResult)), |
| 94 | patch, |
| 95 | }); |
| 96 | }); |
| 97 | |
| 98 | export const layer = Layer.effect(UiSettings, make); |