diff --git a/.changeset/mighty-islands-drum.md b/.changeset/mighty-islands-drum.md new file mode 100644 index 000000000..9c36c867a --- /dev/null +++ b/.changeset/mighty-islands-drum.md @@ -0,0 +1,12 @@ +--- +"@evolu/common-react": patch +"@evolu/react-native": patch +"@evolu/common-web": patch +"@evolu/common": patch +"@evolu/server": patch +"@evolu/react": patch +--- + +Ensure valid device clock and Timestamp time. + +Millis represents a time that is valid for usage with the Merkle tree. It must be between Apr 13, 1997, and Nov 05, 2051, to ensure MinutesBase3 length equals 16. We can find diff for two Merkle trees only within this range. If the device clock is out of range, Evolu will not store data until it's fixed. diff --git a/packages/evolu-common/src/Crdt.ts b/packages/evolu-common/src/Crdt.ts new file mode 100644 index 000000000..df893a1e9 --- /dev/null +++ b/packages/evolu-common/src/Crdt.ts @@ -0,0 +1,376 @@ +import * as Schema from "@effect/schema/Schema"; +import { + Brand, + Context, + Effect, + Either, + Layer, + Number, + Option, + ReadonlyArray, + String, + pipe, +} from "effect"; +import { Config } from "./Config.js"; +import { NanoId, NodeId } from "./Crypto.js"; +import { murmurhash } from "./Murmurhash.js"; + +// https://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html +// https://jaredforsyth.com/posts/hybrid-logical-clocks/ +// https://github.com/clintharris/crdt-example-app_annotated/blob/master/shared/timestamp.js +// https://github.com/actualbudget/actual/tree/master/packages/crdt + +export interface Timestamp { + readonly node: NodeId; + readonly millis: Millis; + readonly counter: Counter; +} + +export const AllowedTimeRange = { + greaterThan: 860934419999, + lessThan: 2582803260000, +}; + +/** + * Millis represents a time that is valid for usage with the Merkle tree. + * It must be between Apr 13, 1997, and Nov 05, 2051, to ensure MinutesBase3 + * length equals 16. We can find diff for two Merkle trees only within this range. + * If the device clock is out of range, Evolu will not store data until it's fixed. + */ +export const Millis = Schema.number.pipe( + Schema.greaterThan(AllowedTimeRange.greaterThan), + Schema.lessThan(AllowedTimeRange.lessThan), + Schema.brand("Millis"), +); + +export type Millis = Schema.Schema.To; + +export const initialMillis = Schema.parseSync(Millis)( + AllowedTimeRange.greaterThan + 1, +); + +export const Counter = Schema.number.pipe( + Schema.between(0, 65535), + Schema.brand("Counter"), +); +export type Counter = Schema.Schema.To; + +const initialCounter = Schema.parseSync(Counter)(0); + +export type TimestampHash = number & Brand.Brand<"TimestampHash">; + +export type TimestampString = string & Brand.Brand<"TimestampString">; + +export const timestampToString = (t: Timestamp): TimestampString => + [ + new Date(t.millis).toISOString(), + t.counter.toString(16).toUpperCase().padStart(4, "0"), + t.node, + ].join("-") as TimestampString; + +// TODO: Replace with safe. +export const unsafeTimestampFromString = (s: TimestampString): Timestamp => { + const a = s.split("-"); + return { + millis: Date.parse(a.slice(0, 3).join("-")).valueOf() as Millis, + counter: parseInt(a[3], 16) as Counter, + node: a[4] as NodeId, + }; +}; + +export const timestampToHash = (t: Timestamp): TimestampHash => + murmurhash(timestampToString(t)) as TimestampHash; + +const syncNodeId = Schema.parseSync(NodeId)("0000000000000000"); + +export const makeSyncTimestamp = ( + millis: Millis = initialMillis, +): Timestamp => ({ + millis, + counter: initialCounter, + node: syncNodeId, +}); + +export const makeInitialTimestamp = NanoId.pipe( + Effect.flatMap(({ nanoidAsNodeId }) => nanoidAsNodeId), + Effect.map( + (node): Timestamp => ({ + millis: initialMillis, + counter: initialCounter, + node, + }), + ), +); + +export interface Time { + readonly now: Effect.Effect; +} + +export const Time = Context.Tag