@evolu/common 7.2.3 → 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 +16 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +16 -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 +109 -108
- 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 +6 -7
- 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.map +1 -1
- package/dist/src/local-first/Protocol.js +10 -10
- 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 +9 -4
- package/dist/src/local-first/Sync.d.ts.map +1 -1
- package/dist/src/local-first/Sync.js +132 -59
- 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 +20 -0
- 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 +181 -209
- package/src/local-first/Evolu.ts +9 -6
- package/src/local-first/LocalAuth.ts +2 -2
- package/src/local-first/Owner.ts +4 -0
- package/src/local-first/Protocol.ts +10 -16
- 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 +190 -66
- 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
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;
|
|
@@ -26,13 +26,13 @@ import {
|
|
|
26
26
|
TypeError,
|
|
27
27
|
} from "../Type.js";
|
|
28
28
|
import {
|
|
29
|
-
OwnerError,
|
|
30
29
|
Owner,
|
|
30
|
+
OwnerError,
|
|
31
31
|
OwnerId,
|
|
32
32
|
OwnerIdBytes,
|
|
33
33
|
OwnerWriteKey,
|
|
34
34
|
} from "./Owner.js";
|
|
35
|
-
import {
|
|
35
|
+
import { systemColumnsWithId } from "./Schema.js";
|
|
36
36
|
import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js";
|
|
37
37
|
|
|
38
38
|
export interface StorageConfig {
|
|
@@ -256,16 +256,11 @@ export interface CrdtMessage {
|
|
|
256
256
|
export const DbChangeValues = record(String, SqliteValue);
|
|
257
257
|
export type DbChangeValues = typeof DbChangeValues.Type;
|
|
258
258
|
|
|
259
|
-
// "createdAt" and "updatedAt" are derived from Timestamp.
|
|
260
|
-
// "id" and "isDeleted" are encoded separately to save bytes
|
|
261
|
-
// "ownerId" is part of the protocol message
|
|
262
|
-
const forbiddenSystemColumns = systemColumns.concat("id");
|
|
263
|
-
|
|
264
259
|
export const ValidDbChangeValues = brand(
|
|
265
260
|
"ValidDbChangeValues",
|
|
266
261
|
DbChangeValues,
|
|
267
262
|
(value) => {
|
|
268
|
-
const invalidColumns =
|
|
263
|
+
const invalidColumns = systemColumnsWithId.filter((key) => key in value);
|
|
269
264
|
if (invalidColumns.length > 0)
|
|
270
265
|
return err<ValidDbChangeValuesError>({
|
|
271
266
|
type: "ValidDbChangeValues",
|