1import {
2 UiSettingsError,
3 UiSettingsProfile,
4 type UiSettingsPatchInput,
5 type UiSettingsPatchResult,
6} from "@t3tools/contracts";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Equal from "effect/Equal";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15import * as Semaphore from "effect/Semaphore";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import * as ServerConfig from "./config.ts";
19
20export 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
31const 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
98export const layer = Layer.effect(UiSettings, make);