@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.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/dist/src/Config.d.ts +26 -27
  3. package/dist/src/Config.d.ts.map +1 -1
  4. package/dist/src/Config.js +35 -7
  5. package/dist/src/Crdt.d.ts +5 -4
  6. package/dist/src/Crdt.d.ts.map +1 -1
  7. package/dist/src/Crdt.js +27 -24
  8. package/dist/src/Crypto.d.ts +16 -10
  9. package/dist/src/Crypto.d.ts.map +1 -1
  10. package/dist/src/Crypto.js +24 -11
  11. package/dist/src/Db.d.ts +58 -78
  12. package/dist/src/Db.d.ts.map +1 -1
  13. package/dist/src/Db.js +383 -185
  14. package/dist/src/Diff.d.ts +9 -3
  15. package/dist/src/Diff.d.ts.map +1 -1
  16. package/dist/src/Diff.js +12 -11
  17. package/dist/src/Error.d.ts +15 -0
  18. package/dist/src/Error.d.ts.map +1 -0
  19. package/dist/src/Error.js +14 -0
  20. package/dist/src/Evolu.d.ts +139 -91
  21. package/dist/src/Evolu.d.ts.map +1 -1
  22. package/dist/src/Evolu.js +267 -239
  23. package/dist/src/Model.d.ts +52 -0
  24. package/dist/src/Model.d.ts.map +1 -1
  25. package/dist/src/Model.js +47 -5
  26. package/dist/src/Owner.d.ts.map +1 -1
  27. package/dist/src/Owner.js +14 -8
  28. package/dist/src/Platform.d.ts +24 -34
  29. package/dist/src/Platform.d.ts.map +1 -1
  30. package/dist/src/Platform.js +9 -31
  31. package/dist/src/Public.d.ts +3 -5
  32. package/dist/src/Public.d.ts.map +1 -1
  33. package/dist/src/Public.js +2 -3
  34. package/dist/src/Sqlite.d.ts +25 -15
  35. package/dist/src/Sqlite.d.ts.map +1 -1
  36. package/dist/src/Sqlite.js +28 -7
  37. package/dist/src/Store.d.ts.map +1 -1
  38. package/dist/src/Sync.d.ts +70 -0
  39. package/dist/src/Sync.d.ts.map +1 -0
  40. package/dist/src/Sync.js +126 -0
  41. package/dist/src/index.d.ts +2 -5
  42. package/dist/src/index.d.ts.map +1 -1
  43. package/dist/src/index.js +2 -5
  44. package/package.json +12 -10
  45. package/src/Config.ts +82 -39
  46. package/src/Crdt.ts +61 -76
  47. package/src/Crypto.ts +83 -60
  48. package/src/Db.ts +802 -370
  49. package/src/Diff.ts +23 -21
  50. package/src/Error.ts +29 -0
  51. package/src/Evolu.ts +599 -499
  52. package/src/Model.ts +102 -6
  53. package/src/Owner.ts +24 -24
  54. package/src/Platform.ts +33 -81
  55. package/src/Public.ts +3 -5
  56. package/src/Sqlite.ts +81 -27
  57. package/src/Sync.ts +306 -0
  58. package/src/index.ts +2 -5
  59. package/dist/src/DbWorker.d.ts +0 -82
  60. package/dist/src/DbWorker.d.ts.map +0 -1
  61. package/dist/src/DbWorker.js +0 -335
  62. package/dist/src/ErrorStore.d.ts +0 -29
  63. package/dist/src/ErrorStore.d.ts.map +0 -1
  64. package/dist/src/ErrorStore.js +0 -13
  65. package/dist/src/OnCompletes.d.ts +0 -14
  66. package/dist/src/OnCompletes.d.ts.map +0 -1
  67. package/dist/src/OnCompletes.js +0 -25
  68. package/dist/src/SyncWorker.d.ts +0 -80
  69. package/dist/src/SyncWorker.d.ts.map +0 -1
  70. package/dist/src/SyncWorker.js +0 -150
  71. package/dist/src/Types.d.ts +0 -13
  72. package/dist/src/Types.d.ts.map +0 -1
  73. package/dist/src/Types.js +0 -1
  74. package/src/DbWorker.ts +0 -721
  75. package/src/ErrorStore.ts +0 -46
  76. package/src/OnCompletes.ts +0 -51
  77. package/src/SyncWorker.ts +0 -361
  78. 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 { make } from "@effect/schema/Schema";
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 * as Exit from "effect/Exit";
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 ReadonlyArray from "effect/ReadonlyArray";
12
- import * as ReadonlyRecord from "effect/ReadonlyRecord";
10
+ import * as Ref from "effect/Ref";
11
+ import * as Schedule from "effect/Schedule";
13
12
  import * as String from "effect/String";
14
- import { initialMerkleTree, makeInitialTimestamp, merkleTreeToString, timestampToString, } from "./Crdt.js";
15
- import { SqliteBoolean, SqliteDate } from "./Model.js";
16
- import { makeOwner } from "./Owner.js";
17
- import { createMessageTable, createMessageTableIndex, createOwnerTable, insertOwner, } from "./Sql.js";
18
- import { indexEquivalence, Sqlite, isJsonObjectOrArray, } from "./Sqlite.js";
19
- import { makeStore } from "./Store.js";
20
- /**
21
- * Create table schema.
22
- *
23
- * Supported types are null, string, number, Uint8Array, JSON Object, and JSON
24
- * Array. Use SqliteDate for dates and SqliteBoolean for booleans.
25
- *
26
- * Reserved columns are createdAt, updatedAt, isDeleted. Those columns are added
27
- * by default.
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
- * Create database schema.
48
- *
49
- * Tables with a name prefixed with _ are local-only, which means they are not
50
- * synced. Local-only tables are useful for device-specific or temporal data.
51
- *
52
- * @example
53
- * const Database = database({
54
- * // A local-only table.
55
- * _todo: TodoTable,
56
- * todo: TodoTable,
57
- * todoCategory: TodoCategoryTable,
58
- * });
59
- * type Database = S.Schema.Type<typeof Database>;
60
- */
61
- export const database = S.struct;
62
- // We use queries as keys, hence JSON.stringify.
63
- export const serializeQuery = ({ sql, parameters = [], options, }) => {
64
- const query = {
65
- sql,
66
- parameters: parameters.map((p) => Predicate.isUint8Array(p)
67
- ? Array.from(p)
68
- : isJsonObjectOrArray(p)
69
- ? { json: p }
70
- : p),
71
- ...(options && { options }),
72
- };
73
- return JSON.stringify(query);
74
- };
75
- export const deserializeQuery = (query) => {
76
- const serializedSqliteQuery = JSON.parse(query);
77
- return {
78
- ...serializedSqliteQuery,
79
- parameters: serializedSqliteQuery.parameters.map((p) => Array.isArray(p)
80
- ? new Uint8Array(p)
81
- : typeof p === "object" && p != null
82
- ? p.json
83
- : p),
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 someDefectToNoSuchTableOrColumnError = Effect.catchSomeDefect((error) => S.is(SqliteNoSuchTableOrColumnError)(error)
120
- ? Option.some(Effect.fail({
121
- _tag: "NoSuchTableOrColumnError",
122
- }))
123
- : Option.none());
124
- export const lazyInit = (mnemonic) => Effect.gen(function* (_) {
125
- const [owner, sqlite, initialTimestampString] = yield* _(Effect.all([makeOwner(mnemonic), Sqlite, makeInitialTimestamp], {
126
- concurrency: "unbounded",
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
- const sqlite = yield* _(Sqlite);
147
- const tables = yield* _(sqlite.exec({
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
- select
151
- sqlite_master.name as tableName,
152
- table_info.name as columnName
153
- from
154
- sqlite_master
155
- join pragma_table_info(sqlite_master.name) as table_info
156
- `.trim(),
157
- }), Effect.map(({ rows }) => {
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* _(sqlite.exec({
140
+ });
141
+ const indexes = yield* Effect.map(sqlite.exec({
171
142
  sql: `
172
- select
173
- name, sql
174
- from
175
- sqlite_master
176
- where
177
- type='index' and
178
- name not like 'sqlite_%' and
179
- name not like 'index_evolu_%'`,
180
- }), Effect.map((result) => ReadonlyArray.map(result.rows, (row) => ({
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
- export const ensureSchema = (schema) => Effect.gen(function* (_) {
192
- const sqlite = yield* _(Sqlite);
193
- const currentSchema = yield* _(getSchema);
163
+ const ensureSchema = (newSchema) => (currentSchema) => Effect.gen(function* () {
164
+ yield* Effect.logTrace("Db ensureSchema");
194
165
  const sql = [];
195
- schema.tables.forEach((table) => {
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
- create table ${table.name} (
200
- "id" text primary key,
201
- ${table.columns
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
- ReadonlyArray.differenceWith(String.Equivalence)(table.columns, currentTable.columns).forEach((newColumn) => {
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
- ReadonlyArray.differenceWith(indexEquivalence)(currentSchema.indexes, ReadonlyArray.intersectionWith(indexEquivalence)(currentSchema.indexes, schema.indexes)).forEach((indexToDrop) => {
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
- ReadonlyArray.differenceWith(indexEquivalence)(schema.indexes, currentSchema.indexes).forEach((newIndex) => {
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* _(sqlite.exec({
227
- sql: sql.join(""),
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 dropAllTables = Effect.gen(function* (_) {
231
- const sqlite = yield* _(Sqlite);
232
- const schema = yield* _(getSchema);
233
- const sql = schema.tables
234
- // The dropped table is completely removed from the database schema and
235
- // the disk file. The table can not be recovered.
236
- // All indices and triggers associated with the table are also deleted.
237
- // https://sqlite.org/lang_droptable.html
238
- .map((table) => `drop table "${table.name}";`)
239
- .join("");
240
- yield* _(sqlite.exec({ sql }));
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
- export const RowsStore = Context.GenericTag("@services/RowsStore");
243
- export const RowsStoreLive = Layer.effect(RowsStore, makeStore(new Map()));
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
+ };
@@ -1,5 +1,4 @@
1
- import * as ReadonlyArray from "effect/ReadonlyArray";
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>) => (current: ReadonlyArray<Row>) => ReadonlyArray<Row>;
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