1import * as Duration from "effect/Duration";
2import * as Redacted from "effect/Redacted";
3import { isResource } from "../Resource.ts";
4
5/**
6 * JSON marker used to tag a `Redacted<T>` value when writing state.
7 * The reviver recognises objects with exactly this key and rebuilds
8 * the `Redacted` wrapper on read.
9 */
10export const REDACTED_MARKER = "__redacted__";
11
12/**
13 * JSON marker used to tag a `Duration` value when writing state.
14 * The reviver recognises objects with exactly this key and rebuilds
15 * a real `Duration` instance on read so that downstream code can call
16 * `Duration.toSeconds`, `Duration.toMillis`, etc. without first
17 * decoding `Duration.toJSON`'s `{_id,_tag,...}` shape.
18 */
19export const DURATION_MARKER = "__duration__";
20
21/**
22 * JSON marker used to tag a `Date` value when writing state. The reviver
23 * recognises objects with exactly this key and rebuilds a real `Date`
24 * instance on read, so a Date-typed prop read back out of persisted state
25 * (`olds` in provider `diff`/`delete`/`read`) is a `Date` again — not the
26 * bare ISO string `JSON.stringify` would otherwise leave behind.
27 */
28export const DATE_MARKER = "__date__";
29
30const decodeDate = (encoded: unknown): Date | undefined => {
31 if (typeof encoded !== "string") return undefined;
32 const date = new Date(encoded);
33 return Number.isNaN(date.getTime()) ? undefined : date;
34};
35
36/**
37 * Rebuild a {@link Duration.Duration} from `Duration.toJSON`'s
38 * `{_id,_tag,millis?,nanos?}` shape. Shared by state persistence and the
39 * RPC sidecar — that JSON is not a valid `Duration.Input`.
40 */
41export const decodeDuration = (
42 encoded: unknown,
43): Duration.Duration | undefined => {
44 if (encoded === null || typeof encoded !== "object") return undefined;
45 const json = encoded as {
46 _tag?: "Millis" | "Nanos" | "Infinity" | "NegativeInfinity";
47 millis?: number;
48 nanos?: string;
49 };
50 switch (json._tag) {
51 case "Millis":
52 return json.millis !== undefined
53 ? Duration.millis(json.millis)
54 : undefined;
55 case "Nanos":
56 return json.nanos !== undefined
57 ? Duration.nanos(BigInt(json.nanos))
58 : undefined;
59 case "Infinity":
60 return Duration.infinity;
61 case "NegativeInfinity":
62 return Duration.negativeInfinity;
63 default:
64 return undefined;
65 }
66};
67
68/**
69 * Recursively encode a state value for JSON serialisation.
70 *
71 * - `Redacted<T>` values are wrapped as `{ [REDACTED_MARKER]: <inner> }`
72 * so the actual string is persisted rather than the `<redacted>`
73 * placeholder produced by the default `toJSON`.
74 * - `Resource` instances are flattened to `{ id, type, props, attr }`
75 * so persisted state matches the schema used by the loader.
76 * - Plain objects and arrays are walked structurally.
77 */
78export const encodeState = (value: unknown): unknown => {
79 if (
80 value === null ||
81 value === undefined ||
82 typeof value === "string" ||
83 typeof value === "number" ||
84 typeof value === "boolean"
85 )
86 return value;
87 if (Redacted.isRedacted(value)) {
88 return {
89 [REDACTED_MARKER]: encodeState(Redacted.value(value)),
90 };
91 }
92 if (Duration.isDuration(value)) {
93 // `JSON.stringify(Duration.seconds(N))` already invokes Duration.toJSON,
94 // but encodeState is also called for code paths that don't go through
95 // `JSON.stringify` (e.g. the HTTP state-store). Tag with our marker so
96 // the reviver can unambiguously rebuild a real Duration instance —
97 // Duration.toJSON's `{_id,_tag,...}` shape is NOT a valid Duration.Input.
98 return {
99 [DURATION_MARKER]: value.toJSON(),
100 };
101 }
102 // Dates are serializable leaves and must round-trip: walking one
103 // structurally would persist `{}` (`Object.entries(new Date())` is empty)
104 // — and every later plan would then diff the empty object against the
105 // program's Date and churn a phantom update forever. Tag with our marker
106 // so the reviver rebuilds a real Date instance; rows written before the
107 // marker carry a bare ISO string, which `canonicalize` in Diff.ts still
108 // compares as equal to a live Date, so legacy rows never churn either.
109 if (value instanceof Date) {
110 return { [DATE_MARKER]: value.toJSON() };
111 }
112 if (isResource(value)) {
113 return {
114 id: value.LogicalId,
115 type: value.Type,
116 props: encodeState(value.Props),
117 attr: encodeState(value.Attributes),
118 };
119 }
120 if (Array.isArray(value)) return value.map(encodeState);
121 if (typeof value === "object") {
122 const result: Record<string, unknown> = {};
123 for (const [k, v] of Object.entries(value)) {
124 result[k] = encodeState(v);
125 }
126 return result;
127 }
128 return value;
129};
130
131/**
132 * JSON reviver that rebuilds `Redacted<T>` values that were written
133 * through {@link encodeState}. Intended for use with `JSON.parse`.
134 */
135export const reviveState = (_key: string, value: unknown): unknown => {
136 if (value !== null && typeof value === "object" && !Array.isArray(value)) {
137 const obj = value as Record<string, unknown>;
138 if (REDACTED_MARKER in obj) {
139 return Redacted.make(obj[REDACTED_MARKER]);
140 }
141 if (DURATION_MARKER in obj) {
142 const decoded = decodeDuration(obj[DURATION_MARKER]);
143 if (decoded !== undefined) return decoded;
144 }
145 // Exact single-key match only — unlike the legacy REDACTED/DURATION
146 // markers there is no pre-marker data to tolerate, so a user object
147 // that merely CONTAINS the key alongside other fields is never
148 // collapsed into a Date (mirrors reviveStateRecursive's strictness).
149 if (DATE_MARKER in obj && Object.keys(obj).length === 1) {
150 const decoded = decodeDate(obj[DATE_MARKER]);
151 if (decoded !== undefined) return decoded;
152 }
153 }
154 return value;
155};
156
157/**
158 * Recursively walk an already-decoded value and rebuild `Redacted<T>`
159 * instances from `{ [REDACTED_MARKER]: <inner> }` envelopes. Mirror
160 * image of {@link encodeState} for callers that hold a parsed JS
161 * value rather than a JSON string (e.g. the HTTP state-store client,
162 * which receives values pre-parsed by `HttpApiClient`).
163 */
164export const reviveStateRecursive = (value: unknown): unknown => {
165 if (value === null || typeof value !== "object") return value;
166 if (Array.isArray(value)) return value.map(reviveStateRecursive);
167 const obj = value as Record<string, unknown>;
168 const keys = Object.keys(obj);
169 if (keys.length === 1 && keys[0] === REDACTED_MARKER) {
170 return Redacted.make(reviveStateRecursive(obj[REDACTED_MARKER]));
171 }
172 if (keys.length === 1 && keys[0] === DURATION_MARKER) {
173 const decoded = decodeDuration(obj[DURATION_MARKER]);
174 if (decoded !== undefined) return decoded;
175 }
176 if (keys.length === 1 && keys[0] === DATE_MARKER) {
177 const decoded = decodeDate(obj[DATE_MARKER]);
178 if (decoded !== undefined) return decoded;
179 }
180 const result: Record<string, unknown> = {};
181 for (const [k, v] of Object.entries(obj)) {
182 result[k] = reviveStateRecursive(v);
183 }
184 return result;
185};