@evolu/common 6.0.1-preview.1 → 6.0.1-preview.10
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/dist/src/Assert.d.ts +6 -3
- package/dist/src/Assert.d.ts.map +1 -1
- package/dist/src/Assert.js +6 -3
- package/dist/src/Crypto.d.ts +11 -0
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Evolu/Config.d.ts +17 -4
- package/dist/src/Evolu/Config.d.ts.map +1 -1
- package/dist/src/Evolu/Config.js +1 -1
- package/dist/src/Evolu/Db.d.ts +21 -13
- package/dist/src/Evolu/Db.d.ts.map +1 -1
- package/dist/src/Evolu/Db.js +81 -64
- package/dist/src/Evolu/Evolu.d.ts +6 -6
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +19 -24
- package/dist/src/Evolu/Protocol.d.ts +79 -37
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +167 -50
- package/dist/src/Evolu/Relay.d.ts +3 -1
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +39 -3
- package/dist/src/Evolu/Schema.d.ts +24 -40
- package/dist/src/Evolu/Schema.d.ts.map +1 -1
- package/dist/src/Evolu/Schema.js +13 -72
- package/dist/src/Evolu/Storage.d.ts +1 -0
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +10 -0
- package/package.json +3 -3
- package/src/Assert.ts +6 -3
- package/src/Crypto.ts +13 -0
- package/src/Evolu/Config.ts +19 -5
- package/src/Evolu/Db.ts +92 -76
- package/src/Evolu/Evolu.ts +38 -35
- package/src/Evolu/Protocol.ts +205 -79
- package/src/Evolu/Relay.ts +45 -4
- package/src/Evolu/Schema.ts +102 -130
- package/src/Evolu/Storage.ts +12 -0
package/dist/src/Evolu/Schema.js
CHANGED
|
@@ -1,86 +1,27 @@
|
|
|
1
1
|
import { pack } from "msgpackr";
|
|
2
|
+
import { assert } from "../Assert.js";
|
|
2
3
|
import { mapObject, objectToEntries } from "../Object.js";
|
|
3
4
|
import { err, ok } from "../Result.js";
|
|
4
5
|
import { SqliteBoolean } from "../Sqlite.js";
|
|
5
|
-
import { brand, createTypeErrorFormatter, DateIsoString,
|
|
6
|
+
import { brand, createTypeErrorFormatter, DateIsoString, nullableToOptional, nullOr, object, omit, optional, } from "../Type.js";
|
|
7
|
+
import { DbSchema } from "./Db.js";
|
|
6
8
|
import { createIndexes } from "./Kysely.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
createdAt: DateIsoString,
|
|
10
|
-
updatedAt: DateIsoString,
|
|
11
|
-
isDeleted: nullOr(SqliteBoolean),
|
|
12
|
-
});
|
|
13
|
-
const isDefaultColumnName = (value) => value === "createdAt" || value === "updatedAt" || value === "isDeleted";
|
|
14
|
-
/**
|
|
15
|
-
* Valid {@link EvoluSchema}.
|
|
16
|
-
*
|
|
17
|
-
* - Table and column names must be Base64Url strings.
|
|
18
|
-
* - Each table must include an `id` column of type {@link Id}.
|
|
19
|
-
* - Default column names (`createdAt`, `updatedAt`, `isDeleted`) are not allowed.
|
|
20
|
-
*/
|
|
21
|
-
export const ValidEvoluSchema = brand("ValidEvoluSchema", record(Base64Url256, object({ id: EvoluType }, record(Base64Url256, Unknown))), (value) => {
|
|
22
|
-
for (const tableName in value) {
|
|
23
|
-
for (const columnName in value[tableName]) {
|
|
24
|
-
if (isDefaultColumnName(columnName)) {
|
|
25
|
-
return err({
|
|
26
|
-
type: "ValidEvoluSchema",
|
|
27
|
-
value,
|
|
28
|
-
reason: {
|
|
29
|
-
kind: "DefaultColumnError",
|
|
30
|
-
tableName,
|
|
31
|
-
columnName,
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return ok(value);
|
|
38
|
-
});
|
|
39
|
-
/**
|
|
40
|
-
* Asserts that the given value is {@link ValidEvoluSchema}.
|
|
41
|
-
*
|
|
42
|
-
* Throws an error if the value is not a valid Evolu schema.
|
|
43
|
-
*/
|
|
44
|
-
export const assertValidEvoluSchema = (value) => {
|
|
45
|
-
const validEvoluSchema = ValidEvoluSchema.fromUnknown(value);
|
|
46
|
-
if (!validEvoluSchema.ok) {
|
|
47
|
-
const message = formatValidEvoluSchemaError(validEvoluSchema.error);
|
|
48
|
-
throw new Error(`Invalid Evolu schema: ${message}`);
|
|
49
|
-
}
|
|
50
|
-
return validEvoluSchema.value;
|
|
51
|
-
};
|
|
52
|
-
const formatValidEvoluSchemaError = (error) => {
|
|
53
|
-
if (error.type === "Record") {
|
|
54
|
-
if (error.reason.kind === "Key") {
|
|
55
|
-
return `The table "${error.reason.key}" has invalid name. A table name must be Base64Url256 string (A-Z, a-z, 0-9, -, _).`;
|
|
56
|
-
}
|
|
57
|
-
if (error.reason.kind === "Value" &&
|
|
58
|
-
error.reason.error.reason.kind === "Props" &&
|
|
59
|
-
error.reason.error.reason.errors.id?.type === "EvoluType") {
|
|
60
|
-
return `The table "${error.reason.key}" has invalid ID column. Check examples.`;
|
|
61
|
-
}
|
|
62
|
-
if (error.reason.kind === "Value" &&
|
|
63
|
-
error.reason.error.reason.kind === "IndexKey") {
|
|
64
|
-
return `The table "${error.reason.key}" has invalid column name "${error.reason.error.reason.key}". A column name must be Base64Url256 string (A-Z, a-z, 0-9, -, _).`;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (error.type === "ValidEvoluSchema") {
|
|
68
|
-
return `The table "${error.reason.tableName}" uses reserved column name "${error.reason.columnName}". Reserved column names are: createdAt, updatedAt, isDeleted.`;
|
|
69
|
-
}
|
|
70
|
-
return JSON.stringify(error, null, 2);
|
|
71
|
-
};
|
|
72
|
-
export const validEvoluSchemaToDbSchema = (validEvoluSchema, indexes) => {
|
|
73
|
-
const tables = objectToEntries(validEvoluSchema).map(([tableName, table]) => ({
|
|
9
|
+
export const evoluSchemaToDbSchema = (schema, indexes) => {
|
|
10
|
+
const tables = objectToEntries(schema).map(([tableName, table]) => ({
|
|
74
11
|
name: tableName,
|
|
75
12
|
columns: objectToEntries(table)
|
|
76
13
|
.filter(([k]) => k !== "id")
|
|
77
14
|
.map(([k]) => k),
|
|
78
15
|
}));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
};
|
|
16
|
+
const dbSchema = { tables, indexes: createIndexes(indexes) };
|
|
17
|
+
assert(DbSchema.is(dbSchema), "Invalid EvoluSchema: Table and column names must use only characters A-Za-z0-9_- and be at most 256 characters long.");
|
|
18
|
+
return dbSchema;
|
|
83
19
|
};
|
|
20
|
+
export const DefaultColumns = object({
|
|
21
|
+
createdAt: DateIsoString,
|
|
22
|
+
updatedAt: DateIsoString,
|
|
23
|
+
isDeleted: nullOr(SqliteBoolean),
|
|
24
|
+
});
|
|
84
25
|
/**
|
|
85
26
|
* Evolu has to limit the maximum mutation size. Otherwise, sync couldn't use
|
|
86
27
|
* the {@link maxProtocolMessageRangesSize}. The max size is 640KB in bytes,
|
|
@@ -36,6 +36,7 @@ export interface SqliteStorageBase {
|
|
|
36
36
|
readonly fingerprintRanges: Storage["fingerprintRanges"];
|
|
37
37
|
readonly findLowerBound: Storage["findLowerBound"];
|
|
38
38
|
readonly iterate: Storage["iterate"];
|
|
39
|
+
readonly deleteOwner: Storage["deleteOwner"];
|
|
39
40
|
}
|
|
40
41
|
export interface SqliteStorageBaseDep {
|
|
41
42
|
readonly storage: SqliteStorageBase;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Storage.d.ts","sourceRoot":"","sources":["../../../src/Evolu/Storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAM,MAAM,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAO,SAAS,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAe,cAAc,EAAe,MAAM,YAAY,CAAC;AAGtE,OAAO,EACL,aAAa,EAQb,OAAO,EAER,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAwB,MAAM,gBAAgB,CAAC;AAEvE,kEAAkE;AAClE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,eAAe,EAAE,CACxB,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,eAAe,KACvB,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IAE/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;IACrC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAC7C,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzD,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACnD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"Storage.d.ts","sourceRoot":"","sources":["../../../src/Evolu/Storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAM,MAAM,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAO,SAAS,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,EAAe,cAAc,EAAe,MAAM,YAAY,CAAC;AAGtE,OAAO,EACL,aAAa,EAQb,OAAO,EAER,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAwB,MAAM,gBAAgB,CAAC;AAEvE,kEAAkE;AAClE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,eAAe,EAAE,CACxB,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,eAAe,KACvB,MAAM,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IAE/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;IACrC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAC7C,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzD,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACnD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;IACrC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;CACrC;AAED,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,SAAS,CAAC;AAEtD,MAAM,WAAW,8BAA8B;IAC7C,cAAc,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;CAC9C;AAED,eAAO,MAAM,uBAAuB,GACjC,MAAM,iBAAiB,MAEtB,SAAS,8BAA8B,KACtC,MAAM,CAAC,iBAAiB,EAAE,WAAW,CAoJvC,CAAC;AA09BJ,eAAO,MAAM,mBAAmB,GAC7B,MAAM,SAAS,MAEd,SAAS,aAAa,EACtB,OAAO,cAAc,KACpB,MAAM,CAAC,eAAe,EAAE,WAAW,CA6ErC,CAAC"}
|
|
@@ -141,6 +141,16 @@ export const createSqliteStorageBase = (deps) => (options) => {
|
|
|
141
141
|
return;
|
|
142
142
|
}
|
|
143
143
|
},
|
|
144
|
+
deleteOwner: (ownerId) => {
|
|
145
|
+
const result = deps.sqlite.exec(sql `
|
|
146
|
+
delete from evolu_timestamp where ownerId = ${ownerId};
|
|
147
|
+
`);
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
options.onStorageError(result.error);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
144
154
|
});
|
|
145
155
|
};
|
|
146
156
|
const assertBeginEnd = (begin, end) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/common",
|
|
3
|
-
"version": "6.0.1-preview.
|
|
3
|
+
"version": "6.0.1-preview.10",
|
|
4
4
|
"description": "TypeScript library and local-first framework",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"evolu",
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
"@bokuweb/zstd-wasm": "0.0.27",
|
|
55
55
|
"@types/better-sqlite3": "^7.6.13",
|
|
56
56
|
"@types/ws": "^8.18.1",
|
|
57
|
-
"better-sqlite3": "^
|
|
57
|
+
"better-sqlite3": "^12.1.1",
|
|
58
58
|
"shx": "^0.3.4",
|
|
59
59
|
"typescript": "^5.8.3",
|
|
60
|
-
"vitest": "^3.2.
|
|
60
|
+
"vitest": "^3.2.3",
|
|
61
61
|
"ws": "^8.18.2",
|
|
62
62
|
"@evolu/tsconfig": "0.0.2"
|
|
63
63
|
},
|
package/src/Assert.ts
CHANGED
|
@@ -32,9 +32,12 @@ import type { Type } from "./Type.js";
|
|
|
32
32
|
* assert(true, "true is not true"); // no-op
|
|
33
33
|
* assert(false, "true is not true"); // throws Error
|
|
34
34
|
*
|
|
35
|
-
* const
|
|
36
|
-
* //
|
|
37
|
-
* assert(
|
|
35
|
+
* const length = buffer.getLength();
|
|
36
|
+
* // We know length is logically non-negative, but TypeScript doesn't
|
|
37
|
+
* assert(
|
|
38
|
+
* NonNegativeInt.is(length),
|
|
39
|
+
* "buffer length should be non-negative",
|
|
40
|
+
* );
|
|
38
41
|
* ```
|
|
39
42
|
*/
|
|
40
43
|
export const assert: (
|
package/src/Crypto.ts
CHANGED
|
@@ -189,3 +189,16 @@ export const padmePaddedLength = (length: NonNegativeInt): NonNegativeInt => {
|
|
|
189
189
|
export const padmePaddingLength = (length: NonNegativeInt): NonNegativeInt => {
|
|
190
190
|
return (padmePaddedLength(length) - length) as NonNegativeInt;
|
|
191
191
|
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Performs a timing-safe comparison of two Uint8Arrays. Returns true if they
|
|
195
|
+
* are equal, false otherwise. Takes constant time regardless of where the
|
|
196
|
+
* arrays differ.
|
|
197
|
+
*
|
|
198
|
+
* @see https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b
|
|
199
|
+
*/
|
|
200
|
+
export type TimingSafeEqual = (a: Uint8Array, b: Uint8Array) => boolean;
|
|
201
|
+
|
|
202
|
+
export interface TimingSafeEqualDep {
|
|
203
|
+
readonly timingSafeEqual: TimingSafeEqual;
|
|
204
|
+
}
|
package/src/Evolu/Config.ts
CHANGED
|
@@ -5,9 +5,12 @@ import type { DbIndexesBuilder } from "./Kysely.js";
|
|
|
5
5
|
|
|
6
6
|
export interface Config extends ConsoleConfig {
|
|
7
7
|
/**
|
|
8
|
-
* The name of Evolu
|
|
9
|
-
* instances concurrently.
|
|
10
|
-
*
|
|
8
|
+
* The name of the Evolu instance. Evolu is multitenant - it can run multiple
|
|
9
|
+
* instances concurrently. Each instance must have a unique name.
|
|
10
|
+
*
|
|
11
|
+
* The instance name is used as the SQLite database filename for persistent
|
|
12
|
+
* storage, ensuring that database files are separated and invisible to each
|
|
13
|
+
* other.
|
|
11
14
|
*
|
|
12
15
|
* The default value is: `Evolu`.
|
|
13
16
|
*
|
|
@@ -22,7 +25,7 @@ export interface Config extends ConsoleConfig {
|
|
|
22
25
|
/**
|
|
23
26
|
* URL for Evolu sync and backup server.
|
|
24
27
|
*
|
|
25
|
-
* The default value is `
|
|
28
|
+
* The default value is `wss://free.evoluhq.com`.
|
|
26
29
|
*/
|
|
27
30
|
readonly syncUrl: string;
|
|
28
31
|
|
|
@@ -68,6 +71,17 @@ export interface Config extends ConsoleConfig {
|
|
|
68
71
|
* until special UX requirements are needed (e.g., multitenancy).
|
|
69
72
|
*/
|
|
70
73
|
readonly mnemonic?: Mnemonic;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Use in-memory SQLite database instead of persistent storage. Useful for
|
|
77
|
+
* testing or temporary data that doesn't need persistence.
|
|
78
|
+
*
|
|
79
|
+
* In-memory databases exist only in RAM and are completely destroyed when
|
|
80
|
+
* the process ends, making them forensically safe for sensitive data.
|
|
81
|
+
*
|
|
82
|
+
* The default value is: `false`.
|
|
83
|
+
*/
|
|
84
|
+
readonly inMemory?: boolean;
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
export interface ConfigDep {
|
|
@@ -76,7 +90,7 @@ export interface ConfigDep {
|
|
|
76
90
|
|
|
77
91
|
export const defaultConfig: Config = {
|
|
78
92
|
name: getOrThrow(SimpleName.fromParent("Evolu")),
|
|
79
|
-
syncUrl: "
|
|
93
|
+
syncUrl: "wss://free.evoluhq.com",
|
|
80
94
|
reloadUrl: "/",
|
|
81
95
|
maxDrift: 5 * 60 * 1000,
|
|
82
96
|
enableLogging: false,
|
package/src/Evolu/Db.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
SqliteValue,
|
|
35
35
|
} from "../Sqlite.js";
|
|
36
36
|
import { TimeDep } from "../Time.js";
|
|
37
|
-
import { Id, Mnemonic, object, SimpleName, String } from "../Type.js";
|
|
37
|
+
import { array, Id, Mnemonic, object, SimpleName, String } from "../Type.js";
|
|
38
38
|
import {
|
|
39
39
|
createInitializedWorker,
|
|
40
40
|
Worker,
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
Base64Url256,
|
|
55
55
|
BinaryId,
|
|
56
56
|
binaryIdToId,
|
|
57
|
+
BinaryOwnerId,
|
|
57
58
|
CrdtMessage,
|
|
58
59
|
createProtocolMessageForSync,
|
|
59
60
|
createProtocolMessageFromCrdtMessages,
|
|
@@ -96,19 +97,21 @@ import {
|
|
|
96
97
|
timestampToTimestampString,
|
|
97
98
|
} from "./Timestamp.js";
|
|
98
99
|
|
|
99
|
-
export
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface DbTable {
|
|
105
|
-
readonly name: Base64Url256;
|
|
106
|
-
readonly columns: ReadonlyArray<Base64Url256>;
|
|
107
|
-
}
|
|
100
|
+
export const DbTable = object({
|
|
101
|
+
name: Base64Url256,
|
|
102
|
+
columns: array(Base64Url256),
|
|
103
|
+
});
|
|
104
|
+
export type DbTable = typeof DbTable.Type;
|
|
108
105
|
|
|
109
106
|
export const DbIndex = object({ name: String, sql: String });
|
|
110
107
|
export type DbIndex = typeof DbIndex.Type;
|
|
111
108
|
|
|
109
|
+
export const DbSchema = object({
|
|
110
|
+
tables: array(DbTable),
|
|
111
|
+
indexes: array(DbIndex),
|
|
112
|
+
});
|
|
113
|
+
export type DbSchema = typeof DbSchema.Type;
|
|
114
|
+
|
|
112
115
|
export type DbWorker = Worker<DbWorkerInput, DbWorkerOutput>;
|
|
113
116
|
|
|
114
117
|
export type CreateDbWorker = (name: SimpleName) => DbWorker;
|
|
@@ -241,6 +244,7 @@ export const createDbWorkerForPlatform = (
|
|
|
241
244
|
|
|
242
245
|
const sqliteResult = await createSqlite(platformDeps)(
|
|
243
246
|
initMessage.config.name,
|
|
247
|
+
{ memory: initMessage.config.inMemory ?? false },
|
|
244
248
|
);
|
|
245
249
|
|
|
246
250
|
if (!sqliteResult.ok) {
|
|
@@ -496,7 +500,6 @@ export const createDbWorkerForPlatform = (
|
|
|
496
500
|
onCompleteId: message.onCompleteId,
|
|
497
501
|
reload: message.reload,
|
|
498
502
|
});
|
|
499
|
-
deps.sqlite[Symbol.dispose]();
|
|
500
503
|
|
|
501
504
|
break;
|
|
502
505
|
}
|
|
@@ -621,34 +624,32 @@ const indexesAreEqual = (self: DbIndex, that: DbIndex): boolean =>
|
|
|
621
624
|
|
|
622
625
|
export interface DbSnapshot {
|
|
623
626
|
readonly schema: DbSchema;
|
|
624
|
-
readonly
|
|
625
|
-
rows: ReadonlyArray<SqliteRow>;
|
|
627
|
+
readonly tables: Array<{
|
|
626
628
|
name: string;
|
|
629
|
+
rows: ReadonlyArray<SqliteRow>;
|
|
627
630
|
}>;
|
|
628
631
|
}
|
|
629
632
|
|
|
630
|
-
// TODO: Move
|
|
631
|
-
export const getDbSnapshot = (
|
|
632
|
-
deps: SqliteDep,
|
|
633
|
-
): Result<DbSnapshot, SqliteError> => {
|
|
633
|
+
// TODO: Move to test helpers.
|
|
634
|
+
export const getDbSnapshot = (deps: SqliteDep): DbSnapshot => {
|
|
634
635
|
const schema = getDbSchema(deps)({ allIndexes: true });
|
|
635
|
-
|
|
636
|
+
assert(schema.ok, "bug");
|
|
636
637
|
|
|
637
|
-
const
|
|
638
|
+
const tables = [];
|
|
638
639
|
|
|
639
640
|
for (const table of schema.value.tables) {
|
|
640
641
|
const result = deps.sqlite.exec(sql`
|
|
641
642
|
select * from ${sql.identifier(table.name)};
|
|
642
643
|
`);
|
|
643
|
-
|
|
644
|
+
assert(result.ok, "bug");
|
|
644
645
|
|
|
645
|
-
|
|
646
|
-
rows: result.value.rows,
|
|
646
|
+
tables.push({
|
|
647
647
|
name: table.name,
|
|
648
|
+
rows: result.value.rows,
|
|
648
649
|
});
|
|
649
650
|
}
|
|
650
651
|
|
|
651
|
-
return
|
|
652
|
+
return { schema: schema.value, tables };
|
|
652
653
|
};
|
|
653
654
|
|
|
654
655
|
const ensureDbSchema =
|
|
@@ -787,41 +788,39 @@ const initializeDb =
|
|
|
787
788
|
`,
|
|
788
789
|
|
|
789
790
|
/**
|
|
790
|
-
* The History table stores all values per timestamp, table,
|
|
791
|
-
* column
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
* and DbChange to leverage a covering index. Hence, every value change
|
|
796
|
-
* has its timestamp, table, row, and column. In the future, we will
|
|
797
|
-
* rethink that and store history more efficiently.
|
|
798
|
-
*
|
|
799
|
-
* There is no need to use `OwnerId` on the client, because timestamps
|
|
800
|
-
* (which include a `NodeId`) are globally unique. The relay needs
|
|
801
|
-
* `OwnerId` because it hosts multiple apps/owners, but client storage
|
|
802
|
-
* represents one DB, and even when it hosts multiple owners, their
|
|
803
|
-
* timestamps remain unique.
|
|
791
|
+
* The History table stores all values per ownerId, timestamp, table, id,
|
|
792
|
+
* and column for conflict-free merging using last-write-win CRDT.
|
|
793
|
+
* Denormalizes Timestamp and DbChange for covering index performance.
|
|
794
|
+
* Time travel is available when last-write-win isn't desired. Future
|
|
795
|
+
* optimization will store history more efficiently.
|
|
804
796
|
*/
|
|
805
797
|
sql`
|
|
806
798
|
create table evolu_history (
|
|
807
|
-
"
|
|
799
|
+
"ownerId" blob not null,
|
|
808
800
|
"table" text not null,
|
|
809
|
-
"
|
|
801
|
+
"id" blob not null,
|
|
810
802
|
"column" text not null,
|
|
803
|
+
"timestamp" blob not null,
|
|
811
804
|
"value" any
|
|
812
805
|
)
|
|
813
806
|
strict;
|
|
814
807
|
`,
|
|
815
808
|
|
|
809
|
+
// Index for reading database changes by owner and timestamp.
|
|
810
|
+
// Timestamp always corresponds to a DbChange.
|
|
816
811
|
sql`
|
|
817
|
-
create index
|
|
812
|
+
create index evolu_history_ownerId_timestamp on evolu_history (
|
|
813
|
+
"ownerId",
|
|
814
|
+
"timestamp"
|
|
815
|
+
);
|
|
818
816
|
`,
|
|
819
817
|
|
|
820
818
|
sql`
|
|
821
|
-
create unique index
|
|
822
|
-
"
|
|
823
|
-
"column",
|
|
819
|
+
create unique index evolu_history_ownerId_table_id_column_timestampDesc on evolu_history (
|
|
820
|
+
"ownerId",
|
|
824
821
|
"table",
|
|
822
|
+
"id",
|
|
823
|
+
"column",
|
|
825
824
|
"timestamp" desc
|
|
826
825
|
);
|
|
827
826
|
`,
|
|
@@ -847,7 +846,14 @@ const initializeDb =
|
|
|
847
846
|
|
|
848
847
|
const result = deps.sqlite.exec(sql`
|
|
849
848
|
insert into evolu_owner
|
|
850
|
-
(
|
|
849
|
+
(
|
|
850
|
+
"mnemonic",
|
|
851
|
+
"id",
|
|
852
|
+
"encryptionKey",
|
|
853
|
+
"createdAt",
|
|
854
|
+
"writeKey",
|
|
855
|
+
"timestamp"
|
|
856
|
+
)
|
|
851
857
|
values
|
|
852
858
|
(
|
|
853
859
|
${ownerRow.mnemonic},
|
|
@@ -910,16 +916,18 @@ const applyMessages =
|
|
|
910
916
|
(
|
|
911
917
|
messages: ReadonlyArray<CrdtMessage>,
|
|
912
918
|
lastTimestamp: Timestamp,
|
|
913
|
-
options: { isMigrationFromVersion0?: boolean } = {},
|
|
914
919
|
): Result<void, SqliteError> => {
|
|
920
|
+
const ownerId = ownerIdToBinaryOwnerId(deps.ownerRowRef.get().id);
|
|
921
|
+
|
|
915
922
|
for (const message of messages) {
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (!apply1.ok) return apply1;
|
|
919
|
-
}
|
|
923
|
+
const result1 = applyMessageToAppTable(deps)(ownerId, message);
|
|
924
|
+
if (!result1.ok) return result1;
|
|
920
925
|
|
|
921
|
-
const
|
|
922
|
-
|
|
926
|
+
const result2 = applyMessageToTimestampAndHistoryTables(deps)(
|
|
927
|
+
ownerId,
|
|
928
|
+
message,
|
|
929
|
+
);
|
|
930
|
+
if (!result2.ok) return result2;
|
|
923
931
|
}
|
|
924
932
|
|
|
925
933
|
const timestamp = timestampToTimestampString(lastTimestamp);
|
|
@@ -933,8 +941,8 @@ const applyMessages =
|
|
|
933
941
|
};
|
|
934
942
|
|
|
935
943
|
const applyMessageToAppTable =
|
|
936
|
-
(deps: SqliteDep) =>
|
|
937
|
-
(message: CrdtMessage): Result<void, SqliteError> => {
|
|
944
|
+
(deps: SqliteDep & OwnerRowRefDep) =>
|
|
945
|
+
(ownerId: BinaryOwnerId, message: CrdtMessage): Result<void, SqliteError> => {
|
|
938
946
|
const date = new Date(message.timestamp.millis).toISOString();
|
|
939
947
|
const timestamp = timestampToBinaryTimestamp(message.timestamp);
|
|
940
948
|
|
|
@@ -942,28 +950,29 @@ const applyMessageToAppTable =
|
|
|
942
950
|
const result = deps.sqlite.exec(sql.prepared`
|
|
943
951
|
with
|
|
944
952
|
lastTimestamp as (
|
|
945
|
-
select timestamp
|
|
953
|
+
select "timestamp"
|
|
946
954
|
from evolu_history
|
|
947
955
|
where
|
|
948
|
-
"
|
|
949
|
-
and "column" = ${column}
|
|
956
|
+
"ownerId" = ${ownerId}
|
|
950
957
|
and "table" = ${message.change.table}
|
|
951
|
-
|
|
958
|
+
and "id" = ${message.change.id}
|
|
959
|
+
and "column" = ${column}
|
|
960
|
+
order by "timestamp" desc
|
|
952
961
|
limit 1
|
|
953
962
|
)
|
|
954
963
|
insert into ${sql.identifier(message.change.table)}
|
|
955
964
|
("id", ${sql.identifier(column)}, createdAt, updatedAt)
|
|
956
965
|
select ${message.change.id}, ${value}, ${date}, ${date}
|
|
957
966
|
where
|
|
958
|
-
(select timestamp from lastTimestamp) is null
|
|
959
|
-
or (select timestamp from lastTimestamp) < ${timestamp}
|
|
967
|
+
(select "timestamp" from lastTimestamp) is null
|
|
968
|
+
or (select "timestamp" from lastTimestamp) < ${timestamp}
|
|
960
969
|
on conflict ("id") do update
|
|
961
970
|
set
|
|
962
971
|
${sql.identifier(column)} = ${value},
|
|
963
972
|
updatedAt = ${date}
|
|
964
973
|
where
|
|
965
|
-
(select timestamp from lastTimestamp) is null
|
|
966
|
-
or (select timestamp from lastTimestamp) < ${timestamp};
|
|
974
|
+
(select "timestamp" from lastTimestamp) is null
|
|
975
|
+
or (select "timestamp" from lastTimestamp) < ${timestamp};
|
|
967
976
|
`);
|
|
968
977
|
|
|
969
978
|
if (!result.ok) return result;
|
|
@@ -973,10 +982,9 @@ const applyMessageToAppTable =
|
|
|
973
982
|
};
|
|
974
983
|
|
|
975
984
|
export const applyMessageToTimestampAndHistoryTables =
|
|
976
|
-
(deps: SqliteDep &
|
|
977
|
-
(message: CrdtMessage): Result<void, SqliteError> => {
|
|
985
|
+
(deps: SqliteDep & ClientStorageDep) =>
|
|
986
|
+
(ownerId: BinaryOwnerId, message: CrdtMessage): Result<void, SqliteError> => {
|
|
978
987
|
const timestamp = timestampToBinaryTimestamp(message.timestamp);
|
|
979
|
-
const ownerId = ownerIdToBinaryOwnerId(deps.ownerRowRef.get().id);
|
|
980
988
|
const id = idToBinaryId(message.change.id);
|
|
981
989
|
|
|
982
990
|
const result = deps.storage.insertTimestamp(ownerId, timestamp);
|
|
@@ -985,9 +993,16 @@ export const applyMessageToTimestampAndHistoryTables =
|
|
|
985
993
|
for (const [column, value] of Object.entries(message.change.values)) {
|
|
986
994
|
const result = deps.sqlite.exec(sql.prepared`
|
|
987
995
|
insert into evolu_history
|
|
988
|
-
("
|
|
996
|
+
("ownerId", "table", "id", "column", "value", "timestamp")
|
|
989
997
|
values
|
|
990
|
-
(
|
|
998
|
+
(
|
|
999
|
+
${ownerId},
|
|
1000
|
+
${message.change.table},
|
|
1001
|
+
${id},
|
|
1002
|
+
${column},
|
|
1003
|
+
${value},
|
|
1004
|
+
${timestamp}
|
|
1005
|
+
)
|
|
991
1006
|
on conflict do nothing;
|
|
992
1007
|
`);
|
|
993
1008
|
if (!result.ok) return result;
|
|
@@ -1063,11 +1078,11 @@ export const maybeMigrateToVersion0 =
|
|
|
1063
1078
|
const messagesRows = deps.sqlite.exec<{
|
|
1064
1079
|
timestamp: TimestampString;
|
|
1065
1080
|
table: Base64Url256;
|
|
1066
|
-
|
|
1081
|
+
id: Id;
|
|
1067
1082
|
column: Base64Url256;
|
|
1068
1083
|
value: SqliteValue;
|
|
1069
1084
|
}>(sql`
|
|
1070
|
-
select "timestamp", "table", "
|
|
1085
|
+
select "timestamp", "table", "id", "column", "value" from evolu_message;
|
|
1071
1086
|
`);
|
|
1072
1087
|
|
|
1073
1088
|
if (!messagesRows.ok) return messagesRows;
|
|
@@ -1083,7 +1098,7 @@ export const maybeMigrateToVersion0 =
|
|
|
1083
1098
|
const messages = messagesRows.value.rows.map((message) => ({
|
|
1084
1099
|
timestamp: timestampStringToTimestamp(message.timestamp),
|
|
1085
1100
|
change: {
|
|
1086
|
-
id: message.
|
|
1101
|
+
id: message.id,
|
|
1087
1102
|
table: message.table,
|
|
1088
1103
|
values: { [message.column]: message.value },
|
|
1089
1104
|
},
|
|
@@ -1176,6 +1191,7 @@ const createClientStorage =
|
|
|
1176
1191
|
...sqliteStorageBase.value,
|
|
1177
1192
|
|
|
1178
1193
|
validateWriteKey: constFalse,
|
|
1194
|
+
setWriteKey: constFalse,
|
|
1179
1195
|
|
|
1180
1196
|
writeMessages: (_ownerId, messages) => {
|
|
1181
1197
|
// TODO: Get owner by _ownerId when we support more.
|
|
@@ -1231,16 +1247,16 @@ const createClientStorage =
|
|
|
1231
1247
|
return true;
|
|
1232
1248
|
},
|
|
1233
1249
|
|
|
1234
|
-
readDbChange: (
|
|
1250
|
+
readDbChange: (ownerId, timestamp) => {
|
|
1235
1251
|
const result = deps.sqlite.exec<{
|
|
1236
1252
|
table: Base64Url256;
|
|
1237
|
-
|
|
1253
|
+
id: BinaryId;
|
|
1238
1254
|
column: Base64Url256;
|
|
1239
1255
|
value: SqliteValue;
|
|
1240
1256
|
}>(sql`
|
|
1241
|
-
select "table", "
|
|
1257
|
+
select "table", "id", "column", "value"
|
|
1242
1258
|
from evolu_history
|
|
1243
|
-
where "timestamp" = ${timestamp};
|
|
1259
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
|
|
1244
1260
|
`);
|
|
1245
1261
|
if (!result.ok) {
|
|
1246
1262
|
deps.postMessage({ type: "onError", error: result.error });
|
|
@@ -1250,18 +1266,18 @@ const createClientStorage =
|
|
|
1250
1266
|
const { rows } = result.value;
|
|
1251
1267
|
assert(rows.length > 0, "Rows must not be empty");
|
|
1252
1268
|
|
|
1253
|
-
const { table,
|
|
1269
|
+
const { table, id } = rows[0];
|
|
1254
1270
|
const values: Record<string, SqliteValue> = {};
|
|
1255
1271
|
|
|
1256
1272
|
for (const r of rows) {
|
|
1257
1273
|
assert(r.table === table, "All rows must have the same table");
|
|
1258
|
-
assert(eqArrayNumber(r.
|
|
1274
|
+
assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
|
|
1259
1275
|
values[r.column] = r.value;
|
|
1260
1276
|
}
|
|
1261
1277
|
|
|
1262
1278
|
const change: DbChange = {
|
|
1263
1279
|
table: rows[0].table,
|
|
1264
|
-
id: binaryIdToId(rows[0].
|
|
1280
|
+
id: binaryIdToId(rows[0].id),
|
|
1265
1281
|
values,
|
|
1266
1282
|
};
|
|
1267
1283
|
|