@evolu/common 4.1.1 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/Config.d.ts +27 -30
- package/dist/src/Config.d.ts.map +1 -1
- package/dist/src/Config.js +32 -5
- package/dist/src/Crdt.d.ts +5 -4
- package/dist/src/Crdt.d.ts.map +1 -1
- package/dist/src/Crdt.js +27 -24
- package/dist/src/Crypto.d.ts +16 -10
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +24 -11
- package/dist/src/Db.d.ts +58 -78
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +368 -170
- package/dist/src/Diff.d.ts +9 -3
- package/dist/src/Diff.d.ts.map +1 -1
- package/dist/src/Diff.js +12 -11
- package/dist/src/Error.d.ts +15 -0
- package/dist/src/Error.d.ts.map +1 -0
- package/dist/src/Error.js +14 -0
- package/dist/src/Evolu.d.ts +139 -91
- package/dist/src/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu.js +267 -239
- package/dist/src/Model.d.ts +52 -0
- package/dist/src/Model.d.ts.map +1 -1
- package/dist/src/Model.js +47 -5
- package/dist/src/Owner.d.ts.map +1 -1
- package/dist/src/Owner.js +14 -8
- package/dist/src/Platform.d.ts +24 -34
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +9 -31
- package/dist/src/Public.d.ts +3 -5
- package/dist/src/Public.d.ts.map +1 -1
- package/dist/src/Public.js +2 -3
- package/dist/src/Sqlite.d.ts +25 -16
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +28 -20
- package/dist/src/Store.d.ts.map +1 -1
- package/dist/src/Sync.d.ts +70 -0
- package/dist/src/Sync.d.ts.map +1 -0
- package/dist/src/Sync.js +126 -0
- package/dist/src/index.d.ts +2 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -5
- package/package.json +12 -10
- package/src/Config.ts +77 -36
- package/src/Crdt.ts +61 -76
- package/src/Crypto.ts +83 -60
- package/src/Db.ts +782 -352
- package/src/Diff.ts +23 -21
- package/src/Error.ts +29 -0
- package/src/Evolu.ts +599 -499
- package/src/Model.ts +102 -6
- package/src/Owner.ts +24 -24
- package/src/Platform.ts +33 -81
- package/src/Public.ts +3 -5
- package/src/Sqlite.ts +81 -44
- package/src/Sync.ts +306 -0
- package/src/index.ts +2 -5
- package/dist/src/DbWorker.d.ts +0 -82
- package/dist/src/DbWorker.d.ts.map +0 -1
- package/dist/src/DbWorker.js +0 -335
- package/dist/src/ErrorStore.d.ts +0 -29
- package/dist/src/ErrorStore.d.ts.map +0 -1
- package/dist/src/ErrorStore.js +0 -13
- package/dist/src/OnCompletes.d.ts +0 -14
- package/dist/src/OnCompletes.d.ts.map +0 -1
- package/dist/src/OnCompletes.js +0 -25
- package/dist/src/SyncWorker.d.ts +0 -80
- package/dist/src/SyncWorker.d.ts.map +0 -1
- package/dist/src/SyncWorker.js +0 -150
- package/dist/src/Types.d.ts +0 -13
- package/dist/src/Types.d.ts.map +0 -1
- package/dist/src/Types.js +0 -1
- package/src/DbWorker.ts +0 -721
- package/src/ErrorStore.ts +0 -46
- package/src/OnCompletes.ts +0 -51
- package/src/SyncWorker.ts +0 -361
- package/src/Types.ts +0 -11
package/dist/src/Sync.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as Http from "@effect/platform/HttpClient";
|
|
2
|
+
import * as S from "@effect/schema/Schema";
|
|
3
|
+
import { concatBytes } from "@noble/ciphers/utils";
|
|
4
|
+
import { BinaryReader, BinaryWriter } from "@protobuf-ts/runtime";
|
|
5
|
+
import * as Arr from "effect/Array";
|
|
6
|
+
import * as Context from "effect/Context";
|
|
7
|
+
import * as Deferred from "effect/Deferred";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import { absurd } from "effect/Function";
|
|
10
|
+
import * as Option from "effect/Option";
|
|
11
|
+
import * as Predicate from "effect/Predicate";
|
|
12
|
+
import { Config } from "./Config.js";
|
|
13
|
+
import { merkleTreeToString, unsafeMerkleTreeFromString, } from "./Crdt.js";
|
|
14
|
+
import { SecretBox } from "./Crypto.js";
|
|
15
|
+
import { Owner } from "./Owner.js";
|
|
16
|
+
import * as Protobuf from "./Protobuf.js";
|
|
17
|
+
export const Sync = Context.GenericTag("Sync");
|
|
18
|
+
// To preserve identity.
|
|
19
|
+
export const initialSyncState = { _tag: "SyncStateInitial" };
|
|
20
|
+
export class SyncFactory extends Context.Tag("SyncFactory")() {
|
|
21
|
+
}
|
|
22
|
+
export const createSync = Effect.gen(function* () {
|
|
23
|
+
const initContext = Context.empty().pipe(Context.add(SecretBox, yield* SecretBox));
|
|
24
|
+
const afterInitContext = yield* Deferred.make();
|
|
25
|
+
return Sync.of({
|
|
26
|
+
init: (owner) => Effect.logDebug(["Sync init", { owner }]).pipe(Effect.tap(Deferred.succeed(afterInitContext, initContext.pipe(Context.add(Owner, owner))))),
|
|
27
|
+
sync: ({ merkleTree, timestamp, messages }) => Effect.gen(function* () {
|
|
28
|
+
yield* Effect.logDebug([
|
|
29
|
+
"Sync request",
|
|
30
|
+
{ merkleTree, timestamp, messages },
|
|
31
|
+
]);
|
|
32
|
+
const secretBox = yield* SecretBox;
|
|
33
|
+
const owner = yield* Owner;
|
|
34
|
+
const config = yield* Config;
|
|
35
|
+
return yield* Effect.forEach(messages || [], ({ timestamp, ...newMessage }) => Effect.map(secretBox.seal(owner.encryptionKey, newMessageToBinary(newMessage)), (content) => ({ timestamp, content }))).pipe(Effect.map((encrypedMessages) => Protobuf.SyncRequest.toBinary({
|
|
36
|
+
messages: encrypedMessages,
|
|
37
|
+
userId: owner.id,
|
|
38
|
+
nodeId: timestamp.node,
|
|
39
|
+
merkleTree: merkleTreeToString(merkleTree),
|
|
40
|
+
}, binaryWriteOptions)), Effect.flatMap((body) => Http.request
|
|
41
|
+
.post(config.syncUrl)
|
|
42
|
+
.pipe(Http.request.uint8ArrayBody(body, "application/x-protobuf"), Http.client.fetchOk, Http.response.arrayBuffer)), Effect.map((buffer) => Protobuf.SyncResponse.fromBinary(new Uint8Array(buffer), binaryReadOptions)), Effect.flatMap((syncResponse) => Effect.forEach(syncResponse.messages, (encrypedMessage) => Effect.map(secretBox.open(owner.encryptionKey, encrypedMessage.content), (binary) => [binary, encrypedMessage.timestamp])).pipe(Effect.map(Arr.filterMap(([binary, timestamp]) => Option.map(newMessageFromBinary(binary), (newMessage) => ({ ...newMessage, timestamp })))), Effect.map((messages) => ({
|
|
43
|
+
messages,
|
|
44
|
+
merkleTree: unsafeMerkleTreeFromString(syncResponse.merkleTree),
|
|
45
|
+
})), Effect.tap((response) => Effect.logDebug(["Sync response", response])))), Effect.catchTag("RequestError", () => Effect.fail({
|
|
46
|
+
_tag: "SyncStateIsNotSynced",
|
|
47
|
+
error: { _tag: "NetworkError" },
|
|
48
|
+
})), Effect.catchTag("ResponseError", ({ response: { status } }) => {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case 402:
|
|
51
|
+
return Effect.fail({
|
|
52
|
+
_tag: "SyncStateIsNotSynced",
|
|
53
|
+
error: { _tag: "PaymentRequiredError" },
|
|
54
|
+
});
|
|
55
|
+
default:
|
|
56
|
+
return Effect.fail({
|
|
57
|
+
_tag: "SyncStateIsNotSynced",
|
|
58
|
+
error: { _tag: "ServerError", status },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
}).pipe((effect) => Effect.flatMap(Deferred.await(afterInitContext), (context) => Effect.provide(effect, context))),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
const newMessageToBinary = ({ value, ...rest }) => concatBytes(version1, Protobuf.MessageContent.toBinary({ value: valueToProtobuf(value), ...rest }, binaryWriteOptions));
|
|
66
|
+
const version1 = new Uint8Array([0, 1]);
|
|
67
|
+
const valueToProtobuf = (value) => {
|
|
68
|
+
switch (typeof value) {
|
|
69
|
+
case "string":
|
|
70
|
+
return { oneofKind: "stringValue", stringValue: value };
|
|
71
|
+
case "number":
|
|
72
|
+
return {
|
|
73
|
+
oneofKind: "numberValue",
|
|
74
|
+
numberValue: S.encodeSync(S.NumberFromString)(value),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (value == null)
|
|
78
|
+
return { oneofKind: undefined };
|
|
79
|
+
if (Predicate.isUint8Array(value))
|
|
80
|
+
return { oneofKind: "bytesValue", bytesValue: value };
|
|
81
|
+
return { oneofKind: "jsonValue", jsonValue: JSON.stringify(value) };
|
|
82
|
+
};
|
|
83
|
+
// The 'protobuf-ts' uses TextEncoder, but polyfill fast-text-encoding
|
|
84
|
+
// doesn't support the fatal option.
|
|
85
|
+
// https://github.com/timostamm/protobuf-ts/issues/184#issuecomment-1658443836
|
|
86
|
+
const binaryWriteOptions = {
|
|
87
|
+
writerFactory: () => new BinaryWriter({
|
|
88
|
+
encode: (input) => new TextEncoder().encode(input),
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
const binaryReadOptions = {
|
|
92
|
+
readerFactory: (bytes) => new BinaryReader(bytes, {
|
|
93
|
+
decode: (input) => new TextDecoder().decode(input),
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
const newMessageFromBinary = (binary) => {
|
|
97
|
+
if (!startsWithArray(binary, version1))
|
|
98
|
+
return Option.none();
|
|
99
|
+
const { value, ...content } = Protobuf.MessageContent.fromBinary(binary.slice(version1.length), binaryReadOptions);
|
|
100
|
+
return Option.some({ value: valueFromProtobuf(value), ...content });
|
|
101
|
+
};
|
|
102
|
+
const startsWithArray = (array, prefix) => {
|
|
103
|
+
if (prefix.length > array.length)
|
|
104
|
+
return false;
|
|
105
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
106
|
+
if (array[i] !== prefix[i])
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
const valueFromProtobuf = (value) => {
|
|
112
|
+
switch (value.oneofKind) {
|
|
113
|
+
case "numberValue":
|
|
114
|
+
return S.decodeSync(S.NumberFromString)(value.numberValue);
|
|
115
|
+
case "stringValue":
|
|
116
|
+
return value.stringValue;
|
|
117
|
+
case "bytesValue":
|
|
118
|
+
return value.bytesValue;
|
|
119
|
+
case "jsonValue":
|
|
120
|
+
return JSON.parse(value.jsonValue);
|
|
121
|
+
case undefined:
|
|
122
|
+
return null;
|
|
123
|
+
default:
|
|
124
|
+
return absurd(value);
|
|
125
|
+
}
|
|
126
|
+
};
|
package/dist/src/index.d.ts
CHANGED
|
@@ -2,18 +2,15 @@ export * from "./Config.js";
|
|
|
2
2
|
export * from "./Crdt.js";
|
|
3
3
|
export * from "./Crypto.js";
|
|
4
4
|
export * from "./Db.js";
|
|
5
|
-
export * from "./DbWorker.js";
|
|
6
5
|
export * from "./Diff.js";
|
|
7
|
-
export * from "./
|
|
6
|
+
export * from "./Error.js";
|
|
8
7
|
export * from "./Evolu.js";
|
|
9
8
|
export * from "./Model.js";
|
|
10
9
|
export * from "./Murmurhash.js";
|
|
11
|
-
export * from "./OnCompletes.js";
|
|
12
10
|
export * from "./Owner.js";
|
|
13
11
|
export * from "./Platform.js";
|
|
14
12
|
export * from "./Protobuf.js";
|
|
15
|
-
export * from "./Public.js";
|
|
16
13
|
export * from "./Sqlite.js";
|
|
17
14
|
export * from "./Store.js";
|
|
18
|
-
export * from "./
|
|
15
|
+
export * from "./Sync.js";
|
|
19
16
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC"}
|
package/dist/src/index.js
CHANGED
|
@@ -2,17 +2,14 @@ export * from "./Config.js";
|
|
|
2
2
|
export * from "./Crdt.js";
|
|
3
3
|
export * from "./Crypto.js";
|
|
4
4
|
export * from "./Db.js";
|
|
5
|
-
export * from "./DbWorker.js";
|
|
6
5
|
export * from "./Diff.js";
|
|
7
|
-
export * from "./
|
|
6
|
+
export * from "./Error.js";
|
|
8
7
|
export * from "./Evolu.js";
|
|
9
8
|
export * from "./Model.js";
|
|
10
9
|
export * from "./Murmurhash.js";
|
|
11
|
-
export * from "./OnCompletes.js";
|
|
12
10
|
export * from "./Owner.js";
|
|
13
11
|
export * from "./Platform.js";
|
|
14
12
|
export * from "./Protobuf.js";
|
|
15
|
-
export * from "./Public.js";
|
|
16
13
|
export * from "./Sqlite.js";
|
|
17
14
|
export * from "./Store.js";
|
|
18
|
-
export * from "./
|
|
15
|
+
export * from "./Sync.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/common",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "Local-first platform designed for privacy, ease of use, and no vendor lock-in to sync and backup people's lifetime data",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"evolu",
|
|
@@ -44,28 +44,30 @@
|
|
|
44
44
|
"README.md"
|
|
45
45
|
],
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@noble/ciphers": "^0.5.
|
|
47
|
+
"@noble/ciphers": "^0.5.2",
|
|
48
48
|
"@noble/hashes": "^1.4.0",
|
|
49
49
|
"@protobuf-ts/runtime": "^2.9.4",
|
|
50
|
-
"@scure/bip39": "^1.
|
|
50
|
+
"@scure/bip39": "^1.3.0",
|
|
51
51
|
"kysely": "^0.27.3",
|
|
52
|
-
"nanoid": "^5.0.
|
|
52
|
+
"nanoid": "^5.0.7"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@effect/
|
|
55
|
+
"@effect/platform": "^0.50.7",
|
|
56
|
+
"@effect/schema": "^0.66.10",
|
|
56
57
|
"@protobuf-ts/plugin": "^2.9.4",
|
|
57
58
|
"@protobuf-ts/protoc": "^2.9.4",
|
|
58
59
|
"array-shuffle": "^3.0.0",
|
|
59
|
-
"effect": "
|
|
60
|
+
"effect": "^3.0.7",
|
|
60
61
|
"eslint": "^8.57.0",
|
|
61
|
-
"typescript": "^5.4.
|
|
62
|
-
"vitest": "^1.
|
|
62
|
+
"typescript": "^5.4.5",
|
|
63
|
+
"vitest": "^1.5.2",
|
|
63
64
|
"@evolu/tsconfig": "0.0.2",
|
|
64
65
|
"eslint-config-evolu": "1.0.0"
|
|
65
66
|
},
|
|
66
67
|
"peerDependencies": {
|
|
67
|
-
"@effect/
|
|
68
|
-
"effect": "
|
|
68
|
+
"@effect/platform": "^0.50.7",
|
|
69
|
+
"@effect/schema": "^0.66.10",
|
|
70
|
+
"effect": "^3.0.0"
|
|
69
71
|
},
|
|
70
72
|
"publishConfig": {
|
|
71
73
|
"access": "public"
|
package/src/Config.ts
CHANGED
|
@@ -1,62 +1,103 @@
|
|
|
1
1
|
import * as Context from "effect/Context";
|
|
2
2
|
import * as Layer from "effect/Layer";
|
|
3
|
-
import
|
|
3
|
+
import * as LogLevel from "effect/LogLevel";
|
|
4
|
+
import * as Logger from "effect/Logger";
|
|
5
|
+
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
6
|
+
import * as Match from "effect/Match";
|
|
4
7
|
|
|
5
8
|
export interface Config {
|
|
6
9
|
/**
|
|
7
|
-
*
|
|
10
|
+
* URL to reload browser tabs after {@link Owner} reset or restore.
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* https://medium.com/@JasonWyatt/squeezing-performance-from-sqlite-indexes-indexes-c4e175f3c346
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* const indexes = [
|
|
15
|
-
* createIndex("indexTodoCreatedAt").on("todo").column("createdAt"),
|
|
16
|
-
*
|
|
17
|
-
* createIndex("indexTodoCategoryCreatedAt")
|
|
18
|
-
* .on("todoCategory")
|
|
19
|
-
* .column("createdAt"),
|
|
20
|
-
* ];
|
|
12
|
+
* The default value is `/`.
|
|
21
13
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/** Log SQL. */
|
|
25
|
-
readonly logSql: boolean;
|
|
14
|
+
reloadUrl: string;
|
|
26
15
|
|
|
27
16
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
17
|
+
* URL for Evolu sync and backup server.
|
|
18
|
+
*
|
|
19
|
+
* The default value is `https://evolu.world`.
|
|
30
20
|
*/
|
|
31
|
-
|
|
21
|
+
syncUrl: string;
|
|
32
22
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Evolu is multitenant - it can run more instances concurrently. Every Evolu
|
|
25
|
+
* instance has to have its own unique name. Database files are separated and
|
|
26
|
+
* invisible to each other.
|
|
27
|
+
*
|
|
28
|
+
* The default value is: `Evolu`.
|
|
29
|
+
*/
|
|
30
|
+
name: string;
|
|
35
31
|
|
|
36
32
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* default value is
|
|
33
|
+
* Maximum physical clock drift allowed in ms.
|
|
34
|
+
*
|
|
35
|
+
* The default value is 5 * 60 * 1000 (5 minutes).
|
|
40
36
|
*/
|
|
41
|
-
|
|
37
|
+
maxDrift: number;
|
|
42
38
|
|
|
43
39
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
40
|
+
* Setting the minimum log level. The default value is `none`.
|
|
41
|
+
*
|
|
42
|
+
* For development, use `trace` to log all events and `debug` to log only
|
|
43
|
+
* events with values. For production, use `warning`.
|
|
46
44
|
*/
|
|
47
|
-
|
|
45
|
+
minimumLogLevel: "none" | "trace" | "debug" | "warning";
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
export const Config = Context.GenericTag<Config>("
|
|
48
|
+
export const Config = Context.GenericTag<Config>("Config");
|
|
51
49
|
|
|
52
|
-
const defaultConfig: Config = {
|
|
53
|
-
indexes: [],
|
|
54
|
-
logSql: false,
|
|
50
|
+
export const defaultConfig: Config = {
|
|
55
51
|
reloadUrl: "/",
|
|
56
52
|
syncUrl: "https://evolu.world",
|
|
57
53
|
name: "Evolu",
|
|
58
54
|
maxDrift: 5 * 60 * 1000,
|
|
55
|
+
minimumLogLevel: "none",
|
|
59
56
|
};
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
/** https://effect.website/docs/guides/runtime */
|
|
59
|
+
export const createRuntime = (
|
|
60
|
+
partialConfig?: Partial<Config>,
|
|
61
|
+
): ManagedRuntime.ManagedRuntime<Config, never> => {
|
|
62
|
+
const config = { ...defaultConfig, ...partialConfig };
|
|
63
|
+
const ConfigLive = Layer.succeed(Config, config);
|
|
64
|
+
|
|
65
|
+
const minimumLogLevel = Match.value(config.minimumLogLevel).pipe(
|
|
66
|
+
Match.when("debug", () => LogLevel.Debug),
|
|
67
|
+
Match.when("none", () => LogLevel.None),
|
|
68
|
+
Match.when("trace", () => LogLevel.Trace),
|
|
69
|
+
Match.when("warning", () => LogLevel.Warning),
|
|
70
|
+
Match.exhaustive,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const evoluLayer =
|
|
74
|
+
config.minimumLogLevel === "none"
|
|
75
|
+
? ConfigLive
|
|
76
|
+
: Layer.mergeAll(
|
|
77
|
+
ConfigLive,
|
|
78
|
+
Logger.minimumLogLevel(minimumLogLevel),
|
|
79
|
+
Logger.replace(Logger.defaultLogger, makeEvoluLogger(config.name)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return ManagedRuntime.make(evoluLayer);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// TODO: Spans and Effect native variadic log args.
|
|
86
|
+
const makeEvoluLogger = (name: string) =>
|
|
87
|
+
Logger.make(({ logLevel, message, date }) => {
|
|
88
|
+
const fn =
|
|
89
|
+
logLevel._tag === "Warning"
|
|
90
|
+
? "warn"
|
|
91
|
+
: logLevel._tag === "Error"
|
|
92
|
+
? "error"
|
|
93
|
+
: "log";
|
|
94
|
+
|
|
95
|
+
const hours = date.getHours().toString().padStart(2, "0");
|
|
96
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
97
|
+
const seconds = date.getSeconds().toString().padStart(2, "0");
|
|
98
|
+
const milliseconds = date.getMilliseconds().toString().padStart(3, "0");
|
|
99
|
+
const formattedDate = `${hours}:${minutes}:${seconds}:${milliseconds}`;
|
|
100
|
+
|
|
101
|
+
const messages: unknown[] = Array.isArray(message) ? message : [message];
|
|
102
|
+
globalThis.console[fn](`${formattedDate} [${name}]`, ...messages);
|
|
103
|
+
});
|
package/src/Crdt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as S from "@effect/schema/Schema";
|
|
2
|
+
import * as Arr from "effect/Array";
|
|
2
3
|
import * as Brand from "effect/Brand";
|
|
3
4
|
import * as Context from "effect/Context";
|
|
4
5
|
import * as Effect from "effect/Effect";
|
|
@@ -7,7 +8,6 @@ import { pipe } from "effect/Function";
|
|
|
7
8
|
import * as Layer from "effect/Layer";
|
|
8
9
|
import * as Number from "effect/Number";
|
|
9
10
|
import * as Option from "effect/Option";
|
|
10
|
-
import * as ReadonlyArray from "effect/ReadonlyArray";
|
|
11
11
|
import * as String from "effect/String";
|
|
12
12
|
import { Config } from "./Config.js";
|
|
13
13
|
import { NanoIdGenerator, NodeId } from "./Crypto.js";
|
|
@@ -36,7 +36,7 @@ export const AllowedTimeRange = {
|
|
|
36
36
|
* the device clock is out of range, Evolu will not store data until it's
|
|
37
37
|
* fixed.
|
|
38
38
|
*/
|
|
39
|
-
export const Millis = S.
|
|
39
|
+
export const Millis = S.Number.pipe(
|
|
40
40
|
S.greaterThan(AllowedTimeRange.greaterThan),
|
|
41
41
|
S.lessThan(AllowedTimeRange.lessThan),
|
|
42
42
|
S.brand("Millis"),
|
|
@@ -48,7 +48,7 @@ export const initialMillis = S.decodeSync(Millis)(
|
|
|
48
48
|
AllowedTimeRange.greaterThan + 1,
|
|
49
49
|
);
|
|
50
50
|
|
|
51
|
-
export const Counter = S.
|
|
51
|
+
export const Counter = S.Number.pipe(S.between(0, 65535), S.brand("Counter"));
|
|
52
52
|
export type Counter = S.Schema.Type<typeof Counter>;
|
|
53
53
|
|
|
54
54
|
const initialCounter = S.decodeSync(Counter)(0);
|
|
@@ -87,7 +87,7 @@ export const makeSyncTimestamp = (
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
export const makeInitialTimestamp = NanoIdGenerator.pipe(
|
|
90
|
-
Effect.flatMap(({
|
|
90
|
+
Effect.flatMap(({ nodeId }) => nodeId),
|
|
91
91
|
Effect.map(
|
|
92
92
|
(node): Timestamp => ({
|
|
93
93
|
millis: initialMillis,
|
|
@@ -97,15 +97,13 @@ export const makeInitialTimestamp = NanoIdGenerator.pipe(
|
|
|
97
97
|
),
|
|
98
98
|
);
|
|
99
99
|
|
|
100
|
-
export
|
|
101
|
-
readonly now: Effect.Effect<Millis, TimestampTimeOutOfRangeError>;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export const Time = Context.GenericTag<Time>("@services/Time");
|
|
105
|
-
|
|
106
|
-
export const TimeLive = Layer.succeed(
|
|
100
|
+
export class Time extends Context.Tag("Time")<
|
|
107
101
|
Time,
|
|
108
|
-
|
|
102
|
+
{
|
|
103
|
+
readonly now: Effect.Effect<Millis, TimestampTimeOutOfRangeError>;
|
|
104
|
+
}
|
|
105
|
+
>() {
|
|
106
|
+
static Live = Layer.succeed(Time, {
|
|
109
107
|
now: Effect.suspend(() => S.decode(Millis)(Date.now())).pipe(
|
|
110
108
|
Effect.catchTag("ParseError", () =>
|
|
111
109
|
Effect.fail<TimestampTimeOutOfRangeError>({
|
|
@@ -113,8 +111,8 @@ export const TimeLive = Layer.succeed(
|
|
|
113
111
|
}),
|
|
114
112
|
),
|
|
115
113
|
),
|
|
116
|
-
})
|
|
117
|
-
|
|
114
|
+
});
|
|
115
|
+
}
|
|
118
116
|
|
|
119
117
|
/**
|
|
120
118
|
* The TimestampError type represents all Timestamp-related errors. If such an
|
|
@@ -146,39 +144,33 @@ export interface TimestampTimeOutOfRangeError {
|
|
|
146
144
|
readonly _tag: "TimestampTimeOutOfRangeError";
|
|
147
145
|
}
|
|
148
146
|
|
|
149
|
-
const getNextMillis = (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
TimestampDriftError | TimestampTimeOutOfRangeError,
|
|
154
|
-
Time | Config
|
|
155
|
-
> =>
|
|
156
|
-
Effect.gen(function* (_) {
|
|
157
|
-
const time = yield* _(Time);
|
|
158
|
-
const config = yield* _(Config);
|
|
147
|
+
const getNextMillis = (millis: ReadonlyArray<Millis>) =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const time = yield* Time;
|
|
150
|
+
const config = yield* Config;
|
|
159
151
|
|
|
160
|
-
const now = yield*
|
|
152
|
+
const now = yield* time.now;
|
|
161
153
|
const next = Math.max(now, ...millis) as Millis;
|
|
162
154
|
|
|
163
155
|
if (next - now > config.maxDrift)
|
|
164
|
-
yield*
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
156
|
+
yield* Effect.fail<TimestampDriftError>({
|
|
157
|
+
_tag: "TimestampDriftError",
|
|
158
|
+
now,
|
|
159
|
+
next,
|
|
160
|
+
});
|
|
171
161
|
|
|
172
162
|
return next;
|
|
173
163
|
});
|
|
174
164
|
|
|
175
|
-
const incrementCounter = (
|
|
176
|
-
counter: Counter,
|
|
177
|
-
): Either.Either<Counter, TimestampCounterOverflowError> =>
|
|
165
|
+
const incrementCounter = (counter: Counter) =>
|
|
178
166
|
pipe(
|
|
179
167
|
Number.increment(counter),
|
|
180
168
|
S.decodeEither(Counter),
|
|
181
|
-
Either.mapLeft(
|
|
169
|
+
Either.mapLeft(
|
|
170
|
+
(): TimestampCounterOverflowError => ({
|
|
171
|
+
_tag: "TimestampCounterOverflowError",
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
182
174
|
);
|
|
183
175
|
|
|
184
176
|
const counterMin = S.decodeSync(Counter)(0);
|
|
@@ -192,11 +184,11 @@ export const sendTimestamp = (
|
|
|
192
184
|
| TimestampTimeOutOfRangeError,
|
|
193
185
|
Time | Config
|
|
194
186
|
> =>
|
|
195
|
-
Effect.gen(function* (
|
|
196
|
-
const millis = yield*
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
const millis = yield* getNextMillis([timestamp.millis]);
|
|
197
189
|
const counter =
|
|
198
190
|
millis === timestamp.millis
|
|
199
|
-
? yield*
|
|
191
|
+
? yield* incrementCounter(timestamp.counter)
|
|
200
192
|
: counterMin;
|
|
201
193
|
return { ...timestamp, millis, counter };
|
|
202
194
|
});
|
|
@@ -217,23 +209,19 @@ export const receiveTimestamp = ({
|
|
|
217
209
|
> =>
|
|
218
210
|
Effect.gen(function* (_) {
|
|
219
211
|
if (local.node === remote.node)
|
|
220
|
-
yield*
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
: millis === remote.millis
|
|
234
|
-
? incrementCounter(remote.counter)
|
|
235
|
-
: Either.right(counterMin),
|
|
236
|
-
);
|
|
212
|
+
yield* Effect.fail<TimestampDuplicateNodeError>({
|
|
213
|
+
_tag: "TimestampDuplicateNodeError",
|
|
214
|
+
node: local.node,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const millis = yield* getNextMillis([local.millis, remote.millis]);
|
|
218
|
+
const counter = yield* millis === local.millis && millis === remote.millis
|
|
219
|
+
? incrementCounter(Math.max(local.counter, remote.counter) as Counter)
|
|
220
|
+
: millis === local.millis
|
|
221
|
+
? incrementCounter(local.counter)
|
|
222
|
+
: millis === remote.millis
|
|
223
|
+
? incrementCounter(remote.counter)
|
|
224
|
+
: Either.right(counterMin);
|
|
237
225
|
|
|
238
226
|
return { ...local, millis, counter };
|
|
239
227
|
});
|
|
@@ -262,17 +250,15 @@ export const millisToMerkleTreePath = (millis: Millis): MerkleTreePath =>
|
|
|
262
250
|
.toString(3)
|
|
263
251
|
.split("") as MerkleTreePath;
|
|
264
252
|
|
|
265
|
-
const merkleTreePathToMillis = (path: MerkleTreePath)
|
|
253
|
+
const merkleTreePathToMillis = (path: MerkleTreePath) =>
|
|
266
254
|
path.length === 0
|
|
267
255
|
? initialMillis
|
|
268
256
|
: // 16 is the length of the base 3 value of the current time in minutes.
|
|
269
257
|
// Ensure it's padded to create the full value.
|
|
270
258
|
((parseInt(path.join("").padEnd(16, "0"), 3) * 1000 * 60) as Millis);
|
|
271
259
|
|
|
272
|
-
const xorTimestampHashes = (
|
|
273
|
-
a
|
|
274
|
-
b: TimestampHash,
|
|
275
|
-
): TimestampHash => ((a || 0) ^ b) as TimestampHash;
|
|
260
|
+
const xorTimestampHashes = (a: TimestampHash | undefined, b: TimestampHash) =>
|
|
261
|
+
((a || 0) ^ b) as TimestampHash;
|
|
276
262
|
|
|
277
263
|
const insertKey = (
|
|
278
264
|
tree: MerkleTree,
|
|
@@ -292,23 +278,22 @@ const insertKey = (
|
|
|
292
278
|
};
|
|
293
279
|
};
|
|
294
280
|
|
|
295
|
-
export const insertIntoMerkleTree =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
281
|
+
export const insertIntoMerkleTree = (
|
|
282
|
+
tree: MerkleTree,
|
|
283
|
+
timestamp: Timestamp,
|
|
284
|
+
): MerkleTree => {
|
|
285
|
+
const path = millisToMerkleTreePath(timestamp.millis);
|
|
286
|
+
const hash = timestampToHash(timestamp);
|
|
287
|
+
return insertKey(
|
|
288
|
+
{ ...tree, hash: xorTimestampHashes(tree.hash, hash) },
|
|
289
|
+
path,
|
|
290
|
+
hash,
|
|
291
|
+
);
|
|
292
|
+
};
|
|
306
293
|
|
|
307
294
|
const sortedMerkleTreeKeys: ReadonlyArray<MerkleTreeKey> = ["0", "1", "2"];
|
|
308
295
|
|
|
309
|
-
const getSortedMerkleTreeKeys = (
|
|
310
|
-
tree: MerkleTree,
|
|
311
|
-
): ReadonlyArray<MerkleTreeKey> =>
|
|
296
|
+
const getSortedMerkleTreeKeys = (tree: MerkleTree) =>
|
|
312
297
|
sortedMerkleTreeKeys.filter((key) => key in tree);
|
|
313
298
|
|
|
314
299
|
export const diffMerkleTrees = (
|
|
@@ -326,7 +311,7 @@ export const diffMerkleTrees = (
|
|
|
326
311
|
// the top of this function should pass)
|
|
327
312
|
// eslint-disable-next-line no-constant-condition
|
|
328
313
|
while (1) {
|
|
329
|
-
const keys =
|
|
314
|
+
const keys = Arr.dedupeWith(
|
|
330
315
|
getSortedMerkleTreeKeys(node1).concat(getSortedMerkleTreeKeys(node2)),
|
|
331
316
|
String.Equivalence,
|
|
332
317
|
);
|