@evolu/common 4.1.0 → 5.0.0
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 +26 -27
- package/dist/src/Config.d.ts.map +1 -1
- package/dist/src/Config.js +35 -7
- 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 +383 -185
- 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 -15
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +28 -7
- 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 +82 -39
- package/src/Crdt.ts +61 -76
- package/src/Crypto.ts +83 -60
- package/src/Db.ts +802 -370
- 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 -27
- 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/Db.js
CHANGED
|
@@ -1,160 +1,131 @@
|
|
|
1
|
-
import * as AST from "@effect/schema/AST";
|
|
2
1
|
import * as S from "@effect/schema/Schema";
|
|
3
|
-
import
|
|
2
|
+
import * as Arr from "effect/Array";
|
|
3
|
+
import * as Console from "effect/Console";
|
|
4
4
|
import * as Context from "effect/Context";
|
|
5
|
+
import * as Deferred from "effect/Deferred";
|
|
5
6
|
import * as Effect from "effect/Effect";
|
|
6
|
-
import
|
|
7
|
-
import { pipe } from "effect/Function";
|
|
8
|
-
import * as Layer from "effect/Layer";
|
|
7
|
+
import { constVoid, pipe } from "effect/Function";
|
|
9
8
|
import * as Option from "effect/Option";
|
|
10
9
|
import * as Predicate from "effect/Predicate";
|
|
11
|
-
import * as
|
|
12
|
-
import * as
|
|
10
|
+
import * as Ref from "effect/Ref";
|
|
11
|
+
import * as Schedule from "effect/Schedule";
|
|
13
12
|
import * as String from "effect/String";
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* const TodoId = id("Todo");
|
|
31
|
-
* type TodoId = S.Schema.Type<typeof TodoId>;
|
|
32
|
-
*
|
|
33
|
-
* const TodoTable = table({
|
|
34
|
-
* id: TodoId,
|
|
35
|
-
* title: NonEmptyString1000,
|
|
36
|
-
* isCompleted: S.nullable(SqliteBoolean),
|
|
37
|
-
* });
|
|
38
|
-
* type TodoTable = S.Schema.Type<typeof TodoTable>;
|
|
39
|
-
*/
|
|
40
|
-
export const table = (fields) => S.struct(fields).pipe(S.extend(ReservedColumns));
|
|
41
|
-
const ReservedColumns = S.struct({
|
|
42
|
-
createdAt: SqliteDate,
|
|
43
|
-
updatedAt: SqliteDate,
|
|
44
|
-
isDeleted: SqliteBoolean,
|
|
13
|
+
import * as SynchronizedRef from "effect/SynchronizedRef";
|
|
14
|
+
import { Time, diffMerkleTrees, initialMerkleTree, insertIntoMerkleTree, makeInitialTimestamp, makeSyncTimestamp, merkleTreeToString, receiveTimestamp, sendTimestamp, timestampToString, unsafeTimestampFromString, } from "./Crdt.js";
|
|
15
|
+
import { Bip39, NanoIdGenerator } from "./Crypto.js";
|
|
16
|
+
import { makePatches } from "./Diff.js";
|
|
17
|
+
import { ensureTransferableError, makeUnexpectedError, } from "./Error.js";
|
|
18
|
+
import { cast } from "./Model.js";
|
|
19
|
+
import { Owner, makeOwner } from "./Owner.js";
|
|
20
|
+
import { SyncLock } from "./Platform.js";
|
|
21
|
+
import * as Sql from "./Sql.js";
|
|
22
|
+
import { Sqlite, SqliteFactory, drawSqliteQueryPlan, isJsonObjectOrArray, } from "./Sqlite.js";
|
|
23
|
+
import { Sync, SyncFactory, } from "./Sync.js";
|
|
24
|
+
export const Index = S.Struct({
|
|
25
|
+
name: S.String,
|
|
26
|
+
sql: S.String,
|
|
45
27
|
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
28
|
+
export class DbFactory extends Context.Tag("DbFactory")() {
|
|
29
|
+
}
|
|
30
|
+
export const createDb = Effect.gen(function* () {
|
|
31
|
+
const { createSqlite } = yield* SqliteFactory;
|
|
32
|
+
const { createSync } = yield* SyncFactory;
|
|
33
|
+
const initContext = Context.empty().pipe(Context.add(Bip39, yield* Bip39), Context.add(NanoIdGenerator, yield* NanoIdGenerator), Context.add(Time, yield* Time), Context.add(SyncLock, yield* SyncLock));
|
|
34
|
+
const afterInitContext = yield* Deferred.make();
|
|
35
|
+
const afterInit = (options) => (effect) => Effect.flatMap(Deferred.await(afterInitContext), (context) => Effect.flatMap(Sqlite, (sqlite) => sqlite.transaction(options.transaction)(effect)).pipe(Effect.provide(context)));
|
|
36
|
+
const queryRowsRef = yield* SynchronizedRef.make(new Map());
|
|
37
|
+
const db = {
|
|
38
|
+
init: (schema, initialData, onError, onSyncStateChange, onReceive) => Effect.gen(function* () {
|
|
39
|
+
yield* Effect.logDebug(["Db init", { schema }]);
|
|
40
|
+
const sqlite = yield* createSqlite;
|
|
41
|
+
const contextWithSqlite = Context.add(initContext, Sqlite, sqlite);
|
|
42
|
+
const owner = yield* getSchema.pipe(Effect.tap(ensureSchema(schema)), Effect.flatMap((currentSchema) => {
|
|
43
|
+
if (currentSchema.tables.map((t) => t.name).includes("evolu_owner"))
|
|
44
|
+
return readOwner;
|
|
45
|
+
return createOwner().pipe(Effect.tap(applyMutations(initialData)));
|
|
46
|
+
}), sqlite.transaction("exclusive"), Effect.provide(contextWithSqlite));
|
|
47
|
+
const sync = yield* createSync.pipe(Effect.provide(initContext));
|
|
48
|
+
yield* sync.init(owner);
|
|
49
|
+
Deferred.unsafeDone(afterInitContext, Effect.succeed(contextWithSqlite.pipe(Context.add(Owner, owner), Context.add(Sync, sync), Context.add(Callbacks, { onError, onSyncStateChange, onReceive }))));
|
|
50
|
+
return owner;
|
|
51
|
+
}),
|
|
52
|
+
loadQueries: (queries) => Effect.logDebug(["Db loadQueries", { queries }]).pipe(Effect.zipRight(loadQueries(queries, queryRowsRef)), afterInit({ transaction: "shared" })),
|
|
53
|
+
mutate: (mutations, queriesToRefresh) => Effect.gen(function* () {
|
|
54
|
+
yield* Effect.logDebug(["Db mutate", { mutations, queriesToRefresh }]);
|
|
55
|
+
const time = yield* Time;
|
|
56
|
+
const sqlite = yield* Sqlite;
|
|
57
|
+
const [toSyncMutations, localOnlyMutations] = Arr.partition(mutations,
|
|
58
|
+
/** Table name starting with '_' is local only (not synced). */
|
|
59
|
+
(mutation) => mutation.table.startsWith("_"));
|
|
60
|
+
for (const mutation of localOnlyMutations) {
|
|
61
|
+
const isDeleteMutation = mutationToNewMessages(mutation).some(({ column, value }) => column === "isDeleted" && value === 1);
|
|
62
|
+
if (isDeleteMutation) {
|
|
63
|
+
yield* sqlite.exec({
|
|
64
|
+
sql: `delete from "${mutation.table}" where "id" = ?;`,
|
|
65
|
+
parameters: [mutation.id],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const messages = mutationToNewMessages(mutation);
|
|
70
|
+
for (const message of messages) {
|
|
71
|
+
const now = yield* time.now;
|
|
72
|
+
yield* upsertValueIntoTableRowColumn(message, messages, now);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (toSyncMutations.length > 0) {
|
|
77
|
+
yield* Effect.tap(applyMutations(toSyncMutations), forkSync);
|
|
78
|
+
}
|
|
79
|
+
return yield* loadQueries(queriesToRefresh, queryRowsRef);
|
|
80
|
+
}).pipe(afterInit({ transaction: "exclusive" })),
|
|
81
|
+
resetOwner: () => Effect.logTrace("Db resetOwner").pipe(Effect.tap(dropAllTables), afterInit({ transaction: "last" })),
|
|
82
|
+
restoreOwner: (schema, mnemonic) => Effect.logTrace("Db restoreOwner").pipe(Effect.tap(dropAllTables), Effect.tap(Effect.flatMap(getSchema, ensureSchema(schema))), Effect.tap(createOwner(mnemonic)), afterInit({ transaction: "last" })),
|
|
83
|
+
ensureSchema: (schema) => getSchema.pipe(Effect.flatMap(ensureSchema(schema)), afterInit({ transaction: "exclusive" })),
|
|
84
|
+
sync: (queriesToRefresh) => Effect.logDebug(["Db sync", { queriesToRefresh }]).pipe(Effect.zipRight(forkSync()), Effect.zipRight(loadQueries(queriesToRefresh, queryRowsRef)), afterInit({ transaction: "shared" })),
|
|
85
|
+
// TODO:
|
|
86
|
+
// dispose: () =>
|
|
87
|
+
// Effect.logTrace("Db dispose").pipe(
|
|
88
|
+
// Effect.tap(Scope.close(scope, Exit.succeed("Db disposed"))),
|
|
89
|
+
// afterInit({ transaction: "last" }),
|
|
90
|
+
// ),
|
|
84
91
|
};
|
|
85
|
-
|
|
86
|
-
const _emptyRows = [];
|
|
87
|
-
export const emptyRows = () => _emptyRows;
|
|
88
|
-
const queryResultCache = new WeakMap();
|
|
89
|
-
export const queryResultFromRows = (rows) => {
|
|
90
|
-
let queryResult = queryResultCache.get(rows);
|
|
91
|
-
if (queryResult == null) {
|
|
92
|
-
queryResult = { rows, row: rows[0] };
|
|
93
|
-
queryResultCache.set(rows, queryResult);
|
|
94
|
-
}
|
|
95
|
-
return queryResult;
|
|
96
|
-
};
|
|
97
|
-
// TODO: https://discord.com/channels/795981131316985866/1218626687546294386/1218796529725476935
|
|
98
|
-
// https://github.com/Effect-TS/schema/releases/tag/v0.18.0
|
|
99
|
-
const getPropertySignatures = (schema) => {
|
|
100
|
-
const out = {};
|
|
101
|
-
const propertySignatures = AST.getPropertySignatures(schema.ast);
|
|
102
|
-
for (let i = 0; i < propertySignatures.length; i++) {
|
|
103
|
-
const propertySignature = propertySignatures[i];
|
|
104
|
-
out[propertySignature.name] = make(propertySignature.type);
|
|
105
|
-
}
|
|
106
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
107
|
-
return out;
|
|
108
|
-
};
|
|
109
|
-
export const schemaToTables = (schema) => pipe(getPropertySignatures(schema), ReadonlyRecord.toEntries, ReadonlyArray.map(([name, schema]) => ({
|
|
110
|
-
name,
|
|
111
|
-
columns: Object.keys(getPropertySignatures(schema)),
|
|
112
|
-
})));
|
|
113
|
-
export const transaction = (effect) => Effect.flatMap(Sqlite, (sqlite) => Effect.acquireUseRelease(sqlite.exec({ sql: "begin" }), () => effect, (_, exit) => Exit.isFailure(exit)
|
|
114
|
-
? sqlite.exec({ sql: "rollback" })
|
|
115
|
-
: sqlite.exec({ sql: "end" })));
|
|
116
|
-
export const SqliteNoSuchTableOrColumnError = S.struct({
|
|
117
|
-
message: S.union(S.string.pipe(S.includes("no such table")), S.string.pipe(S.includes("no such column")), S.string.pipe(S.includes("has no column"))),
|
|
92
|
+
return db;
|
|
118
93
|
});
|
|
119
|
-
export const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
94
|
+
export const Callbacks = Context.GenericTag("Callbacks");
|
|
95
|
+
const loadQueries = (queries, queryRowsRef) => Effect.gen(function* () {
|
|
96
|
+
const sqlite = yield* Sqlite;
|
|
97
|
+
const previousState = yield* SynchronizedRef.getAndUpdateEffect(queryRowsRef, (previousState) => Effect.map(Effect.forEach(queries, (query) => {
|
|
98
|
+
const sqliteQuery = deserializeQuery(query);
|
|
99
|
+
return sqlite.exec(sqliteQuery).pipe(Effect.tap(maybeExplainQueryPlan(sqliteQuery)), Effect.map(({ rows }) => [query, rows]));
|
|
100
|
+
}), (queriesRows) => new Map([...previousState, ...queriesRows])));
|
|
101
|
+
const currentState = yield* SynchronizedRef.get(queryRowsRef);
|
|
102
|
+
return queries.map((query) => ({
|
|
103
|
+
query,
|
|
104
|
+
patches: makePatches(previousState.get(query), currentState.get(query) || []),
|
|
127
105
|
}));
|
|
128
|
-
yield* _(Effect.all([
|
|
129
|
-
sqlite.exec(createMessageTable),
|
|
130
|
-
sqlite.exec(createMessageTableIndex),
|
|
131
|
-
sqlite.exec(createOwnerTable),
|
|
132
|
-
sqlite.exec({
|
|
133
|
-
...insertOwner,
|
|
134
|
-
parameters: [
|
|
135
|
-
owner.id,
|
|
136
|
-
owner.mnemonic,
|
|
137
|
-
owner.encryptionKey,
|
|
138
|
-
timestampToString(initialTimestampString),
|
|
139
|
-
merkleTreeToString(initialMerkleTree),
|
|
140
|
-
],
|
|
141
|
-
}),
|
|
142
|
-
]));
|
|
143
|
-
return owner;
|
|
144
106
|
});
|
|
107
|
+
const maybeExplainQueryPlan = (sqliteQuery) => {
|
|
108
|
+
if (!sqliteQuery.options?.logExplainQueryPlan)
|
|
109
|
+
return Effect.void;
|
|
110
|
+
return Sqlite.pipe(Effect.flatMap((sqlite) => sqlite.exec({
|
|
111
|
+
...sqliteQuery,
|
|
112
|
+
sql: `EXPLAIN QUERY PLAN ${sqliteQuery.sql}`,
|
|
113
|
+
})), Effect.tap(Console.log("ExplainQueryPlan", sqliteQuery)), Effect.tap(({ rows }) => Console.log(drawSqliteQueryPlan(rows))), Effect.map(constVoid));
|
|
114
|
+
};
|
|
145
115
|
const getSchema = Effect.gen(function* (_) {
|
|
146
|
-
|
|
147
|
-
const
|
|
116
|
+
yield* Effect.logTrace("Db getSchema");
|
|
117
|
+
const sqlite = yield* Sqlite;
|
|
118
|
+
const tables = yield* Effect.map(sqlite.exec({
|
|
148
119
|
// https://til.simonwillison.net/sqlite/list-all-columns-in-a-database
|
|
149
120
|
sql: `
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}),
|
|
121
|
+
select
|
|
122
|
+
sqlite_master.name as tableName,
|
|
123
|
+
table_info.name as columnName
|
|
124
|
+
from
|
|
125
|
+
sqlite_master
|
|
126
|
+
join pragma_table_info(sqlite_master.name) as table_info
|
|
127
|
+
`.trim(),
|
|
128
|
+
}), ({ rows }) => {
|
|
158
129
|
const map = new Map();
|
|
159
130
|
rows.forEach((row) => {
|
|
160
131
|
const { tableName, columnName } = row;
|
|
@@ -162,22 +133,23 @@ const getSchema = Effect.gen(function* (_) {
|
|
|
162
133
|
map.set(tableName, []);
|
|
163
134
|
map.get(tableName)?.push(columnName);
|
|
164
135
|
});
|
|
165
|
-
return Array.from(map, ([name, columns]) => ({
|
|
136
|
+
return globalThis.Array.from(map, ([name, columns]) => ({
|
|
166
137
|
name,
|
|
167
138
|
columns,
|
|
168
139
|
}));
|
|
169
|
-
})
|
|
170
|
-
const indexes = yield*
|
|
140
|
+
});
|
|
141
|
+
const indexes = yield* Effect.map(sqlite.exec({
|
|
171
142
|
sql: `
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
143
|
+
select
|
|
144
|
+
name, sql
|
|
145
|
+
from
|
|
146
|
+
sqlite_master
|
|
147
|
+
where
|
|
148
|
+
type='index' and
|
|
149
|
+
name not like 'sqlite_%' and
|
|
150
|
+
name not like 'index_evolu_%'
|
|
151
|
+
`.trim(),
|
|
152
|
+
}), (result) => Arr.map(result.rows, (row) => ({
|
|
181
153
|
name: row.name,
|
|
182
154
|
/**
|
|
183
155
|
* SQLite returns "CREATE INDEX" for "create index" for some reason.
|
|
@@ -185,59 +157,285 @@ const getSchema = Effect.gen(function* (_) {
|
|
|
185
157
|
* for `indexEquivalence` manually.
|
|
186
158
|
*/
|
|
187
159
|
sql: row.sql.replace("CREATE INDEX", "create index"),
|
|
188
|
-
})))
|
|
160
|
+
})));
|
|
189
161
|
return { tables, indexes };
|
|
190
162
|
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const currentSchema = yield* _(getSchema);
|
|
163
|
+
const ensureSchema = (newSchema) => (currentSchema) => Effect.gen(function* () {
|
|
164
|
+
yield* Effect.logTrace("Db ensureSchema");
|
|
194
165
|
const sql = [];
|
|
195
|
-
|
|
166
|
+
newSchema.tables.forEach((table) => {
|
|
196
167
|
const currentTable = currentSchema.tables.find((t) => t.name === table.name);
|
|
197
168
|
if (!currentTable) {
|
|
198
169
|
sql.push(`
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
170
|
+
create table ${table.name} (
|
|
171
|
+
"id" text primary key,
|
|
172
|
+
${table.columns
|
|
202
173
|
.filter((c) => c !== "id")
|
|
203
174
|
// "A column with affinity BLOB does not prefer one storage class over another
|
|
204
175
|
// and no attempt is made to coerce data from one storage class into another."
|
|
205
176
|
// https://www.sqlite.org/datatype3.html
|
|
206
177
|
.map((name) => `"${name}" blob`)
|
|
207
178
|
.join(", ")}
|
|
208
|
-
|
|
209
|
-
`);
|
|
179
|
+
);`.trim());
|
|
210
180
|
}
|
|
211
181
|
else {
|
|
212
|
-
|
|
182
|
+
Arr.differenceWith(String.Equivalence)(table.columns, currentTable.columns).forEach((newColumn) => {
|
|
213
183
|
sql.push(`alter table "${table.name}" add column "${newColumn}" blob;`);
|
|
214
184
|
});
|
|
215
185
|
}
|
|
216
186
|
});
|
|
217
187
|
// Remove old indexes.
|
|
218
|
-
|
|
188
|
+
Arr.differenceWith(indexEquivalence)(currentSchema.indexes, Arr.intersectionWith(indexEquivalence)(currentSchema.indexes, newSchema.indexes)).forEach((indexToDrop) => {
|
|
219
189
|
sql.push(`drop index "${indexToDrop.name}";`);
|
|
220
190
|
});
|
|
221
191
|
// Add new indexes.
|
|
222
|
-
|
|
192
|
+
Arr.differenceWith(indexEquivalence)(newSchema.indexes, currentSchema.indexes).forEach((newIndex) => {
|
|
223
193
|
sql.push(`${newIndex.sql};`);
|
|
224
194
|
});
|
|
225
|
-
if (sql.length > 0)
|
|
226
|
-
yield*
|
|
227
|
-
|
|
228
|
-
|
|
195
|
+
if (sql.length > 0) {
|
|
196
|
+
const sqlite = yield* Sqlite;
|
|
197
|
+
yield* sqlite.exec({ sql: sql.join("\n") });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
const indexEquivalence = (self, that) => self.name === that.name && self.sql === that.sql;
|
|
201
|
+
const readOwner = Effect.logTrace("Db readOwner").pipe(Effect.zipRight(Sqlite), Effect.flatMap((sqlite) => sqlite.exec(Sql.selectOwner)), Effect.map(({ rows: [row] }) => ({
|
|
202
|
+
id: row.id,
|
|
203
|
+
mnemonic: row.mnemonic,
|
|
204
|
+
encryptionKey: row.encryptionKey,
|
|
205
|
+
})));
|
|
206
|
+
const createOwner = (mnemonic) => Effect.logTrace("Db createOwner").pipe(Effect.zipRight(Effect.all([makeOwner(mnemonic), Sqlite, makeInitialTimestamp])), Effect.tap(([owner, sqlite, initialTimestampString]) => Effect.all([
|
|
207
|
+
sqlite.exec(Sql.createMessageTable),
|
|
208
|
+
sqlite.exec(Sql.createMessageTableIndex),
|
|
209
|
+
sqlite.exec(Sql.createOwnerTable),
|
|
210
|
+
sqlite.exec({
|
|
211
|
+
...Sql.insertOwner,
|
|
212
|
+
parameters: [
|
|
213
|
+
owner.id,
|
|
214
|
+
owner.mnemonic,
|
|
215
|
+
owner.encryptionKey,
|
|
216
|
+
timestampToString(initialTimestampString),
|
|
217
|
+
merkleTreeToString(initialMerkleTree),
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
])), Effect.map(([owner]) => owner));
|
|
221
|
+
const applyMutations = (mutations) => Effect.gen(function* (_) {
|
|
222
|
+
const { timestamp, merkleTree } = yield* readTimestampAndMerkleTree;
|
|
223
|
+
const [nextTimestamp, messages] = yield* Effect.mapAccum(mutations.flatMap(mutationToNewMessages), timestamp, (currentTimestamp, newMessage) => Effect.map(sendTimestamp(currentTimestamp), (nextTimestamp) => {
|
|
224
|
+
const message = {
|
|
225
|
+
...newMessage,
|
|
226
|
+
timestamp: timestampToString(nextTimestamp),
|
|
227
|
+
};
|
|
228
|
+
return [nextTimestamp, message];
|
|
229
|
+
}));
|
|
230
|
+
const nextMerkleTree = yield* applyMessages(merkleTree, messages);
|
|
231
|
+
yield* writeTimestampAndMerkleTree(nextTimestamp, nextMerkleTree);
|
|
232
|
+
return messages;
|
|
233
|
+
});
|
|
234
|
+
const readTimestampAndMerkleTree = Sqlite.pipe(Effect.flatMap((sqlite) => sqlite.exec(Sql.selectOwnerTimestampAndMerkleTree)), Effect.map(({ rows: [{ timestamp, merkleTree }] }) => ({
|
|
235
|
+
timestamp: unsafeTimestampFromString(timestamp),
|
|
236
|
+
merkleTree: merkleTree,
|
|
237
|
+
})));
|
|
238
|
+
const mutationToNewMessages = (mutation) => pipe(Object.entries(mutation.values), Arr.filterMap(([column, value]) =>
|
|
239
|
+
// The value can be undefined if exactOptionalPropertyTypes isn't true.
|
|
240
|
+
// Don't insert nulls because null is the default value.
|
|
241
|
+
value === undefined || (mutation.isInsert && value == null)
|
|
242
|
+
? Option.none()
|
|
243
|
+
: Option.some([column, value])), Arr.map(([column, value]) => ({
|
|
244
|
+
table: mutation.table,
|
|
245
|
+
row: mutation.id,
|
|
246
|
+
column,
|
|
247
|
+
value: typeof value === "boolean"
|
|
248
|
+
? cast(value)
|
|
249
|
+
: value instanceof Date
|
|
250
|
+
? cast(value)
|
|
251
|
+
: value,
|
|
252
|
+
})));
|
|
253
|
+
const applyMessages = (merkleTree, messages) => Effect.gen(function* () {
|
|
254
|
+
const sqlite = yield* Sqlite;
|
|
255
|
+
for (const message of messages) {
|
|
256
|
+
const messageTimestamp = unsafeTimestampFromString(message.timestamp);
|
|
257
|
+
const lastTimestamp = yield* Effect.map(sqlite.exec({
|
|
258
|
+
...Sql.selectLastTimestampForTableRowColumn,
|
|
259
|
+
parameters: [message.table, message.row, message.column, 1],
|
|
260
|
+
}), ({ rows }) => rows.length > 0 ? rows[0].timestamp : null);
|
|
261
|
+
if (lastTimestamp == null || lastTimestamp < message.timestamp) {
|
|
262
|
+
yield* upsertValueIntoTableRowColumn(message, messages, messageTimestamp.millis);
|
|
263
|
+
}
|
|
264
|
+
if (lastTimestamp == null || lastTimestamp !== message.timestamp) {
|
|
265
|
+
const { changes } = yield* sqlite.exec({
|
|
266
|
+
...Sql.insertIntoMessagesIfNew,
|
|
267
|
+
parameters: [
|
|
268
|
+
message.timestamp,
|
|
269
|
+
message.table,
|
|
270
|
+
message.row,
|
|
271
|
+
message.column,
|
|
272
|
+
message.value,
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
if (changes === 1)
|
|
276
|
+
merkleTree = insertIntoMerkleTree(merkleTree, messageTimestamp);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return merkleTree;
|
|
229
280
|
});
|
|
230
|
-
export const
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
281
|
+
export const upsertValueIntoTableRowColumn = (message, messages, millis) => Sqlite.pipe(Effect.map((sqlite) => {
|
|
282
|
+
const now = cast(new Date(millis));
|
|
283
|
+
return sqlite.exec({
|
|
284
|
+
sql: `
|
|
285
|
+
insert into
|
|
286
|
+
"${message.table}" ("id", "${message.column}", "createdAt", "updatedAt")
|
|
287
|
+
values
|
|
288
|
+
(?, ?, ?, ?)
|
|
289
|
+
on conflict do update set
|
|
290
|
+
"${message.column}" = ?,
|
|
291
|
+
"updatedAt" = ?
|
|
292
|
+
`.trim(),
|
|
293
|
+
parameters: [message.row, message.value, now, now, message.value, now],
|
|
294
|
+
});
|
|
295
|
+
}), Effect.flatMap((insert) => Effect.catchSomeDefect(insert, (error) => S.is(SqliteNoSuchTableOrColumnError)(error)
|
|
296
|
+
? Option.some(
|
|
297
|
+
// If one message fails, we ensure schema for all messages.
|
|
298
|
+
ensureSchemaByNewMessages(messages).pipe(Effect.zipRight(insert)))
|
|
299
|
+
: Option.none())));
|
|
300
|
+
const SqliteNoSuchTableOrColumnError = S.Struct({
|
|
301
|
+
message: S.Union(S.String.pipe(S.includes("no such table")), S.String.pipe(S.includes("no such column")), S.String.pipe(S.includes("has no column"))),
|
|
302
|
+
});
|
|
303
|
+
const ensureSchemaByNewMessages = (messages) => Effect.gen(function* () {
|
|
304
|
+
const tablesMap = new Map();
|
|
305
|
+
messages.forEach((message) => {
|
|
306
|
+
const table = tablesMap.get(message.table);
|
|
307
|
+
if (table == null) {
|
|
308
|
+
tablesMap.set(message.table, {
|
|
309
|
+
name: message.table,
|
|
310
|
+
columns: [message.column, "createdAt", "updatedAt"],
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (table.columns.includes(message.column))
|
|
315
|
+
return;
|
|
316
|
+
tablesMap.set(message.table, {
|
|
317
|
+
name: message.table,
|
|
318
|
+
columns: table.columns.concat(message.column),
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
const tables = Arr.fromIterable(tablesMap.values());
|
|
322
|
+
yield* Effect.flatMap(getSchema, ensureSchema({ tables, indexes: [] }));
|
|
323
|
+
});
|
|
324
|
+
const writeTimestampAndMerkleTree = (timestamp, merkleTree) => Effect.flatMap(Sqlite, (sqlite) => sqlite.exec({
|
|
325
|
+
...Sql.updateOwnerTimestampAndMerkleTree,
|
|
326
|
+
parameters: [
|
|
327
|
+
merkleTreeToString(merkleTree),
|
|
328
|
+
timestampToString(timestamp),
|
|
329
|
+
],
|
|
330
|
+
}));
|
|
331
|
+
const dropAllTables = Effect.gen(function* () {
|
|
332
|
+
yield* Effect.logTrace("Db dropAllTables");
|
|
333
|
+
const sqlite = yield* Sqlite;
|
|
334
|
+
const schema = yield* getSchema;
|
|
335
|
+
yield* Effect.forEach(schema.tables, (table) =>
|
|
336
|
+
// The dropped table is completely removed from the database schema and
|
|
337
|
+
// the disk file. The table can not be recovered.
|
|
338
|
+
// All indices and triggers associated with the table are also deleted.
|
|
339
|
+
// https://sqlite.org/lang_droptable.html
|
|
340
|
+
sqlite.exec({ sql: `drop table "${table.name}"` }));
|
|
341
|
+
});
|
|
342
|
+
const forkSync = (messages = []) => SyncLock.pipe(Effect.flatMap((syncLock) => syncLock.tryAcquire), Effect.matchEffect({
|
|
343
|
+
onFailure: () => Effect.void,
|
|
344
|
+
onSuccess: () => syncLoop(messages),
|
|
345
|
+
}), Effect.scoped, Effect.forkDaemon);
|
|
346
|
+
const syncLoop = (messages = []) => Effect.gen(function* (_) {
|
|
347
|
+
const sqlite = yield* Sqlite;
|
|
348
|
+
const sync = yield* Sync;
|
|
349
|
+
const callbacks = yield* Callbacks;
|
|
350
|
+
const time = yield* Time;
|
|
351
|
+
const syncDataRef = yield* readTimestampAndMerkleTree.pipe(sqlite.transaction("shared"), Effect.map((a) => ({ ...a, messages })), Effect.flatMap(Ref.make));
|
|
352
|
+
callbacks.onSyncStateChange({ _tag: "SyncStateIsSyncing" });
|
|
353
|
+
yield* Ref.get(syncDataRef).pipe(Effect.flatMap(sync.sync), Effect.flatMap(handleSyncResult), Effect.repeat({
|
|
354
|
+
// TODO: Schedule.jittered(Schedule.exponential("10 millis"))
|
|
355
|
+
schedule: Schedule.recurs(50),
|
|
356
|
+
until: Option.match({
|
|
357
|
+
onNone: () => Effect.gen(function* () {
|
|
358
|
+
callbacks.onSyncStateChange({
|
|
359
|
+
_tag: "SyncStateIsSynced",
|
|
360
|
+
time: yield* time.now,
|
|
361
|
+
});
|
|
362
|
+
return true;
|
|
363
|
+
}),
|
|
364
|
+
onSome: (syncData) => Effect.gen(function* () {
|
|
365
|
+
yield* Ref.set(syncDataRef, syncData);
|
|
366
|
+
return false;
|
|
367
|
+
}),
|
|
368
|
+
}),
|
|
369
|
+
}), Effect.catchAllDefect((error) =>
|
|
370
|
+
// Db can run in a Web Worker, so we must ensure transferable error.
|
|
371
|
+
Effect.fail(makeUnexpectedError(ensureTransferableError(error)))), Effect.catchTag("SyncStateIsNotSynced", (state) => Effect.succeed(callbacks.onSyncStateChange(state))), Effect.catchAll((error) => Effect.succeed(callbacks.onError(error))));
|
|
241
372
|
});
|
|
242
|
-
|
|
243
|
-
|
|
373
|
+
const handleSyncResult = (result) => Effect.flatMap(Sqlite, (sqlite) => Effect.gen(function* (_) {
|
|
374
|
+
const { onReceive } = yield* Callbacks;
|
|
375
|
+
const current = yield* readTimestampAndMerkleTree;
|
|
376
|
+
const nextTimestamp = yield* Effect.reduce(result.messages, current.timestamp, (local, message) => receiveTimestamp({
|
|
377
|
+
local,
|
|
378
|
+
remote: unsafeTimestampFromString(message.timestamp),
|
|
379
|
+
}));
|
|
380
|
+
const nextMerkleTree = yield* applyMessages(current.merkleTree, result.messages);
|
|
381
|
+
if (result.messages.length > 0) {
|
|
382
|
+
yield* writeTimestampAndMerkleTree(nextTimestamp, nextMerkleTree);
|
|
383
|
+
onReceive();
|
|
384
|
+
}
|
|
385
|
+
return yield* diffMerkleTrees(result.merkleTree, nextMerkleTree).pipe(Effect.flatMap((diff) => sqlite.exec({
|
|
386
|
+
...Sql.selectMessagesToSync,
|
|
387
|
+
parameters: [timestampToString(makeSyncTimestamp(diff))],
|
|
388
|
+
})), Effect.map(({ rows }) => ({
|
|
389
|
+
messages: rows,
|
|
390
|
+
merkleTree: nextMerkleTree,
|
|
391
|
+
timestamp: nextTimestamp,
|
|
392
|
+
})), Effect.option);
|
|
393
|
+
}).pipe(sqlite.transaction("exclusive")));
|
|
394
|
+
// We use queries as keys, hence JSON.stringify.
|
|
395
|
+
export const serializeQuery = ({ sql, parameters = [], options, }) => {
|
|
396
|
+
const query = {
|
|
397
|
+
sql,
|
|
398
|
+
parameters: parameters.map((p) => Predicate.isUint8Array(p)
|
|
399
|
+
? Arr.fromIterable(p)
|
|
400
|
+
: isJsonObjectOrArray(p)
|
|
401
|
+
? { json: p }
|
|
402
|
+
: p),
|
|
403
|
+
...(options && { options }),
|
|
404
|
+
};
|
|
405
|
+
return JSON.stringify(query);
|
|
406
|
+
};
|
|
407
|
+
export const deserializeQuery = (query) => {
|
|
408
|
+
const serializedSqliteQuery = JSON.parse(query);
|
|
409
|
+
return {
|
|
410
|
+
...serializedSqliteQuery,
|
|
411
|
+
parameters: serializedSqliteQuery.parameters.map((p) => Arr.isArray(p)
|
|
412
|
+
? new Uint8Array(p)
|
|
413
|
+
: typeof p === "object" && p != null
|
|
414
|
+
? p.json
|
|
415
|
+
: p),
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
// To preserve identity.
|
|
419
|
+
const _emptyRows = [];
|
|
420
|
+
export const emptyRows = () => _emptyRows;
|
|
421
|
+
// To preserve identity.
|
|
422
|
+
const queryResultCache = new WeakMap();
|
|
423
|
+
export const queryResultFromRows = (rows) => {
|
|
424
|
+
let queryResult = queryResultCache.get(rows);
|
|
425
|
+
if (queryResult == null) {
|
|
426
|
+
queryResult = { rows, row: rows[0] };
|
|
427
|
+
queryResultCache.set(rows, queryResult);
|
|
428
|
+
}
|
|
429
|
+
return queryResult;
|
|
430
|
+
};
|
|
431
|
+
export const notSupportedPlatformWorker = {
|
|
432
|
+
init: () => Effect.fail({
|
|
433
|
+
_tag: "NotSupportedPlatformError",
|
|
434
|
+
}),
|
|
435
|
+
loadQueries: () => Effect.succeed([]),
|
|
436
|
+
mutate: () => Effect.succeed([]),
|
|
437
|
+
resetOwner: () => Effect.void,
|
|
438
|
+
restoreOwner: () => Effect.void,
|
|
439
|
+
ensureSchema: () => Effect.void,
|
|
440
|
+
sync: () => Effect.succeed([]),
|
|
441
|
+
};
|
package/dist/src/Diff.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Query, Row } from "./Db.js";
|
|
1
|
+
import type { Query, Row } from "./Db.js";
|
|
3
2
|
import { Value } from "./Sqlite.js";
|
|
4
3
|
export interface QueryPatches {
|
|
5
4
|
readonly query: Query;
|
|
@@ -15,7 +14,14 @@ export interface ReplaceAtPatch {
|
|
|
15
14
|
readonly index: number;
|
|
16
15
|
readonly value: Row;
|
|
17
16
|
}
|
|
18
|
-
export declare const applyPatches: (patches: ReadonlyArray<Patch
|
|
17
|
+
export declare const applyPatches: (patches: ReadonlyArray<Patch>, current: ReadonlyArray<Row>) => ReadonlyArray<Row>;
|
|
18
|
+
/**
|
|
19
|
+
* We detect only changes in the whole result and in-place edits. In the future,
|
|
20
|
+
* we will add more heuristics. We will probably not implement the Myers diff
|
|
21
|
+
* algorithm because it's faster to rerender all than to compute many detailed
|
|
22
|
+
* patches. We will only implement logic a developer would implement manually,
|
|
23
|
+
* if necessary.
|
|
24
|
+
*/
|
|
19
25
|
export declare const makePatches: (previousRows: ReadonlyArray<Row> | undefined, nextRows: ReadonlyArray<Row>) => readonly Patch[];
|
|
20
26
|
export declare const areEqual: (a: Value | Row | ReadonlyArray<Row>, b: Value | Row | ReadonlyArray<Row>) => boolean;
|
|
21
27
|
//# sourceMappingURL=Diff.d.ts.map
|