| 1 | import { deepEqual } from "@/Diff"; |
| 2 | import { |
| 3 | DATE_MARKER, |
| 4 | decodeDuration, |
| 5 | DURATION_MARKER, |
| 6 | encodeState, |
| 7 | reviveState, |
| 8 | reviveStateRecursive, |
| 9 | } from "@/State/StateEncoding"; |
| 10 | import { describe, expect, test } from "alchemy-test"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | |
| 13 | // Dates in persisted props must ROUND-TRIP: a provider's `diff`/`delete`/ |
| 14 | // `read` receive `olds` from the state store on a later run, and a |
| 15 | // Date-typed prop must come back as a real Date — not `{}` (the old |
| 16 | // structural-walk bug, which churned a phantom update on every plan) and |
| 17 | // not a bare ISO string (which lies about the declared prop type). |
| 18 | describe( |
| 19 | "StateEncoding Duration round-trip", |
| 20 | { tags: ["unit", "local"] }, |
| 21 | () => { |
| 22 | // The revived value is untyped JSON, but a Duration in = a Duration out is |
| 23 | // exactly what these tests assert; the cast lets `Duration.equals` (which |
| 24 | // no longer accepts `unknown`) verify it. |
| 25 | const roundTrip = (value: Duration.Duration) => |
| 26 | JSON.parse( |
| 27 | JSON.stringify(encodeState(value)), |
| 28 | reviveState, |
| 29 | ) as Duration.Duration; |
| 30 | |
| 31 | test("finite, infinity, and negative infinity survive encode → JSON → revive", () => { |
| 32 | expect( |
| 33 | Duration.equals(roundTrip(Duration.seconds(15)), Duration.seconds(15)), |
| 34 | ).toBe(true); |
| 35 | expect( |
| 36 | Duration.equals(roundTrip(Duration.infinity), Duration.infinity), |
| 37 | ).toBe(true); |
| 38 | expect( |
| 39 | Duration.equals( |
| 40 | roundTrip(Duration.negativeInfinity), |
| 41 | Duration.negativeInfinity, |
| 42 | ), |
| 43 | ).toBe(true); |
| 44 | }); |
| 45 | |
| 46 | test("decodeDuration rebuilds Duration.toJSON without clamping negatives", () => { |
| 47 | expect( |
| 48 | Duration.equals( |
| 49 | decodeDuration(Duration.negativeInfinity.toJSON())!, |
| 50 | Duration.negativeInfinity, |
| 51 | ), |
| 52 | ).toBe(true); |
| 53 | expect(encodeState(Duration.millis(15000))).toEqual({ |
| 54 | [DURATION_MARKER]: Duration.millis(15000).toJSON(), |
| 55 | }); |
| 56 | }); |
| 57 | }, |
| 58 | ); |
| 59 | |
| 60 | describe("StateEncoding Date round-trip", { tags: ["unit", "local"] }, () => { |
| 61 | const value = { |
| 62 | expires: new Date("2027-01-01T00:00:00.000Z"), |
| 63 | nested: { dates: [new Date("2028-06-15T12:30:00.000Z")] }, |
| 64 | text: "unrelated", |
| 65 | }; |
| 66 | |
| 67 | test("encode → JSON → reviveState rebuilds Date instances (local store path)", () => { |
| 68 | const revived = JSON.parse( |
| 69 | JSON.stringify(encodeState(value)), |
| 70 | reviveState, |
| 71 | ) as typeof value; |
| 72 | expect(revived.expires).toBeInstanceOf(Date); |
| 73 | expect(revived.expires.toISOString()).toBe("2027-01-01T00:00:00.000Z"); |
| 74 | expect(revived.nested.dates[0]).toBeInstanceOf(Date); |
| 75 | expect(revived.nested.dates[0]!.toISOString()).toBe( |
| 76 | "2028-06-15T12:30:00.000Z", |
| 77 | ); |
| 78 | expect(revived.text).toBe("unrelated"); |
| 79 | }); |
| 80 | |
| 81 | test("encode → transport JSON → reviveStateRecursive rebuilds Date instances (HTTP store path)", () => { |
| 82 | // The HTTP state store sends `encodeState(...)` as a JSON payload and |
| 83 | // revives the pre-parsed value on the way back out. |
| 84 | const transported = JSON.parse(JSON.stringify(encodeState(value))); |
| 85 | const revived = reviveStateRecursive(transported) as typeof value; |
| 86 | expect(revived.expires).toBeInstanceOf(Date); |
| 87 | expect(revived.expires.toISOString()).toBe("2027-01-01T00:00:00.000Z"); |
| 88 | expect(revived.nested.dates[0]).toBeInstanceOf(Date); |
| 89 | }); |
| 90 | |
| 91 | test("the persisted envelope is the marker, not a structural walk", () => { |
| 92 | expect(encodeState(new Date("2027-01-01T00:00:00.000Z"))).toEqual({ |
| 93 | [DATE_MARKER]: "2027-01-01T00:00:00.000Z", |
| 94 | }); |
| 95 | }); |
| 96 | |
| 97 | test("legacy rows with bare ISO strings stay strings and never churn", () => { |
| 98 | // Rows written before DATE_MARKER persisted plain ISO strings. The |
| 99 | // reviver must not promote arbitrary strings to Dates — and the diff |
| 100 | // must still treat them as equal to the program's live Date, so legacy |
| 101 | // rows re-deploy without a phantom update. |
| 102 | const legacy = JSON.parse( |
| 103 | JSON.stringify({ expires: "2027-01-01T00:00:00.000Z" }), |
| 104 | reviveState, |
| 105 | ) as { expires: unknown }; |
| 106 | expect(typeof legacy.expires).toBe("string"); |
| 107 | expect( |
| 108 | deepEqual( |
| 109 | { expires: new Date("2027-01-01T00:00:00.000Z") }, |
| 110 | { expires: "2027-01-01T00:00:00.000Z" }, |
| 111 | ), |
| 112 | ).toBe(true); |
| 113 | }); |
| 114 | |
| 115 | test("a user object that happens to carry the marker key is not corrupted into a Date", () => { |
| 116 | // Both revivers only rewrite EXACT single-key envelopes, so a user prop |
| 117 | // shaped `{ __date__: ..., other: ... }` survives untouched through the |
| 118 | // HTTP path and the local JSON.parse path alike. |
| 119 | const suspicious = { [DATE_MARKER]: "2027-01-01T00:00:00.000Z", n: 1 }; |
| 120 | const viaHttp = reviveStateRecursive( |
| 121 | JSON.parse(JSON.stringify(suspicious)), |
| 122 | ) as typeof suspicious; |
| 123 | expect(viaHttp.n).toBe(1); |
| 124 | expect(typeof viaHttp[DATE_MARKER]).toBe("string"); |
| 125 | const viaLocal = JSON.parse( |
| 126 | JSON.stringify(suspicious), |
| 127 | reviveState, |
| 128 | ) as typeof suspicious; |
| 129 | expect(viaLocal.n).toBe(1); |
| 130 | expect(typeof viaLocal[DATE_MARKER]).toBe("string"); |
| 131 | }); |
| 132 | }); |