@evolu/common 7.2.2 → 7.3.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/dist/src/Function.d.ts +61 -0
- package/dist/src/Function.d.ts.map +1 -1
- package/dist/src/Function.js +19 -0
- package/dist/src/Object.d.ts +32 -1
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +32 -1
- package/dist/src/Order.d.ts +2 -1
- package/dist/src/Order.d.ts.map +1 -1
- package/dist/src/Order.js +1 -0
- package/dist/src/Result.d.ts +36 -29
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Sqlite.d.ts +9 -7
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +8 -0
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +6 -4
- package/dist/src/Type.d.ts +30 -2
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +82 -0
- package/dist/src/local-first/Db.d.ts +1 -5
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +112 -117
- package/dist/src/local-first/Evolu.d.ts +3 -0
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +14 -12
- package/dist/src/local-first/LocalAuth.d.ts +2 -2
- package/dist/src/local-first/Owner.d.ts +3 -0
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.d.ts +1 -1
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +12 -12
- package/dist/src/local-first/PublicKysely.js +1 -1
- package/dist/src/local-first/Query.d.ts +54 -6
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Query.js +130 -11
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +17 -18
- package/dist/src/local-first/Schema.d.ts +15 -14
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +43 -51
- package/dist/src/local-first/Storage.d.ts +2 -2
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +2 -6
- package/dist/src/local-first/Sync.d.ts +10 -7
- package/dist/src/local-first/Sync.d.ts.map +1 -1
- package/dist/src/local-first/Sync.js +154 -103
- package/dist/src/local-first/Timestamp.d.ts +13 -0
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +14 -1
- package/dist/src/local-first/index.d.ts +0 -1
- package/dist/src/local-first/index.d.ts.map +1 -1
- package/dist/src/local-first/index.js +0 -1
- package/package.json +1 -1
- package/src/Function.ts +74 -0
- package/src/Object.ts +40 -1
- package/src/Order.ts +2 -1
- package/src/Result.ts +38 -29
- package/src/Sqlite.ts +18 -7
- package/src/Task.ts +6 -4
- package/src/Type.ts +134 -1
- package/src/local-first/Db.ts +182 -217
- package/src/local-first/Evolu.ts +21 -10
- package/src/local-first/LocalAuth.ts +2 -2
- package/src/local-first/Owner.ts +4 -0
- package/src/local-first/Protocol.ts +13 -19
- package/src/local-first/PublicKysely.ts +1 -1
- package/src/local-first/Query.ts +216 -21
- package/src/local-first/Relay.ts +20 -24
- package/src/local-first/Schema.ts +83 -70
- package/src/local-first/Storage.ts +3 -8
- package/src/local-first/Sync.ts +215 -135
- package/src/local-first/Timestamp.ts +14 -1
- package/src/local-first/index.ts +0 -1
- package/dist/src/local-first/Diff.d.ts +0 -43
- package/dist/src/local-first/Diff.d.ts.map +0 -1
- package/dist/src/local-first/Diff.js +0 -97
- package/src/local-first/Diff.ts +0 -144
|
@@ -198,7 +198,7 @@ import {
|
|
|
198
198
|
} from "../Crypto.js";
|
|
199
199
|
import { eqArrayNumber } from "../Eq.js";
|
|
200
200
|
import { computeBalancedBuckets } from "../Number.js";
|
|
201
|
-
import { objectToEntries } from "../Object.js";
|
|
201
|
+
import { createRecord, objectToEntries } from "../Object.js";
|
|
202
202
|
import { err, ok, Result } from "../Result.js";
|
|
203
203
|
import { SqliteValue } from "../Sqlite.js";
|
|
204
204
|
import {
|
|
@@ -221,8 +221,8 @@ import {
|
|
|
221
221
|
} from "../Type.js";
|
|
222
222
|
import { Predicate } from "../Types.js";
|
|
223
223
|
import {
|
|
224
|
-
OwnerError,
|
|
225
224
|
Owner,
|
|
225
|
+
OwnerError,
|
|
226
226
|
OwnerId,
|
|
227
227
|
OwnerIdBytes,
|
|
228
228
|
ownerIdToOwnerIdBytes,
|
|
@@ -972,12 +972,9 @@ export const applyProtocolMessageAsClient =
|
|
|
972
972
|
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
973
973
|
|
|
974
974
|
if (isNonEmptyReadonlyArray(messages)) {
|
|
975
|
-
const
|
|
976
|
-
ownerIdBytes,
|
|
977
|
-
messages,
|
|
978
|
-
);
|
|
975
|
+
const result = await deps.storage.writeMessages(ownerIdBytes, messages);
|
|
979
976
|
// Errors are handled by the Storage. Here we just stop syncing.
|
|
980
|
-
if (!
|
|
977
|
+
if (!result.ok) return ok({ type: "no-response" });
|
|
981
978
|
}
|
|
982
979
|
|
|
983
980
|
// Now: No writeKey, no sync.
|
|
@@ -1006,10 +1003,10 @@ export const applyProtocolMessageAsClient =
|
|
|
1006
1003
|
rangesMaxSize: options.rangesMaxSize,
|
|
1007
1004
|
});
|
|
1008
1005
|
|
|
1009
|
-
const
|
|
1006
|
+
const result = sync(deps)(ranges, output, ownerIdBytes);
|
|
1010
1007
|
|
|
1011
1008
|
// Client sync error (handled via Storage) or no changes.
|
|
1012
|
-
if (!
|
|
1009
|
+
if (!result.ok || !result.value) {
|
|
1013
1010
|
return ok({ type: "no-response" });
|
|
1014
1011
|
}
|
|
1015
1012
|
|
|
@@ -1126,14 +1123,11 @@ export const applyProtocolMessageAsRelay =
|
|
|
1126
1123
|
});
|
|
1127
1124
|
}
|
|
1128
1125
|
|
|
1129
|
-
const
|
|
1130
|
-
ownerIdBytes,
|
|
1131
|
-
messages,
|
|
1132
|
-
);
|
|
1126
|
+
const result = await deps.storage.writeMessages(ownerIdBytes, messages);
|
|
1133
1127
|
|
|
1134
|
-
if (!
|
|
1128
|
+
if (!result.ok) {
|
|
1135
1129
|
const errorCode =
|
|
1136
|
-
|
|
1130
|
+
result.error.type === "StorageWriteError"
|
|
1137
1131
|
? ProtocolErrorCode.WriteError
|
|
1138
1132
|
: ProtocolErrorCode.QuotaError;
|
|
1139
1133
|
const message = createProtocolMessageBuffer(ownerId, {
|
|
@@ -1189,13 +1183,13 @@ export const applyProtocolMessageAsRelay =
|
|
|
1189
1183
|
return ok({ type: "response", message: output.unwrap() });
|
|
1190
1184
|
}
|
|
1191
1185
|
|
|
1192
|
-
const
|
|
1186
|
+
const result = sync(deps)(ranges, output, ownerIdBytes);
|
|
1193
1187
|
|
|
1194
|
-
const message =
|
|
1188
|
+
const message = result.ok
|
|
1195
1189
|
? output.unwrap()
|
|
1196
1190
|
: createProtocolMessageBuffer(ownerId, {
|
|
1197
1191
|
messageType: MessageType.Response,
|
|
1198
|
-
errorCode:
|
|
1192
|
+
errorCode: result.error,
|
|
1199
1193
|
}).unwrap();
|
|
1200
1194
|
|
|
1201
1195
|
// Non-initiators always respond to provide sync completion feedback,
|
|
@@ -1816,7 +1810,7 @@ export const decryptAndDecodeDbChange =
|
|
|
1816
1810
|
const id = decodeId(buffer);
|
|
1817
1811
|
|
|
1818
1812
|
const length = decodeLength(buffer);
|
|
1819
|
-
const values =
|
|
1813
|
+
const values = createRecord<string, SqliteValue>();
|
|
1820
1814
|
|
|
1821
1815
|
for (let i = 0; i < length; i++) {
|
|
1822
1816
|
const column = decodeString(buffer);
|
package/src/local-first/Query.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { Brand } from "../Brand.js";
|
|
2
2
|
import { bytesToHex, hexToBytes } from "../Buffer.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createRandomBytes } from "../Crypto.js";
|
|
4
4
|
import {
|
|
5
|
+
createRecord,
|
|
6
|
+
isPlainObject,
|
|
7
|
+
objectToEntries,
|
|
8
|
+
ReadonlyRecord,
|
|
9
|
+
} from "../Object.js";
|
|
10
|
+
import { ok, Result } from "../Result.js";
|
|
11
|
+
import {
|
|
12
|
+
eqSqliteValue,
|
|
13
|
+
explainSqliteQueryPlan,
|
|
5
14
|
SafeSql,
|
|
15
|
+
SqliteDep,
|
|
16
|
+
SqliteError,
|
|
6
17
|
SqliteQuery,
|
|
7
18
|
SqliteQueryOptions,
|
|
8
19
|
SqliteRow,
|
|
9
20
|
SqliteValue,
|
|
10
21
|
} from "../Sqlite.js";
|
|
11
22
|
import { Store, StoreSubscribe } from "../Store.js";
|
|
23
|
+
import { createId, Id, String } from "../Type.js";
|
|
12
24
|
import { Simplify } from "../Types.js";
|
|
13
25
|
|
|
14
26
|
/**
|
|
@@ -111,26 +123,6 @@ export type QueriesToQueryRowsPromises<Q extends Queries> = {
|
|
|
111
123
|
|
|
112
124
|
export type QueryRowsMap = ReadonlyMap<Query, ReadonlyArray<Row>>;
|
|
113
125
|
|
|
114
|
-
export interface QueryRowsCache {
|
|
115
|
-
readonly set: (
|
|
116
|
-
queriesRows: ReadonlyArray<readonly [Query, ReadonlyArray<SqliteRow>]>,
|
|
117
|
-
) => void;
|
|
118
|
-
readonly get: () => QueryRowsMap;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export const createQueryRowsCache = (): QueryRowsCache => {
|
|
122
|
-
let queryRowsCache: QueryRowsMap = new Map();
|
|
123
|
-
|
|
124
|
-
const cache: QueryRowsCache = {
|
|
125
|
-
set: (queriesRows) => {
|
|
126
|
-
queryRowsCache = new Map([...queryRowsCache, ...queriesRows]);
|
|
127
|
-
},
|
|
128
|
-
get: () => queryRowsCache,
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
return cache;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
126
|
export interface SubscribedQueries {
|
|
135
127
|
subscribe: (query: Query) => StoreSubscribe;
|
|
136
128
|
|
|
@@ -166,3 +158,206 @@ export const createSubscribedQueries = (
|
|
|
166
158
|
|
|
167
159
|
return subscribedQueries;
|
|
168
160
|
};
|
|
161
|
+
|
|
162
|
+
export interface GetQueryRowsCacheDep {
|
|
163
|
+
readonly getQueryRowsCache: GetQueryRowsCache;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type GetQueryRowsCache = (tabId: Id) => QueryRowsCache;
|
|
167
|
+
|
|
168
|
+
export interface QueryRowsCache {
|
|
169
|
+
readonly set: (
|
|
170
|
+
queriesRows: ReadonlyArray<readonly [Query, ReadonlyArray<SqliteRow>]>,
|
|
171
|
+
) => void;
|
|
172
|
+
readonly get: () => QueryRowsMap;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const createGetQueryRowsCache = (): GetQueryRowsCache => {
|
|
176
|
+
const tabQueryRowsCacheMap = new Map<Id, QueryRowsCache>();
|
|
177
|
+
|
|
178
|
+
return (tabId: Id) => {
|
|
179
|
+
let cache = tabQueryRowsCacheMap.get(tabId);
|
|
180
|
+
if (!cache) {
|
|
181
|
+
let queryRowsCache: QueryRowsMap = new Map();
|
|
182
|
+
cache = {
|
|
183
|
+
set: (queriesRows) => {
|
|
184
|
+
queryRowsCache = new Map([...queryRowsCache, ...queriesRows]);
|
|
185
|
+
},
|
|
186
|
+
get: () => queryRowsCache,
|
|
187
|
+
};
|
|
188
|
+
tabQueryRowsCacheMap.set(tabId, cache);
|
|
189
|
+
}
|
|
190
|
+
return cache;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const loadQueries =
|
|
195
|
+
(deps: GetQueryRowsCacheDep & SqliteDep) =>
|
|
196
|
+
(
|
|
197
|
+
tabId: Id,
|
|
198
|
+
queries: ReadonlyArray<Query>,
|
|
199
|
+
): Result<ReadonlyArray<QueryPatches>, SqliteError> => {
|
|
200
|
+
const queriesRows = [];
|
|
201
|
+
|
|
202
|
+
for (const query of queries) {
|
|
203
|
+
const sqlQuery = deserializeQuery(query);
|
|
204
|
+
const result = deps.sqlite.exec(sqlQuery);
|
|
205
|
+
if (!result.ok) return result;
|
|
206
|
+
|
|
207
|
+
queriesRows.push([query, result.value.rows] as const);
|
|
208
|
+
if (sqlQuery.options?.logExplainQueryPlan) {
|
|
209
|
+
explainSqliteQueryPlan(deps)(sqlQuery);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const queryRowsCache = deps.getQueryRowsCache(tabId);
|
|
214
|
+
|
|
215
|
+
const previousState = queryRowsCache.get();
|
|
216
|
+
queryRowsCache.set(queriesRows);
|
|
217
|
+
|
|
218
|
+
const currentState = queryRowsCache.get();
|
|
219
|
+
|
|
220
|
+
const queryPatchesArray = queries.map(
|
|
221
|
+
(query): QueryPatches => ({
|
|
222
|
+
query,
|
|
223
|
+
patches: makePatches(
|
|
224
|
+
previousState.get(query),
|
|
225
|
+
currentState.get(query) ?? emptyRows,
|
|
226
|
+
),
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
return ok(queryPatchesArray);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export interface QueryPatches {
|
|
233
|
+
readonly query: Query;
|
|
234
|
+
readonly patches: ReadonlyArray<Patch>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type Patch = ReplaceAllPatch | ReplaceAtPatch;
|
|
238
|
+
|
|
239
|
+
export interface ReplaceAllPatch {
|
|
240
|
+
readonly op: "replaceAll";
|
|
241
|
+
readonly value: ReadonlyArray<Row>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface ReplaceAtPatch {
|
|
245
|
+
readonly op: "replaceAt";
|
|
246
|
+
readonly index: number;
|
|
247
|
+
readonly value: Row;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* We detect only changes in the whole result and in-place edits. In the future,
|
|
252
|
+
* we will add more heuristics. We will probably not implement the Myers diff
|
|
253
|
+
* algorithm because it's faster to rerender all than to compute many detailed
|
|
254
|
+
* patches. We will only implement logic a developer would implement manually,
|
|
255
|
+
* if necessary.
|
|
256
|
+
*/
|
|
257
|
+
export const makePatches = (
|
|
258
|
+
previousRows: ReadonlyArray<Row> | undefined,
|
|
259
|
+
nextRows: ReadonlyArray<Row>,
|
|
260
|
+
): ReadonlyArray<Patch> => {
|
|
261
|
+
if (previousRows === undefined)
|
|
262
|
+
return [{ op: "replaceAll", value: nextRows }];
|
|
263
|
+
// TODO: Detect prepend and append, it's cheap.
|
|
264
|
+
if (previousRows.length !== nextRows.length) {
|
|
265
|
+
return [{ op: "replaceAll", value: nextRows }];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const length = previousRows.length;
|
|
269
|
+
const replaceAtPatches: Array<ReplaceAtPatch> = [];
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < length; i++) {
|
|
272
|
+
const previousRow = previousRows[i];
|
|
273
|
+
const nextRow = nextRows[i];
|
|
274
|
+
|
|
275
|
+
// We expect the same shape for both rows.
|
|
276
|
+
for (const key in previousRow)
|
|
277
|
+
if (
|
|
278
|
+
!eqSqliteValue(
|
|
279
|
+
previousRow[key] as SqliteValue,
|
|
280
|
+
nextRow[key] as SqliteValue,
|
|
281
|
+
)
|
|
282
|
+
) {
|
|
283
|
+
replaceAtPatches.push({ op: "replaceAt", value: nextRow, index: i });
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (length > 0 && replaceAtPatches.length === length) {
|
|
289
|
+
return [{ op: "replaceAll", value: nextRows }];
|
|
290
|
+
}
|
|
291
|
+
return replaceAtPatches;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export const applyPatches = (
|
|
295
|
+
patches: ReadonlyArray<Patch>,
|
|
296
|
+
current: ReadonlyArray<Row>,
|
|
297
|
+
): ReadonlyArray<Row> =>
|
|
298
|
+
patches.reduce((next, patch) => {
|
|
299
|
+
switch (patch.op) {
|
|
300
|
+
case "replaceAll":
|
|
301
|
+
return parseSqliteJsonArray(patch.value);
|
|
302
|
+
case "replaceAt": {
|
|
303
|
+
const parsedRow = parseSqliteJsonArray([patch.value])[0];
|
|
304
|
+
return next.toSpliced(patch.index, 1, parsedRow);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}, current);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A unique identifier prepended to JSON-encoded strings. This allows safe
|
|
311
|
+
* detection and parsing of only those columns that require JSON.parse.
|
|
312
|
+
*
|
|
313
|
+
* The identifier is a cryptographically random Evolu Id, ensuring uniqueness
|
|
314
|
+
* and preventing malicious actors from inserting fake data that could be
|
|
315
|
+
* misinterpreted as JSON by the application.
|
|
316
|
+
*
|
|
317
|
+
* Note: The same queries created by different browser tabs will have different
|
|
318
|
+
* identifiers and thus be considered different and cached separately. This is
|
|
319
|
+
* usually not a big deal, but if needed, the DB cache can be optimized by
|
|
320
|
+
* passing the kyselyJsonIdentifier into the DB worker during initialization,
|
|
321
|
+
* allowing queries to be grouped and recognized across tabs or sessions.
|
|
322
|
+
*
|
|
323
|
+
* See: https://github.com/kysely-org/kysely/issues/1372#issuecomment-2702773948
|
|
324
|
+
*/
|
|
325
|
+
export const kyselyJsonIdentifier = createId({
|
|
326
|
+
randomBytes: createRandomBytes(),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
export const parseSqliteJsonArray = <T>(
|
|
330
|
+
arr: ReadonlyArray<T>,
|
|
331
|
+
): ReadonlyArray<T> => {
|
|
332
|
+
const result = new Array<T>(arr.length);
|
|
333
|
+
for (let i = 0; i < arr.length; ++i) {
|
|
334
|
+
result[i] = parse(arr[i]) as T;
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const parse = (obj: unknown): unknown => {
|
|
340
|
+
if (String.is(obj) && obj.startsWith(kyselyJsonIdentifier)) {
|
|
341
|
+
return JSON.parse(obj.slice(kyselyJsonIdentifier.length));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (Array.isArray(obj)) {
|
|
345
|
+
return parseSqliteJsonArray(obj);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (isPlainObject(obj)) {
|
|
349
|
+
return parseObject(obj);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return obj;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const parseObject = (
|
|
356
|
+
obj: ReadonlyRecord<string, unknown>,
|
|
357
|
+
): ReadonlyRecord<string, unknown> => {
|
|
358
|
+
const result = createRecord();
|
|
359
|
+
for (const key in obj) {
|
|
360
|
+
result[key] = parse(obj[key]);
|
|
361
|
+
}
|
|
362
|
+
return result as ReadonlyRecord<string, unknown>;
|
|
363
|
+
};
|
package/src/local-first/Relay.ts
CHANGED
|
@@ -217,13 +217,8 @@ export const createRelaySqliteStorage =
|
|
|
217
217
|
(storedBytes ?? 0) + incomingBytes,
|
|
218
218
|
);
|
|
219
219
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
newStoredBytes,
|
|
223
|
-
);
|
|
224
|
-
const isWithinQuota = isAsync(withinQuotaResult)
|
|
225
|
-
? await withinQuotaResult
|
|
226
|
-
: withinQuotaResult;
|
|
220
|
+
const result = config.isOwnerWithinQuota(ownerId, newStoredBytes);
|
|
221
|
+
const isWithinQuota = isAsync(result) ? await result : result;
|
|
227
222
|
if (!isWithinQuota) {
|
|
228
223
|
return err({ type: "StorageQuotaError", ownerId });
|
|
229
224
|
}
|
|
@@ -240,30 +235,31 @@ export const createRelaySqliteStorage =
|
|
|
240
235
|
lastTimestamp,
|
|
241
236
|
);
|
|
242
237
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
238
|
+
{
|
|
239
|
+
const result = sqliteStorageBase.insertTimestamp(
|
|
240
|
+
ownerIdBytes,
|
|
241
|
+
timestamp,
|
|
242
|
+
strategy,
|
|
243
|
+
);
|
|
244
|
+
if (!result.ok) return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
{
|
|
248
|
+
const result = deps.sqlite.exec(sql`
|
|
249
|
+
insert into evolu_message ("ownerId", "timestamp", "change")
|
|
250
|
+
values (${ownerIdBytes}, ${timestamp}, ${change})
|
|
251
|
+
on conflict do nothing;
|
|
252
|
+
`);
|
|
253
|
+
if (!result.ok) return result;
|
|
254
|
+
}
|
|
256
255
|
}
|
|
257
256
|
|
|
258
|
-
|
|
257
|
+
return updateOwnerUsage(deps)(
|
|
259
258
|
ownerIdBytes,
|
|
260
259
|
newStoredBytes,
|
|
261
260
|
firstTimestamp,
|
|
262
261
|
lastTimestamp,
|
|
263
262
|
);
|
|
264
|
-
if (!updateUsage.ok) return updateUsage;
|
|
265
|
-
|
|
266
|
-
return ok();
|
|
267
263
|
});
|
|
268
264
|
})();
|
|
269
265
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as Kysely from "kysely";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createRecord,
|
|
4
|
+
getProperty,
|
|
5
|
+
mapObject,
|
|
6
|
+
ReadonlyRecord,
|
|
7
|
+
} from "../Object.js";
|
|
3
8
|
import { ok, Result } from "../Result.js";
|
|
4
9
|
import {
|
|
5
10
|
SafeSql,
|
|
@@ -30,6 +35,8 @@ import {
|
|
|
30
35
|
omit,
|
|
31
36
|
optional,
|
|
32
37
|
OptionalType,
|
|
38
|
+
record,
|
|
39
|
+
set,
|
|
33
40
|
String,
|
|
34
41
|
TableId,
|
|
35
42
|
Type,
|
|
@@ -40,8 +47,9 @@ import {
|
|
|
40
47
|
import { Simplify } from "../Types.js";
|
|
41
48
|
import { AppOwner, OwnerId } from "./Owner.js";
|
|
42
49
|
import { Query, Row } from "./Query.js";
|
|
43
|
-
import { CrdtMessage, DbChange } from "./Storage.js";
|
|
50
|
+
import type { CrdtMessage, DbChange } from "./Storage.js";
|
|
44
51
|
import { Timestamp, TimestampBytes } from "./Timestamp.js";
|
|
52
|
+
import { readonly } from "../Function.js";
|
|
45
53
|
|
|
46
54
|
/**
|
|
47
55
|
* Defines the schema of an Evolu database.
|
|
@@ -169,12 +177,10 @@ export const evoluSchemaToDbSchema = (
|
|
|
169
177
|
schema: EvoluSchema,
|
|
170
178
|
indexesConfig?: IndexesConfig,
|
|
171
179
|
): DbSchema => {
|
|
172
|
-
const tables =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
.map(([k]) => k),
|
|
177
|
-
}));
|
|
180
|
+
const tables = mapObject(
|
|
181
|
+
schema,
|
|
182
|
+
(table) => new Set(Object.keys(table).filter((k) => k !== "id")),
|
|
183
|
+
);
|
|
178
184
|
|
|
179
185
|
const indexes = indexesConfig
|
|
180
186
|
? indexesConfig(createIndex).map(
|
|
@@ -206,6 +212,13 @@ export type CreateQuery<S extends EvoluSchema> = <R extends Row>(
|
|
|
206
212
|
readonly column: string;
|
|
207
213
|
readonly value: SqliteValue;
|
|
208
214
|
};
|
|
215
|
+
readonly evolu_message_quarantine: {
|
|
216
|
+
readonly timestamp: TimestampBytes;
|
|
217
|
+
readonly table: string;
|
|
218
|
+
readonly id: IdBytes;
|
|
219
|
+
readonly column: string;
|
|
220
|
+
readonly value: SqliteValue;
|
|
221
|
+
};
|
|
209
222
|
}
|
|
210
223
|
>,
|
|
211
224
|
"selectFrom" | "fn" | "with" | "withRecursive"
|
|
@@ -231,7 +244,11 @@ export const SystemColumns = object({
|
|
|
231
244
|
});
|
|
232
245
|
export type SystemColumns = typeof SystemColumns.Type;
|
|
233
246
|
|
|
234
|
-
export const systemColumns =
|
|
247
|
+
export const systemColumns = readonly(
|
|
248
|
+
new Set(Object.keys(SystemColumns.props)),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
export const systemColumnsWithId = readonly([...systemColumns, "id"]);
|
|
235
252
|
|
|
236
253
|
export type MutationKind = "insert" | "update" | "upsert";
|
|
237
254
|
|
|
@@ -443,21 +460,21 @@ export type InferColumnErrors<
|
|
|
443
460
|
>;
|
|
444
461
|
}[keyof MutationMapping<T, M>];
|
|
445
462
|
|
|
446
|
-
export const DbTable = object({
|
|
447
|
-
name: String,
|
|
448
|
-
columns: array(String),
|
|
449
|
-
});
|
|
450
|
-
export type DbTable = typeof DbTable.Type;
|
|
451
|
-
|
|
452
463
|
export const DbIndex = object({ name: String, sql: String });
|
|
453
464
|
export type DbIndex = typeof DbIndex.Type;
|
|
454
465
|
|
|
455
466
|
export const DbSchema = object({
|
|
456
|
-
tables:
|
|
467
|
+
tables: record(String, set(String)),
|
|
457
468
|
indexes: array(DbIndex),
|
|
458
469
|
});
|
|
459
470
|
export type DbSchema = typeof DbSchema.Type;
|
|
460
471
|
|
|
472
|
+
// TODO: Use a ref and update dbSchema on hot reloading to support
|
|
473
|
+
// development workflows where schema changes without full app restart.
|
|
474
|
+
export interface DbSchemaDep {
|
|
475
|
+
readonly dbSchema: DbSchema;
|
|
476
|
+
}
|
|
477
|
+
|
|
461
478
|
/** Get the current database schema by reading SQLite metadata. */
|
|
462
479
|
export const getDbSchema =
|
|
463
480
|
(deps: SqliteDep) =>
|
|
@@ -465,7 +482,7 @@ export const getDbSchema =
|
|
|
465
482
|
DbSchema,
|
|
466
483
|
SqliteError
|
|
467
484
|
> => {
|
|
468
|
-
const
|
|
485
|
+
const tables = createRecord<string, Set<string>>();
|
|
469
486
|
|
|
470
487
|
const tableAndColumnInfoRows = deps.sqlite.exec(sql`
|
|
471
488
|
select
|
|
@@ -483,12 +500,9 @@ export const getDbSchema =
|
|
|
483
500
|
tableName: string;
|
|
484
501
|
columnName: string;
|
|
485
502
|
};
|
|
486
|
-
|
|
487
|
-
map.get(tableName)?.push(columnName);
|
|
503
|
+
(tables[tableName] ??= new Set()).add(columnName);
|
|
488
504
|
});
|
|
489
505
|
|
|
490
|
-
const tables = Array.from(map, ([name, columns]) => ({ name, columns }));
|
|
491
|
-
|
|
492
506
|
const indexesRows = deps.sqlite.exec(
|
|
493
507
|
allIndexes
|
|
494
508
|
? sql`
|
|
@@ -532,55 +546,54 @@ export const ensureDbSchema =
|
|
|
532
546
|
(deps: SqliteDep) =>
|
|
533
547
|
(
|
|
534
548
|
newSchema: DbSchema,
|
|
535
|
-
currentSchema
|
|
536
|
-
options?: { ignoreIndexes: boolean },
|
|
549
|
+
currentSchema?: DbSchema,
|
|
537
550
|
): Result<void, SqliteError> => {
|
|
538
551
|
const queries: Array<SqliteQuery> = [];
|
|
539
552
|
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
553
|
+
if (!currentSchema) {
|
|
554
|
+
const dbSchema = getDbSchema(deps)();
|
|
555
|
+
if (!dbSchema.ok) return dbSchema;
|
|
556
|
+
currentSchema = dbSchema.value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
for (const [tableName, newColumns] of Object.entries(newSchema.tables)) {
|
|
560
|
+
const currentColumns = getProperty(currentSchema.tables, tableName);
|
|
561
|
+
if (!currentColumns) {
|
|
562
|
+
queries.push(createAppTable(tableName, newColumns));
|
|
546
563
|
} else {
|
|
547
|
-
|
|
548
|
-
.
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
`);
|
|
554
|
-
});
|
|
564
|
+
for (const newColumn of newColumns.difference(currentColumns)) {
|
|
565
|
+
queries.push(sql`
|
|
566
|
+
alter table ${sql.identifier(tableName)}
|
|
567
|
+
add column ${sql.identifier(newColumn)} any;
|
|
568
|
+
`);
|
|
569
|
+
}
|
|
555
570
|
}
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
if (options?.ignoreIndexes !== true) {
|
|
559
|
-
// Remove current indexes that are not in the newSchema.
|
|
560
|
-
currentSchema.indexes
|
|
561
|
-
.filter(
|
|
562
|
-
(currentIndex) =>
|
|
563
|
-
!newSchema.indexes.some((newIndex) =>
|
|
564
|
-
indexesAreEqual(newIndex, currentIndex),
|
|
565
|
-
),
|
|
566
|
-
)
|
|
567
|
-
.forEach((index) => {
|
|
568
|
-
queries.push(sql`drop index ${sql.identifier(index.name)};`);
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
// Add new indexes that are not in the currentSchema.
|
|
572
|
-
newSchema.indexes
|
|
573
|
-
.filter(
|
|
574
|
-
(newIndex) =>
|
|
575
|
-
!currentSchema.indexes.some((currentIndex) =>
|
|
576
|
-
indexesAreEqual(newIndex, currentIndex),
|
|
577
|
-
),
|
|
578
|
-
)
|
|
579
|
-
.forEach((newIndex) => {
|
|
580
|
-
queries.push({ sql: `${newIndex.sql};` as SafeSql, parameters: [] });
|
|
581
|
-
});
|
|
582
571
|
}
|
|
583
572
|
|
|
573
|
+
// Remove current indexes that are not in the newSchema.
|
|
574
|
+
currentSchema.indexes
|
|
575
|
+
.filter(
|
|
576
|
+
(currentIndex) =>
|
|
577
|
+
!newSchema.indexes.some((newIndex) =>
|
|
578
|
+
indexesAreEqual(newIndex, currentIndex),
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
.forEach((index) => {
|
|
582
|
+
queries.push(sql`drop index ${sql.identifier(index.name)};`);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Add new indexes that are not in the currentSchema.
|
|
586
|
+
newSchema.indexes
|
|
587
|
+
.filter(
|
|
588
|
+
(newIndex) =>
|
|
589
|
+
!currentSchema.indexes.some((currentIndex) =>
|
|
590
|
+
indexesAreEqual(newIndex, currentIndex),
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
.forEach((newIndex) => {
|
|
594
|
+
queries.push({ sql: `${newIndex.sql};` as SafeSql, parameters: [] });
|
|
595
|
+
});
|
|
596
|
+
|
|
584
597
|
for (const query of queries) {
|
|
585
598
|
const result = deps.sqlite.exec(query);
|
|
586
599
|
if (!result.ok) return result;
|
|
@@ -588,19 +601,19 @@ export const ensureDbSchema =
|
|
|
588
601
|
return ok();
|
|
589
602
|
};
|
|
590
603
|
|
|
591
|
-
const createAppTable = (
|
|
592
|
-
create table ${sql.identifier(
|
|
604
|
+
const createAppTable = (tableName: string, columns: ReadonlySet<string>) => sql`
|
|
605
|
+
create table ${sql.identifier(tableName)} (
|
|
593
606
|
"id" text,
|
|
594
607
|
${sql.raw(
|
|
595
|
-
`${systemColumns
|
|
596
|
-
.concat(table.columns)
|
|
597
|
-
.filter((c) => c !== "id")
|
|
608
|
+
`${[...systemColumns, ...columns]
|
|
598
609
|
// With strict tables and any type, data is preserved exactly as received
|
|
599
610
|
// without any type affinity coercion. This allows storing any data type
|
|
600
611
|
// while maintaining strict null enforcement for primary key columns.
|
|
612
|
+
// TODO: Use proper SQLite types for system columns (text for createdAt,
|
|
613
|
+
// updatedAt, ownerId, integer for isDeleted) instead of "any".
|
|
601
614
|
.map((name) => `${sql.identifier(name).sql} any`)
|
|
602
|
-
.join(", ")}`,
|
|
603
|
-
)}
|
|
615
|
+
.join(", ")}, `,
|
|
616
|
+
)}
|
|
604
617
|
primary key ("ownerId", "id")
|
|
605
618
|
)
|
|
606
619
|
without rowid, strict;
|